diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..4005069 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target-dir = "/Volumes/Devel/provisioning/platform/target" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..beb8574 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,138 @@ +# Platform Changelog + +## [Unreleased] + +### Added + +#### ops control plane (`crates/ops-keeper`, `crates/ops-controller`) + +New two-component ops control plane implementing secure, auditable operation dispatch. + +**ops-keeper** — policy gate before any operation reaches the orchestrator: +- Glob-based `PolicyDef` matching on `op_type`, `image_patterns`, `target_patterns` +- `Signer` issues compact Ed25519 JWTs (`OpsClaims`) on policy approval +- NATS JetStream pull consumer on `ops.pending.*` +- `AuditEvent` emission to `ops.audit.*` on every terminal decision + +**ops-controller** — durable executor with at-least-once delivery: +- Verifies keeper-signed JWT before dispatching to orchestrator HTTP API +- SurrealDB idempotency store; `reconcile_pending` reconciles stale ops on startup +- Structured `AckResult` (Ack / Nak / Term) per JetStream semantics +- Emits audit events to `ops.audit.*` on every terminal outcome + +**ADR**: ADR-038 (ops control plane design) + +#### audit-mirror (`crates/audit-mirror`) + +Sidecar that consumes `ops.audit.*` and creates a signed git commit per event in a local Radicle repository, then announces the repo. JTI deduplication prevents double-commits on consumer redelivery. + +**ADR**: ADR-038 + +#### contract-tests (`crates/contract-tests`) + +G3 contract test suite verifying semantic equivalence across three tiers: +- **Tier A**: direct registry invocation (reference baseline) +- **Tier B**: axum HTTP daemon on ephemeral port +- **Tier C**: in-process MCP `handle_request` + +Normaliser strips volatile fields; JSON schema validates every tier output against `listing_output_schema`. Regression guard for CLI↔HTTP↔MCP drift. + +#### control-center: NATS→WebSocket bridge (`src/handlers/nats_bridge.rs`) + +Eliminates long-polling between control-center UI and orchestrator. Durable JetStream consumer `cc-task-status-bridge` subscribes to `TASKS` stream and re-broadcasts each `TaskStatusPayload` to all connected WebSocket clients via `WebSocketManager`. + +#### API catalog endpoints (`crates/ai-service`, `crates/control-center`) + +`GET /api-catalog` added to ai-service and control-center. Each handler uses `inventory::iter::()` to emit a sorted JSON catalog of all registered routes — self-describing API surface without a separate OpenAPI toolchain. + +#### SOLID boundary pre-commit check (`.pre-commit-hooks/solid-boundary-check.sh`) + +Shell hook enforced in `.pre-commit-config.yaml`. Fails the commit if a crate imports across declared SOLID layer boundaries, keeping dependency direction consistent before code reaches CI. + +### Removed / Lifted + +- **`crates/backup-manager`** — lifted to `cloudatasave` (LibreCloud/cloudDataSave). **ADR-041**. +- **`crates/buildkit-launcher`** — lifted to `lian-build`. **ADR-040**. +- **`crates/daemon`** — `Cargo.toml` removed; binary consolidated into `provisioning-daemon`. +- **`prov-ecosystem/crates/nu-daemon`** / **`daemon-cli`** — excluded from workspace; `rustls=0.23.28` pin conflicts with surrealdb@3 (`^0.23.36`). Build standalone with `cargo build -p nu-daemon` until `nu-command` relaxes the pin. + +--- + +## [3.6.0] — 2026-04-17 + +### Added + +#### ncl-sync daemon (`crates/ncl-sync/`) + +New Rust daemon that eliminates `nickel export` latency from CLI commands by pre-compiling NCL files and maintaining a shared cache. + +- **File watching**: `notify` watcher on workspace NCL directories; re-exports automatically on file change +- **Warm-up**: on `prvng platform start`, scans all NCL files in the workspace and exports stale entries concurrently (configurable concurrency) +- **Shared cache**: `~/.cache/provisioning/config-cache/` — same directory and key strategy as `nu_plugin_nickel` +- **Content-addressed keys**: `SHA256(file_content + sorted_import_paths + format)` — zero coordination overhead between daemon and plugin +- **Sync requests**: Nu processes write `.sync-.json` sidecars after mutations; daemon drains them within 500 ms +- **CLI subcommands**: `daemon`, `warm`, `invalidate`, `key`, `stats` +- **Config-driven** via `platform-config`: `platform/config/ncl-sync.ncl` — idle timeout, concurrency, poll interval, extra import paths +- **No platform dependencies**: intentionally avoids NATS, SurrealDB, and orchestrator to prevent bootstrap circularity + +#### Platform lifecycle integration + +- `prvng platform start` now launches `ncl-sync daemon` (via `ncl-sync-start` in `service-manager.nu`) +- `prvng platform stop` stops ncl-sync (via PID file) +- `prvng platform status` shows ncl-sync running state + +#### Nu cache layer + +- `lib_provisioning/config/cache/core.nu`: implemented `cache-lookup`, `cache-write`, `write-sync-request` (previously no-ops) +- `lib_provisioning/config/cache/nickel.nu`: implemented `lookup-nickel-cache`, `derive-ncl-cache-key`, `request-ncl-sync` + +#### nickel_processor wrappers + +New `lib_provisioning/utils/nickel_processor.nu` functions: +- `ncl-eval`: drop-in for `^nickel export ... | from json` — checks plugin cache, propagates error on failure +- `ncl-eval-soft`: soft-failure variant with configurable fallback value + +#### nu_plugin_nickel updates + +- `nickel-eval` and `nickel-export` now accept `--import-path LIST` flag (`-I`) +- New command `nickel-cache-key`: prints the cache key for a file (parity testing) +- Cache key derivation updated to `SHA256(file_content + sorted_import_paths + format)` — aligned with ncl-sync +- Cache key now includes import paths: same file with different import paths produces different cache entries + +#### Hot-path migration (C1) + +Replaced `^nickel export ... | from json` with `ncl-eval`/`ncl-eval-soft` in the four highest-frequency call sites: +- `main_provisioning/dispatcher.nu` — commands-registry load +- `main_provisioning/components.nu` — workspace settings export +- `main_provisioning/workflow.nu` — workflow NCL exports +- `main_provisioning/extensions.nu` — per-extension metadata + +#### Extended migration (C2 + C3) + +Migrated remaining high-value call sites (~55 additional sites across 30+ files) including `config/export.nu`, `taskservs/discover.nu`, `servers/create.nu`, `ontoref-queries.nu`, `service-manager.nu`, `dag.nu`, and others. + +#### ADRs + +- `adrs/adr-022-ncl-sync-daemon.ncl`: daemon design decisions, key strategy rationale, constraint enforcement +- `adrs/adr-023-ncl-export-wrapper.ncl`: `ncl-eval`/`ncl-eval-soft` wrapper design, migration strategy + +#### Tests + +- `tests/cache/test_key_parity.nu`: validates that ncl-sync and nu_plugin_nickel produce identical keys for the same `(file, import_paths, format)` triple + +### Performance + +| Command | Before | After (warm cache) | +|---------|--------|--------------------| +| `prvng component list` | 3–7 s | ~1.5 s | +| `prvng workflow list` | 3–5 s | ~1.5 s | +| `prvng deploy` | 15–30 s | ~3–5 s | +| Multi-export commands (ontoref) | 12–30 s | ~1.5 s | + +Nu module parse startup (~1.2 s) is unaffected — separate concern. + +--- + +## [3.5.0] — 2025-10-07 + +Initial platform version with orchestrator, control center, installer, MCP server, vault service, and extension registry. diff --git a/Cargo.toml b/Cargo.toml index 186752e..1eb1735 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,24 +11,39 @@ members = [ "crates/control-center", "crates/control-center-ui", "crates/vault-service", - "crates/detector", "crates/mcp-server", - "crates/daemon", - "prov-ecosystem/crates/daemon-cli", + # lifted: "crates/backup-manager" → cloudatasave (LibreCloud/cloudDataSave) — adr-041 + # archived: "crates/detector" → archive/detector (no dependents, stale since Jan 2026) "prov-ecosystem/crates/machines", "prov-ecosystem/crates/encrypt", "prov-ecosystem/crates/backup", "prov-ecosystem/crates/observability", + "crates/ncl-sync", + "crates/prvng-cli", + "crates/provisioning-core", + "crates/provisioning-tool", + "crates/provisioning-daemon", + "crates/contract-tests", + "crates/extension-manager", + "crates/ops-keeper", + "crates/audit-mirror", + "crates/ops-controller", + # lifted: "crates/buildkit-launcher" → lian-build — adr-040 ] exclude = [ - "syntaxis", - "syntaxis/core", + # archived: syntaxis/ → archive/syntaxis (Jan 2026, all refs commented out) + # archived: stratumiops/ → archive/stratumiops (Jan 2026, workspace uses canonical ../../../Development/stratumiops) "prov-ecosystem/crates/syntaxis-integration", "prov-ecosystem/crates/audit", "prov-ecosystem/crates/valida", "prov-ecosystem/crates/runtime", "prov-ecosystem/crates/gitops", + # nu-daemon + daemon-cli are excluded: nu-command@0.110.0 (via nushell feature) pins + # rustls=0.23.28, hard conflict with surrealdb@3 (requires ^0.23.36). Not resolvable + # until nu-command relaxes its rustls pin. Build standalone: cargo build -p nu-daemon + "crates/nu-daemon", + "prov-ecosystem/crates/daemon-cli", ] resolver = "2" @@ -97,12 +112,16 @@ resolver = "2" # DATABASE AND STORAGE # ============================================================================ sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "chrono", "uuid"] } - surrealdb = { version = "2.6", features = ["kv-mem", "protocol-ws", "protocol-http"] } + # kv-surrealkv: core relational/graph (orchestrator state, control-center) + # kv-rocksdb: hot data (embeddings cache, audit logs) — via platform-db embedded-rocksdb feature + # rustls excluded: nu-command@0.110.0 pins rustls=0.23.28, SurrealDB 3 requires ^0.23.36 (conflict) + # TLS for remote connections is handled at the proxy layer (nginx/Caddy) in production. + surrealdb = { version = "3", features = ["kv-mem", "kv-surrealkv", "protocol-ws"], default-features = false } # ============================================================================ # MESSAGING (NATS) # ============================================================================ - async-nats = "0.40" + async-nats = "0.46" # ============================================================================ # SECURITY AND CRYPTOGRAPHY @@ -112,7 +131,7 @@ resolver = "2" base64 = "0.22" git2 = { version = "0.20", default-features = false, features = ["https", "ssh"] } hmac = "0.12" - jsonwebtoken = { version = "10.3", features = ["rust_crypto"] } + jsonwebtoken = { version = "10.3", default-features = false, features = ["aws_lc_rs"] } rand = { version = "0.9", features = ["std_rng", "os_rng"] } ring = "0.17" sha2 = "0.10" @@ -127,12 +146,18 @@ resolver = "2" # ============================================================================ regex = "1.12" validator = { version = "0.20", features = ["derive"] } + globset = "0.4" # ============================================================================ # GRAPH ALGORITHMS AND UTILITIES # ============================================================================ petgraph = "0.8" + # ============================================================================ + # CONCURRENT DATA STRUCTURES + # ============================================================================ + dashmap = "6" + # ============================================================================ # ADDITIONAL SHARED DEPENDENCIES # ============================================================================ @@ -250,10 +275,8 @@ resolver = "2" xxhash-rust = { version = "0.8", features = ["xxh3"] } # ============================================================================ - # RAG FRAMEWORK DEPENDENCIES (Rig) + # RAG AND TEXT PROCESSING # ============================================================================ - rig-core = "0.30" - rig-surrealdb = "0.1" tokenizers = "0.22" # ============================================================================ @@ -261,8 +284,8 @@ resolver = "2" # ============================================================================ moka = { version = "0.12", features = ["future"] } sled = "0.34" - fastembed = "5.8" - lancedb = "0.23" + fastembed = "5.11" + lancedb = "0.26" arrow = "=56" # ============================================================================ @@ -271,30 +294,38 @@ resolver = "2" platform-config = { path = "./crates/platform-config" } platform-nats = { path = "./crates/platform-nats" } platform-db = { path = "./crates/platform-db" } - service-clients = { path = "./crates/service-clients" } - rag = { path = "./crates/rag" } - mcp-server = { path = "./crates/mcp-server" } + platform-clients = { path = "./crates/service-clients" } + platform-rag = { path = "./crates/rag" } + provisioning-mcp = { path = "./crates/mcp-server" } ai-service = { path = "./crates/ai-service" } # ============================================================================ # PROV-ECOSYSTEM (Now members of workspace) # ============================================================================ daemon-cli = { path = "./prov-ecosystem/crates/daemon-cli" } - machines = { path = "./prov-ecosystem/crates/machines" } - encrypt = { path = "./prov-ecosystem/crates/encrypt" } - backup = { path = "./prov-ecosystem/crates/backup" } - observability = { path = "./prov-ecosystem/crates/observability" } + platform-machines = { path = "./prov-ecosystem/crates/machines" } + platform-encrypt = { path = "./prov-ecosystem/crates/encrypt" } + platform-backup = { path = "./prov-ecosystem/crates/backup" } + platform-observability = { path = "./prov-ecosystem/crates/observability" } init-servs = { path = "./prov-ecosystem/crates/init-servs" } - # stratum-embeddings and stratum-llm are built in isolated Docker context for RAG - # See: crates/rag/docker/Dockerfile - stratum-embeddings = { path = "./stratumiops/crates/stratum-embeddings", features = ["openai-provider", "ollama-provider", "fastembed-provider", "memory-cache"] } - stratum-llm = { path = "./stratumiops/crates/stratum-llm", features = ["anthropic", "openai", "ollama"] } + # ============================================================================ + # ONTOREF PROTOCOL ADOPTION (API catalog surface — Phase 4) + # ============================================================================ + ontoref-ontology = { path = "../../ontoref/crates/ontoref-ontology", features = ["derive"] } + ontoref-derive = { path = "../../ontoref/crates/ontoref-derive" } + inventory = "0.3" + + # Stratum ecosystem — sourced from canonical stratumiops repo (SurrealDB v3 throughout) + stratum-embeddings = { path = "../../stratumiops/crates/stratum-embeddings", features = ["openai-provider", "ollama-provider", "fastembed-provider", "memory-cache"] } + stratum-llm = { path = "../../stratumiops/crates/stratum-llm", features = ["anthropic", "openai", "ollama"] } + stratum-graph = { path = "../../stratumiops/crates/stratum-graph" } + stratum-state = { path = "../../stratumiops/crates/stratum-state" } # ============================================================================ # SECRETUMVAULT (Enterprise Secrets Management - canonical source) # ============================================================================ - secretumvault = { path = "../../../Development/secretumvault", features = ["surrealdb-storage", "filesystem", "server", "cedar"] } + secretumvault = { path = "../../secretumvault", features = ["surrealdb-storage", "filesystem", "server", "cedar"] } # ============================================================================ # WASM/WEB-SPECIFIC DEPENDENCIES diff --git a/README.md b/README.md index 3840172..190fce6 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,71 @@ OCI-compliant registry for extension distribution and versioning. --- -### 7. **API Gateway** (`infrastructure/api-gateway/`) +### 7. **ops-keeper** (`crates/ops-keeper/`) + +Policy-based operation gate — signs approved operations with Ed25519 keys before forwarding to the control plane. + +**Language**: Rust + +**Purpose**: Operation approval, policy enforcement, and keeper-signed JWT emission + +**Key Features**: + +- Glob-based `PolicyDef` matching against op type, image patterns, and target patterns +- `Signer` wraps an Ed25519 key pair; emits compact JWTs (`OpsClaims`) on approval +- `PendingOp` tracking with NATS JetStream durable consumer (`ops.pending.*`) +- `AuditEvent` emission to `ops.audit.*` stream on approval or rejection +- Nickel-driven policy config (`keeper_policy.ncl`) + +**Status**: ✅ Active Development + +--- + +### 8. **ops-controller** (`crates/ops-controller/`) + +NATS JetStream consumer that processes keeper-approved operations, calls the orchestrator, and enforces idempotency via SurrealDB. + +**Language**: Rust + +**Purpose**: Durable control plane execution with at-least-once delivery guarantees + +**Key Features**: + +- Pull consumer on `ops.pending.*` JetStream stream +- Ed25519 JWT verification of keeper-signed claims before dispatch +- Idempotency check via SurrealDB; reconciles stale pending ops on startup +- Orchestrator HTTP dispatch with structured `AckResult` (Ack/Nak/Term) +- Audit emission (`ops.audit.*`) on every terminal outcome + +**Status**: ✅ Active Development + +**ADR**: ADR-038 (ops control plane design) + +--- + +### 9. **audit-mirror** (`crates/audit-mirror/`) + +Sidecar that consumes `ops.audit.*` NATS events and mirrors each event as a signed git commit into a Radicle repository. + +**Language**: Rust + +**Purpose**: Immutable, content-addressed audit trail via Radicle git storage + +**Key Features**: + +- NATS JetStream pull consumer on `ops.audit.*` +- JTI deduplication — skips already-committed event IDs via `git log` scan +- `commit_writer` creates signed commits with the audit payload as the blob +- `radicle_publish` announces the repo to the Radicle network after each commit +- Configurable via CLI flags (NATS URL, workspace, Radicle repo path, key path) + +**Status**: ✅ Active Development + +**ADR**: ADR-038 + +--- + +### 10. **API Gateway** (`infrastructure/api-gateway/`) Unified REST API gateway for external integration. @@ -218,46 +282,113 @@ Unified REST API gateway for external integration. - Authentication and authorization - Rate limiting and throttling - API versioning -- Request validation -- Metrics and monitoring **Status**: 🔄 Planned -**Endpoints** (Planned): +--- -- `/api/v1/servers/*` - Server management -- `/api/v1/taskservs/*` - Task service operations -- `/api/v1/clusters/*` - Cluster operations -- `/api/v1/workflows/*` - Workflow management +### 11. **Extension Registry** (`crates/extension-registry/`) + +Registry and catalog for browsing, discovering, and distributing extensions. + +**Language**: Rust + +**Purpose**: Extension discovery, metadata management, and OCI/Forgejo-backed distribution + +**Status**: ✅ Active Development --- -### 8. **Extension Registry** (`extension-registry/`) +### 12. **contract-tests** (`crates/contract-tests/`) -Registry and catalog for browsing and discovering extensions. +G3 contract test suite — verifies semantic equivalence across the CLI↔HTTP↔MCP tier stack. -**Purpose**: Extension discovery and metadata management +**Language**: Rust (test crate) + +**Purpose**: Prevent drift between registry, HTTP daemon, and MCP server response shapes **Key Features**: -- Extension catalog -- Search and filtering -- Version history -- Dependency information -- Documentation links -- Community ratings (future) +- Tier A: direct registry invocation (reference baseline) +- Tier B: axum HTTP server on `127.0.0.1:0` (ephemeral port) +- Tier C: in-process MCP `handle_request` +- Normaliser strips volatile fields (`trace_id`, `timestamp`) — asserts semantic, not byte-for-byte equality +- JSON schema validation against `listing_output_schema` on every tier -**Status**: 🔄 Planned +**Status**: ✅ Active Development --- -### 9. **Provisioning Server** (`provisioning-server/`) +### 13. **ncl-sync** (`crates/ncl-sync/`) -Alternative provisioning service implementation. +Nickel configuration sync daemon — compiles NCL to JSON proactively and maintains a shared cache for all Nu processes. -**Purpose**: Additional provisioning service capabilities +**Language**: Rust -**Status**: 🔄 In Development +**Purpose**: Eliminate `nickel export` latency (~2–5s per call) from CLI commands by pre-compiling NCL files and serving results from an in-memory-backed file cache. + +**Key Features**: + +- File watcher (`notify`) on workspace NCL directories — re-exports on change automatically +- Warm-up on `prvng platform start` — first command of the day already finds cache hot +- Shared cache at `~/.cache/provisioning/config-cache/` used by both this daemon and `nu_plugin_nickel` +- Content-addressed keys: `SHA256(file_content + sorted_import_paths + format)` — identical to plugin key strategy, zero coordination overhead +- Post-operation sync: Nu writes `.sync-.json` sidecar after mutations; daemon re-exports within 500 ms +- Configurable via `platform/config/ncl-sync.ncl` (idle timeout, concurrency, poll interval) +- No NATS, no SurrealDB, no platform service dependencies — intentional to avoid bootstrap circularity + +**Status**: ✅ Production Ready + +**Install**: + +```bash +cargo build --release --package ncl-sync +install -m 0755 target/release/provisioning-ncl-sync ~/.local/bin/provisioning-ncl-sync +``` + +**Usage**: + +```bash +# Start daemon for a workspace +ncl-sync daemon --workspace ~/workspaces/libre-daoshi + +# One-shot warm-up +ncl-sync warm ~/workspaces/libre-daoshi + +# Evict a specific file from cache +ncl-sync invalidate settings.ncl + +# Print cache key (parity testing) +ncl-sync key settings.ncl --import-path /ws --import-path /prov + +# Cache statistics +ncl-sync stats +``` + +**Lifecycle integration**: Started automatically by `prvng platform start`, stopped by `prvng platform stop`. Status visible in `prvng platform status`. + +**Performance impact** (with warm cache): + +| Command | Before | After | +|---------|--------|-------| +| `prvng component list` | ~3–7 s | ~1.5 s | +| `prvng workflow list` | ~3–5 s | ~1.5 s | +| `prvng deploy` | ~15–30 s | ~3–5 s | + +**Configuration** (`platform/config/ncl-sync.ncl`): + +```nickel +{ + ncl_sync = { + idle_timeout_secs = 600, # daemon auto-shutdown after N seconds idle + sync_poll_interval_ms = 500, # how often to check for sync-request sidecars + warm_concurrency = 4, # max parallel nickel export during warm-up + extra_import_paths = [], # additional import paths beyond workspace + $PROVISIONING + } +} +``` + +**ADRs**: [ADR-022](../adrs/adr-022-ncl-sync-daemon.ncl) (daemon design), [ADR-023](../adrs/adr-023-ncl-export-wrapper.ncl) (Nu wrapper strategy) --- @@ -345,16 +476,27 @@ Systemd service units for platform services. │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ Installer │ │ OCI Registry │ │ Extension │ │ -│ │ (Rust/Nu) │ │ │ │ Registry │ │ +│ │ Installer │ │ Extension │ │ ops-keeper │ │ +│ │ (Rust/Nu) │ │ Registry │ │ (Rust) │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ops-controller│ │ audit-mirror │ │ +│ │ (Rust) │ │ (Rust) │ │ +│ └──────────────┘ └──────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ ncl-sync daemon (Rust) │ │ +│ │ ~/.cache/provisioning/config-cache/ ←→ Nu procs │ │ +│ └──────────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────┘ ↓ ┌──────────────────────────────────────────────────────────────┐ │ Data & State Layer │ -│ • SurrealDB (State Management) │ +│ • NATS JetStream (ops.pending.*, ops.audit.*, TASKS) │ +│ • SurrealDB (State Management, Idempotency) │ +│ • Radicle (Immutable Audit Log via git) │ │ • File-based Persistence (Checkpoints) │ -│ • Configuration Storage │ └──────────────────────────────────────────────────────────────┘ ``` @@ -373,11 +515,13 @@ Systemd service units for platform services. ### Key Dependencies - **tokio** - Async runtime for Rust services -- **axum** / **actix-web** - Web frameworks +- **axum** - Web framework (control-center, orchestrator, provisioning-daemon) +- **async-nats** - NATS JetStream client (ops-keeper, ops-controller, audit-mirror, control-center) +- **surrealdb** - State management and idempotency store - **serde** - Serialization/deserialization -- **bollard** - Docker API client (test environments) - **ratatui** - Terminal UI framework (installer) -- **SurrealDB** - State management database +- **git2** - Radicle git integration (audit-mirror) +- **jsonwebtoken** - Ed25519 JWT signing/verification (ops-keeper, ops-controller) --- @@ -477,19 +621,23 @@ nu run.nu ```bash platform/ -├── orchestrator/ # Rust orchestrator service -├── control-center/ # Rust control center backend -├── control-center-ui/ # Web frontend -├── installer/ # Rust/Nushell installer -├── mcp-server/ # Nushell MCP server -├── infrastructure/api-gateway/ # Rust API gateway (planned) -├── infrastructure/oci-registry/ # OCI registry (planned) -├── extension-registry/ # Extension catalog (planned) -├── provisioning-server/# Alternative service -├── infrastructure/docker/ # Docker Compose configs -├── k8s/ # Kubernetes manifests -├── infrastructure/systemd/ # Systemd units -└── docs/ # Platform documentation +├── crates/ +│ ├── orchestrator/ # Rust orchestrator service +│ ├── control-center/ # Rust control center backend +│ ├── control-center-ui/ # Web frontend +│ ├── mcp-server/ # Nushell MCP server +│ ├── ncl-sync/ # Nickel config sync daemon +│ └── ... +├── config/ +│ ├── ncl-sync.ncl # ncl-sync daemon configuration +│ └── external-services.ncl +├── infrastructure/ +│ ├── api-gateway/ # Rust API gateway (planned) +│ ├── oci-registry/ # OCI registry (planned) +│ ├── docker/ # Docker Compose configs +│ ├── systemd/ # Systemd units +│ └── ... +└── docs/ # Platform documentation ``` ### Adding New Services @@ -569,5 +717,5 @@ For platform service issues: --- **Maintained By**: Platform Team -**Last Updated**: 2025-10-07 -**Platform Version**: 3.5.0 +**Last Updated**: 2026-05-12 +**Platform Version**: 3.6.0 diff --git a/crates/ai-service/Cargo.toml b/crates/ai-service/Cargo.toml index 9adc393..7863587 100644 --- a/crates/ai-service/Cargo.toml +++ b/crates/ai-service/Cargo.toml @@ -20,6 +20,11 @@ axum = { workspace = true } tower = { workspace = true, features = ["full"] } tower-http = { workspace = true, features = ["cors", "trace"] } +# Ontoref API catalog +ontoref-ontology = { workspace = true } +ontoref-derive = { workspace = true } +inventory = { workspace = true } + # Serialization serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } @@ -29,7 +34,7 @@ toml = { workspace = true } platform-config = { workspace = true } # Centralized observability (logging, metrics, health, tracing) -observability = { workspace = true, features = ["logging", "metrics-prometheus", "health"] } +platform-observability = { workspace = true, features = ["logging", "metrics-prometheus", "health"] } # Error handling anyhow = { workspace = true } @@ -47,10 +52,10 @@ uuid = { workspace = true, features = ["v4", "serde"] } clap = { workspace = true, features = ["derive"] } # RAG crate for AI capabilities -rag = { workspace = true } +platform-rag = { workspace = true } # MCP server tools for real implementations -mcp-server = { workspace = true } +provisioning-mcp = { workspace = true } # Graph operations for DAG petgraph = { workspace = true } diff --git a/crates/ai-service/src/api_catalog.rs b/crates/ai-service/src/api_catalog.rs new file mode 100644 index 0000000..1b6c01d --- /dev/null +++ b/crates/ai-service/src/api_catalog.rs @@ -0,0 +1,12 @@ +use axum::{extract::State, response::IntoResponse, Json}; +use ontoref_ontology::api::ApiRouteEntry; +use serde_json::json; +use std::sync::Arc; + +use crate::service::AiService; + +pub async fn api_catalog(State(_state): State>) -> impl IntoResponse { + let mut routes: Vec<&'static ApiRouteEntry> = inventory::iter::().collect(); + routes.sort_by(|a, b| a.path.cmp(b.path).then(a.method.cmp(b.method))); + Json(json!({ "service": "ai-service", "routes": routes })) +} diff --git a/crates/ai-service/src/handlers.rs b/crates/ai-service/src/handlers.rs index e5c7d7f..957384e 100644 --- a/crates/ai-service/src/handlers.rs +++ b/crates/ai-service/src/handlers.rs @@ -9,6 +9,7 @@ use axum::{ routing::{get, post}, Json, Router, }; +use ontoref_derive::onto_api; use serde_json::json; use tracing::debug; @@ -27,10 +28,19 @@ pub fn create_routes(state: Arc) -> Router { get(get_best_practices_handler), ) .route("/health", get(health_check_handler)) + .route("/api/catalog", get(crate::api_catalog::api_catalog)) .with_state(state) } -/// Call an MCP tool +#[onto_api( + method = "POST", + path = "/api/v1/ai/mcp/tool", + description = "Invoke an MCP tool by name with arguments", + auth = "bearer", + actors = "developer, agent", + tags = "ai, mcp", + feature = "" +)] async fn call_mcp_tool_handler( State(service): State>, Json(req): Json, @@ -45,7 +55,15 @@ async fn call_mcp_tool_handler( Ok(Json(response)) } -/// Ask AI a question (RAG-powered) +#[onto_api( + method = "POST", + path = "/api/v1/ai/ask", + description = "Ask a RAG-powered question grounded in Nickel schemas and deployment history", + auth = "bearer", + actors = "developer, agent", + tags = "ai, rag", + feature = "" +)] async fn ask_handler( State(service): State>, Json(req): Json, @@ -57,7 +75,15 @@ async fn ask_handler( Ok(Json(response)) } -/// Get extension dependency DAG +#[onto_api( + method = "GET", + path = "/api/v1/ai/dag/extensions", + description = "Get the extension dependency DAG used by AI for schema-aware config generation", + auth = "bearer", + actors = "developer, agent", + tags = "ai, dag, extensions", + feature = "" +)] async fn get_extension_dag_handler( State(service): State>, ) -> Result, InternalError> { @@ -71,7 +97,15 @@ async fn get_extension_dag_handler( Ok(Json(dag)) } -/// Get best practices for a category +#[onto_api( + method = "GET", + path = "/api/v1/ai/knowledge/best-practices", + description = "Get best practices for a given category from the knowledge base", + auth = "bearer", + actors = "developer, agent", + tags = "ai, knowledge", + feature = "" +)] async fn get_best_practices_handler( State(service): State>, axum::extract::Query(params): axum::extract::Query>, @@ -91,7 +125,15 @@ async fn get_best_practices_handler( Ok(Json(practices)) } -/// Health check endpoint +#[onto_api( + method = "GET", + path = "/health", + description = "AI service health check", + auth = "none", + actors = "developer, agent, ci", + tags = "health", + feature = "" +)] async fn health_check_handler( State(service): State>, ) -> Result { diff --git a/crates/ai-service/src/lib.rs b/crates/ai-service/src/lib.rs index 925efa4..2a366e0 100644 --- a/crates/ai-service/src/lib.rs +++ b/crates/ai-service/src/lib.rs @@ -4,6 +4,7 @@ //! Exposes Claude-based question answering, MCP tool execution, extension //! dependency graphs, and best practice recommendations via HTTP API. +pub mod api_catalog; pub mod config; pub mod dag; pub mod handlers; diff --git a/crates/ai-service/src/main.rs b/crates/ai-service/src/main.rs index d8317db..b35456c 100644 --- a/crates/ai-service/src/main.rs +++ b/crates/ai-service/src/main.rs @@ -31,6 +31,11 @@ struct Args { /// Service bind port #[arg(short = 'p', long, default_value_t = DEFAULT_PORT)] port: u16, + + /// Print all #[onto_api] registered routes as JSON and exit. + /// Pipe to api-catalog-ai-service.json: `just export-api-catalog` + #[arg(long)] + dump_api_catalog: bool, } #[tokio::main] @@ -38,6 +43,11 @@ async fn main() -> anyhow::Result<()> { // Parse CLI arguments FIRST (so --help works before any other processing) let args = Args::parse(); + if args.dump_api_catalog { + println!("{}", ontoref_ontology::api::dump_catalog_json()); + return Ok(()); + } + // Initialize centralized observability (logging, metrics, health checks) let _guard = observability::init_from_env("ai-service", env!("CARGO_PKG_VERSION"))?; diff --git a/crates/audit-mirror/Cargo.toml b/crates/audit-mirror/Cargo.toml new file mode 100644 index 0000000..b98863d --- /dev/null +++ b/crates/audit-mirror/Cargo.toml @@ -0,0 +1,30 @@ +[package] +authors.workspace = true +edition.workspace = true +license.workspace = true +name = "audit-mirror" +version.workspace = true +description = "NATS ops.audit.* → Radicle git commit sidecar with jti idempotency (ADR-038)" + +[[bin]] +name = "audit-mirror" +path = "src/main.rs" + +[dependencies] +async-nats = { workspace = true } +tokio = { workspace = true, features = ["full"] } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +chrono = { workspace = true } +clap = { workspace = true } +futures = { workspace = true } +git2 = { workspace = true } +bytes = { workspace = true } +reqwest = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/audit-mirror/src/commit_writer.rs b/crates/audit-mirror/src/commit_writer.rs new file mode 100644 index 0000000..565566b --- /dev/null +++ b/crates/audit-mirror/src/commit_writer.rs @@ -0,0 +1,99 @@ +use git2::{Repository, Signature}; + +use crate::error::MirrorError; + +/// Writes a single commit into the HEAD of `repo` embedding `jti` in the message. +/// +/// The commit message format is: +/// audit: jti= +/// +/// +/// +/// This is the canonical format that `jti_check::already_committed` searches for. +/// An empty tree is used because the audit repo contains only commit-message blobs; +/// no working-tree files are modified. +pub fn write_audit_commit( + repo: &Repository, + jti: &str, + json_payload: &str, + author_name: &str, + author_email: &str, +) -> Result { + let sig = Signature::now(author_name, author_email) + .map_err(|e| MirrorError::Git(e.to_string()))?; + + let tree_id = { + let mut index = repo.index().map_err(|e| MirrorError::Git(e.to_string()))?; + // Write tree from current index (may be empty — no working-tree blobs needed) + index.write_tree().map_err(|e| MirrorError::Git(e.to_string()))? + }; + + let tree = repo + .find_tree(tree_id) + .map_err(|e| MirrorError::Git(e.to_string()))?; + + let message = format!("audit: jti={jti}\n\n{json_payload}"); + + // Parent is HEAD if it exists; first commit for an empty (unborn) branch has no parent. + let parent_commit; + let parents: Vec<&git2::Commit<'_>>; + match repo.head() { + Ok(head_ref) => { + parent_commit = head_ref + .peel_to_commit() + .map_err(|e| MirrorError::Git(e.to_string()))?; + parents = vec![&parent_commit]; + } + Err(e) if e.code() == git2::ErrorCode::UnbornBranch => { + parents = vec![]; + } + Err(e) => return Err(MirrorError::Git(e.to_string())), + } + + let oid = repo + .commit(Some("HEAD"), &sig, &sig, &message, &tree, &parents) + .map_err(|e| MirrorError::Git(e.to_string()))?; + + Ok(oid) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn init_repo() -> (TempDir, Repository) { + let dir = TempDir::new().unwrap(); + let repo = Repository::init(dir.path()).unwrap(); + let mut cfg = repo.config().unwrap(); + cfg.set_str("user.name", "test").unwrap(); + cfg.set_str("user.email", "test@test.com").unwrap(); + (dir, repo) + } + + #[test] + fn writes_commit_to_empty_repo() { + let (_dir, repo) = init_repo(); + let oid = write_audit_commit(&repo, "jti-001", r#"{"event":"test"}"#, "ci", "ci@ci.local").unwrap(); + let commit = repo.find_commit(oid).unwrap(); + assert!(commit.message().unwrap().contains("jti=jti-001")); + assert!(commit.message().unwrap().contains(r#"{"event":"test"}"#)); + } + + #[test] + fn writes_second_commit_with_correct_parent() { + let (_dir, repo) = init_repo(); + let first = write_audit_commit(&repo, "jti-001", "{}", "ci", "ci@ci.local").unwrap(); + let second = write_audit_commit(&repo, "jti-002", "{}", "ci", "ci@ci.local").unwrap(); + let commit = repo.find_commit(second).unwrap(); + assert_eq!(commit.parent_id(0).unwrap(), first); + } + + #[test] + fn message_contains_jti_marker() { + let (_dir, repo) = init_repo(); + let oid = write_audit_commit(&repo, "abc-xyz", r#"{"op":"deploy"}"#, "keeper", "keeper@ops").unwrap(); + let msg = repo.find_commit(oid).unwrap().message().unwrap().to_string(); + assert!(msg.starts_with("audit: jti=abc-xyz\n\n")); + } +} diff --git a/crates/audit-mirror/src/error.rs b/crates/audit-mirror/src/error.rs new file mode 100644 index 0000000..21cdbeb --- /dev/null +++ b/crates/audit-mirror/src/error.rs @@ -0,0 +1,19 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum MirrorError { + #[error("git error: {0}")] + Git(String), + #[error("NATS error: {0}")] + Nats(String), + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + #[error("io error: {0}")] + Io(#[from] std::io::Error), +} + +impl From for MirrorError { + fn from(e: git2::Error) -> Self { + MirrorError::Git(e.to_string()) + } +} diff --git a/crates/audit-mirror/src/jti_check.rs b/crates/audit-mirror/src/jti_check.rs new file mode 100644 index 0000000..f91f05d --- /dev/null +++ b/crates/audit-mirror/src/jti_check.rs @@ -0,0 +1,92 @@ +use git2::Repository; + +use crate::error::MirrorError; + +/// Returns true if a commit with the given jti already exists in the HEAD ancestor chain. +/// Searches commit messages for the marker `jti=` added by commit_writer. +/// +/// This is the idempotency guard required by ADR-038 constraint audit-mirror-idempotent-on-jti. +pub fn already_committed(repo: &Repository, jti: &str) -> Result { + let head = match repo.head() { + Ok(r) => r, + Err(e) if e.code() == git2::ErrorCode::UnbornBranch => return Ok(false), + Err(e) => return Err(MirrorError::Git(e.to_string())), + }; + + let head_commit = head + .peel_to_commit() + .map_err(|e| MirrorError::Git(e.to_string()))?; + + let marker = format!("jti={jti}"); + + let mut revwalk = repo + .revwalk() + .map_err(|e| MirrorError::Git(e.to_string()))?; + revwalk + .push(head_commit.id()) + .map_err(|e| MirrorError::Git(e.to_string()))?; + revwalk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME) + .map_err(|e| MirrorError::Git(e.to_string()))?; + + for oid in revwalk { + let oid = oid.map_err(|e| MirrorError::Git(e.to_string()))?; + let commit = repo + .find_commit(oid) + .map_err(|e| MirrorError::Git(e.to_string()))?; + if commit.message().unwrap_or("").contains(&marker) { + return Ok(true); + } + } + + Ok(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn init_bare_repo_with_commit(message: &str) -> (TempDir, Repository) { + let dir = TempDir::new().unwrap(); + let repo = Repository::init(dir.path()).unwrap(); + + { + let mut config = repo.config().unwrap(); + config.set_str("user.name", "test").unwrap(); + config.set_str("user.email", "test@test.com").unwrap(); + } + + let sig = git2::Signature::now("test", "test@test.com").unwrap(); + { + let tree_id = { + let mut index = repo.index().unwrap(); + index.write_tree().unwrap() + }; + let tree = repo.find_tree(tree_id).unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[]) + .unwrap(); + // tree drops here so repo can be moved below + } + + (dir, repo) + } + + #[test] + fn detects_existing_jti_in_head_commit() { + let (_dir, repo) = init_bare_repo_with_commit("audit: jti=abc-123\n\npayload"); + assert!(already_committed(&repo, "abc-123").unwrap()); + } + + #[test] + fn returns_false_for_unknown_jti() { + let (_dir, repo) = init_bare_repo_with_commit("audit: jti=abc-123\n\npayload"); + assert!(!already_committed(&repo, "unknown-jti").unwrap()); + } + + #[test] + fn returns_false_for_empty_repo() { + let dir = TempDir::new().unwrap(); + let repo = Repository::init(dir.path()).unwrap(); + assert!(!already_committed(&repo, "any-jti").unwrap()); + } +} diff --git a/crates/audit-mirror/src/main.rs b/crates/audit-mirror/src/main.rs new file mode 100644 index 0000000..66dcb87 --- /dev/null +++ b/crates/audit-mirror/src/main.rs @@ -0,0 +1,188 @@ +mod commit_writer; +mod error; +mod jti_check; +mod radicle_publish; + +use std::path::PathBuf; + +use async_nats::jetstream::{self, consumer::PullConsumer}; +use bytes::Bytes; +use clap::Parser; +use futures::StreamExt; +use git2::Repository; +use tracing::{error, info, warn}; + +use commit_writer::write_audit_commit; +use jti_check::already_committed; +use radicle_publish::announce_repo; + +#[derive(Parser)] +#[command(name = "audit-mirror", about = "NATS ops.audit.* → Radicle git commit sidecar (ADR-038)")] +struct Cli { + /// NATS server URL + #[arg(long, default_value = "nats://127.0.0.1:4222")] + nats_url: String, + + /// Workspace name (used to derive stream and consumer names) + #[arg(long)] + workspace: String, + + /// Path to the local Radicle storage repo for this workspace's state + /// (e.g. ~/.radicle/storage/) + #[arg(long)] + state_repo_path: PathBuf, + + /// Radicle Repository ID (RID) for gossip announce + #[arg(long)] + rid: String, + + /// Author name embedded in git commits + #[arg(long, default_value = "audit-mirror")] + author_name: String, + + /// Author email embedded in git commits + #[arg(long, default_value = "audit-mirror@ops.local")] + author_email: String, + + /// Radicle local HTTP API base URL + #[arg(long, default_value = "http://127.0.0.1:8776")] + radicle_api_url: String, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let cli = Cli::parse(); + + let nats = async_nats::connect(&cli.nats_url).await?; + let js = jetstream::new(nats); + + let stream_name = format!("OPS_AUDIT_{}", cli.workspace.to_uppercase()); + let consumer_name = format!("{}-audit-mirror", cli.workspace); + + // Ensure stream exists (idempotent — ADR-038 requires the stream be pre-provisioned + // by ops-controller, so we only access it here, not create it). + let stream = js.get_stream(&stream_name).await.map_err(|e| { + format!("stream '{stream_name}' not found — ensure ops-controller has provisioned it: {e}") + })?; + + let consumer: PullConsumer = stream + .get_or_create_consumer( + &consumer_name, + jetstream::consumer::pull::Config { + durable_name: Some(consumer_name.clone()), + ack_policy: jetstream::consumer::AckPolicy::Explicit, + filter_subject: format!("ops.audit.{}.>", cli.workspace), + ..Default::default() + }, + ) + .await?; + + info!( + stream = stream_name, + consumer = consumer_name, + repo = ?cli.state_repo_path, + "audit-mirror ready" + ); + + let repo = Repository::open(&cli.state_repo_path).map_err(|e| { + format!("cannot open state repo at {:?}: {e}", cli.state_repo_path) + })?; + + let mut messages = consumer.messages().await?; + + while let Some(msg_result) = messages.next().await { + let msg = match msg_result { + Ok(m) => m, + Err(e) => { + error!("NATS message error: {e}"); + continue; + } + }; + + let jti = match extract_jti(&msg.payload) { + Some(j) => j, + None => { + warn!(subject = %msg.subject, "no jti in audit payload; acking and skipping"); + let _ = msg.ack().await; + continue; + } + }; + + match already_committed(&repo, &jti) { + Ok(true) => { + info!(jti, "jti already committed — idempotent skip"); + let _ = msg.ack().await; + continue; + } + Ok(false) => {} + Err(e) => { + error!(jti, error = %e, "jti_check failed; leaving in queue for retry"); + continue; + } + } + + let payload_str = String::from_utf8_lossy(&msg.payload); + + match write_audit_commit( + &repo, + &jti, + &payload_str, + &cli.author_name, + &cli.author_email, + ) { + Ok(oid) => { + info!(jti, commit = %oid, "audit commit written"); + } + Err(e) => { + error!(jti, error = %e, "commit_writer failed; leaving in queue for retry"); + continue; + } + } + + // Best-effort gossip announce — non-fatal, commit is durable. + if let Err(e) = announce_repo(&cli.rid, &cli.radicle_api_url).await { + warn!(jti, error = %e, "announce_repo failed (non-fatal)"); + } + + if let Err(e) = msg.ack().await { + warn!(jti, error = %e, "ack failed (JetStream will redeliver; jti_check will deduplicate)"); + } + } + + warn!("NATS message stream ended; audit-mirror exiting"); + Ok(()) +} + +/// Extracts the `jti` field from a JSON payload. +fn extract_jti(payload: &Bytes) -> Option { + let v: serde_json::Value = serde_json::from_slice(payload).ok()?; + v.get("jti")?.as_str().map(str::to_owned) +} + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + + #[test] + fn extracts_jti_from_payload() { + let payload = Bytes::from(r#"{"jti":"abc-123","event":"deploy"}"#); + assert_eq!(extract_jti(&payload), Some("abc-123".to_string())); + } + + #[test] + fn returns_none_for_missing_jti() { + let payload = Bytes::from(r#"{"event":"deploy"}"#); + assert_eq!(extract_jti(&payload), None); + } + + #[test] + fn returns_none_for_invalid_json() { + let payload = Bytes::from(b"not-json" as &[u8]); + assert_eq!(extract_jti(&payload), None); + } +} diff --git a/crates/audit-mirror/src/radicle_publish.rs b/crates/audit-mirror/src/radicle_publish.rs new file mode 100644 index 0000000..4a8f41e --- /dev/null +++ b/crates/audit-mirror/src/radicle_publish.rs @@ -0,0 +1,35 @@ +use tracing::{info, warn}; + +/// Signals the local Radicle node to announce updated refs for `rid`. +/// +/// Radicle Heartwood exposes an HTTP API at `http://127.0.0.1:8776`. After +/// writing a new audit commit to the Radicle storage repo, we POST to the node +/// sync endpoint so gossip propagates immediately rather than waiting for the +/// node's background scan interval. +/// +/// Failure here is non-fatal: the commit is already durable in the local git +/// store and radicled will eventually pick it up via its storage-directory +/// watcher. We log a warning and return Ok(()) so the caller can ack the +/// JetStream message. +pub async fn announce_repo(rid: &str, node_api_url: &str) -> Result<(), crate::error::MirrorError> { + let url = format!("{node_api_url}/v1/repos/{rid}/sync"); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .map_err(|e| crate::error::MirrorError::Nats(format!("HTTP client init: {e}")))?; + + match client.post(&url).send().await { + Ok(resp) if resp.status().is_success() => { + info!(rid, "Radicle repo sync triggered successfully"); + } + Ok(resp) => { + warn!(rid, status = %resp.status(), "Radicle sync returned non-success; node will sync on next scan"); + } + Err(e) => { + warn!(rid, error = %e, "Radicle sync request failed (non-fatal; local commit is durable)"); + } + } + + Ok(()) +} diff --git a/crates/backup-manager/Cargo.toml b/crates/backup-manager/Cargo.toml new file mode 100644 index 0000000..4cfc338 --- /dev/null +++ b/crates/backup-manager/Cargo.toml @@ -0,0 +1,39 @@ +[package] +authors.workspace = true +edition.workspace = true +license.workspace = true +name = "backup-manager" +repository.workspace = true +version.workspace = true + +description = "Multi-context backup orchestrator: restic-first with kopia opt-in, multi-destination replication, ontoref-driven docs. One-shot, daemon, standalone, or coordinator modes. Distinct from platform-backup (../prov-ecosystem/crates/backup) which is a low-level multi-backend library; backup-manager is the orchestrator binary." +keywords = ["backup", "restic", "kopia", "orchestrator", "ontoref"] +categories = ["command-line-utilities", "development-tools"] + +[lib] +name = "backup_manager" +path = "src/lib.rs" + +[[bin]] +name = "prvng-backup" +path = "src/main.rs" + +[dependencies] +platform-config = { workspace = true } + +anyhow = { workspace = true } +async-trait = { workspace = true } +chrono = { workspace = true } +clap = { version = "4", features = ["derive", "env"] } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["full"] } +toml = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +tokio-test = { workspace = true } diff --git a/crates/backup-manager/src/config.rs b/crates/backup-manager/src/config.rs new file mode 100644 index 0000000..7d2ca66 --- /dev/null +++ b/crates/backup-manager/src/config.rs @@ -0,0 +1,329 @@ +//! Configuration loading for the backup-manager via [`platform_config::ConfigLoader`]. +//! +//! Configuration files are searched along the standard hierarchy +//! (`~/.config/provisioning/platform/config/backup-manager.ncl` on Linux, +//! `~/Library/Application Support/...` on macOS) and merged with environment +//! variable overrides (`BACKUP_*` mapped to JSON paths). Nickel contracts +//! validate the merged configuration BEFORE serde deserialisation, so type +//! errors surface as Nickel error messages, not serde "expected struct field". +//! +//! See ADR-015 (`adrs/adr-015-config-driven-platform-config.ncl`) for the +//! mandate that every platform crate uses this loader. + +use std::path::Path; + +use platform_config::ConfigLoader; +use serde::{Deserialize, Serialize}; + +/// Top-level configuration for the backup-manager binary across all four modes. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct BackupManagerConfig { + /// Daemon-mode runtime parameters. Ignored in `one-shot` and `standalone`. + #[serde(default)] + pub daemon: DaemonConfig, + + /// Provider configuration (default provider, version pinning). + #[serde(default)] + pub providers: ProvidersConfig, + + /// Path (or glob) to the directory holding policy Nickel files. + /// Resolved at runtime by invoking `nickel export` on each match. + #[serde(default)] + pub policies: PoliciesRef, + + /// secretumvault client configuration. + #[serde(default)] + pub vault: VaultClientConfig, + + /// platform-nats client configuration. + #[serde(default)] + pub nats: NatsClientConfig, + + /// Prometheus metrics emission configuration. + #[serde(default)] + pub metrics: MetricsConfig, +} + +/// Daemon-mode parameters: priority queue, concurrency, API endpoint. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DaemonConfig { + /// HTTP API listen address (e.g. `127.0.0.1:9099`). + #[serde(default = "default_daemon_http_addr")] + pub http_addr: String, + + /// Unix socket path for local CLI communication. + #[serde(default = "default_daemon_unix_socket")] + pub unix_socket: String, + + /// Maximum concurrent backup operations across all destinations. + #[serde(default = "default_max_parallel_backups")] + pub max_parallel_backups: usize, + + /// Maximum concurrent operations per destination. + #[serde(default = "default_max_parallel_per_destination")] + pub max_parallel_per_destination: usize, + + /// Path where the daemon persists its state (queue, last-run timestamps). + #[serde(default = "default_state_path")] + pub state_path: String, +} + +impl Default for DaemonConfig { + fn default() -> Self { + Self { + http_addr: default_daemon_http_addr(), + unix_socket: default_daemon_unix_socket(), + max_parallel_backups: default_max_parallel_backups(), + max_parallel_per_destination: default_max_parallel_per_destination(), + state_path: default_state_path(), + } + } +} + +fn default_daemon_http_addr() -> String { "127.0.0.1:9099".to_string() } +fn default_daemon_unix_socket() -> String { "/run/prvng-backup.sock".to_string() } +fn default_max_parallel_backups() -> usize { 4 } +fn default_max_parallel_per_destination() -> usize { 2 } +fn default_state_path() -> String { "/var/lib/prvng-backup/state".to_string() } + +/// Provider defaults and version pinning. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProvidersConfig { + /// Provider used when a `BackupPolicy` does not specify one explicitly. + #[serde(default = "default_provider_name")] + pub default: String, + + /// Minimum acceptable restic CLI version. The daemon emits a warning if + /// the installed binary is older. Format: `semver` ("0.16.0"). + #[serde(default)] + pub restic_min_version: Option, + + /// Minimum acceptable kopia CLI version, when kopia is referenced. + #[serde(default)] + pub kopia_min_version: Option, +} + +impl Default for ProvidersConfig { + fn default() -> Self { + Self { + default: default_provider_name(), + restic_min_version: None, + kopia_min_version: None, + } + } +} + +fn default_provider_name() -> String { "restic".to_string() } + +/// Reference to the directory tree where `BackupPolicy` / `BackupGroup` / +/// `SystemBackupDef` Nickel files are committed. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PoliciesRef { + /// Root directory containing component policies (`infra//components/`). + #[serde(default = "default_components_path")] + pub components_path: String, + + /// Path to the BackupGroup declarations file. + #[serde(default = "default_groups_path")] + pub groups_path: String, + + /// Path to the SystemBackupDef declarations file. + #[serde(default = "default_system_backups_path")] + pub system_backups_path: String, + + /// Nickel `--import-path` value used when exporting policies. + #[serde(default = "default_nickel_import_path")] + pub nickel_import_path: String, +} + +impl Default for PoliciesRef { + fn default() -> Self { + Self { + components_path: default_components_path(), + groups_path: default_groups_path(), + system_backups_path: default_system_backups_path(), + nickel_import_path: default_nickel_import_path(), + } + } +} + +fn default_components_path() -> String { + "infra/libre-wuji/components".to_string() +} +fn default_groups_path() -> String { + "infra/libre-wuji/backup-groups.ncl".to_string() +} +fn default_system_backups_path() -> String { + "infra/libre-wuji/system-backups.ncl".to_string() +} +fn default_nickel_import_path() -> String { "provisioning".to_string() } + +/// secretumvault connection parameters. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct VaultClientConfig { + /// Vault HTTP endpoint (e.g. `https://vault.libre-wuji.svc.cluster.local:8200`). + #[serde(default)] + pub endpoint: Option, + + /// Path inside vault where the manager's NKey/JWT lives for auth. + #[serde(default)] + pub auth_path: Option, + + /// Audit log target (subject prefix or file path). + #[serde(default)] + pub audit_target: Option, +} + +/// platform-nats connection parameters (mirrors `NatsConnectionConfig`). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct NatsClientConfig { + /// NATS URL (e.g. `nats://nats.ops-system.svc:4222`). + #[serde(default)] + pub url: Option, + + /// Path inside vault where the NKey seed is stored. + #[serde(default)] + pub nkey_seed_vault_path: Option, + + /// Require signed messages on subscribed subjects. + #[serde(default)] + pub require_signed_messages: bool, +} + +/// Prometheus metrics configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MetricsConfig { + /// Metrics emission mode. Daemon mode uses HTTP; one-shot uses textfile. + #[serde(default)] + pub mode: MetricsMode, + + /// HTTP listen address when `mode = "http"`. + #[serde(default = "default_metrics_http_addr")] + pub http_addr: String, + + /// Textfile path when `mode = "textfile"` (consumed by node_exporter). + #[serde(default = "default_metrics_textfile_path")] + pub textfile_path: String, +} + +impl Default for MetricsConfig { + fn default() -> Self { + Self { + mode: MetricsMode::default(), + http_addr: default_metrics_http_addr(), + textfile_path: default_metrics_textfile_path(), + } + } +} + +/// Metrics emission mode. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum MetricsMode { + /// HTTP endpoint scraped by Prometheus (used by daemon mode). + #[default] + Http, + /// Textfile written to a directory scraped by node_exporter (used by one-shot). + Textfile, +} + +fn default_metrics_http_addr() -> String { "127.0.0.1:9100".to_string() } +fn default_metrics_textfile_path() -> String { + "/var/lib/node-exporter/textfile_collector/prvng-backup.prom".to_string() +} + +impl ConfigLoader for BackupManagerConfig { + fn service_name() -> &'static str { + "backup-manager" + } + + fn collect_env_overrides() -> serde_json::Value { + // Map `BACKUP_*` environment variables to nested JSON paths matching + // the structure of `BackupManagerConfig`. Only set keys are included; + // the merge happens inside `platform_config::format::load_config_with_overrides`. + let mut overrides = serde_json::Map::new(); + + let mut daemon = serde_json::Map::new(); + if let Ok(v) = std::env::var("BACKUP_DAEMON_HTTP_ADDR") { + daemon.insert("http_addr".into(), serde_json::Value::String(v)); + } + if let Ok(v) = std::env::var("BACKUP_DAEMON_UNIX_SOCKET") { + daemon.insert("unix_socket".into(), serde_json::Value::String(v)); + } + if !daemon.is_empty() { + overrides.insert("daemon".into(), serde_json::Value::Object(daemon)); + } + + let mut vault = serde_json::Map::new(); + if let Ok(v) = std::env::var("BACKUP_VAULT_ENDPOINT") { + vault.insert("endpoint".into(), serde_json::Value::String(v)); + } + if !vault.is_empty() { + overrides.insert("vault".into(), serde_json::Value::Object(vault)); + } + + let mut nats = serde_json::Map::new(); + if let Ok(v) = std::env::var("BACKUP_NATS_URL") { + nats.insert("url".into(), serde_json::Value::String(v)); + } + if !nats.is_empty() { + overrides.insert("nats".into(), serde_json::Value::Object(nats)); + } + + let mut providers = serde_json::Map::new(); + if let Ok(v) = std::env::var("BACKUP_PROVIDER_DEFAULT") { + providers.insert("default".into(), serde_json::Value::String(v)); + } + if !providers.is_empty() { + overrides.insert("providers".into(), serde_json::Value::Object(providers)); + } + + serde_json::Value::Object(overrides) + } + + fn from_path>( + path: P, + ) -> std::result::Result> { + let path = path.as_ref(); + let overrides = Self::collect_env_overrides(); + let json_value = platform_config::format::load_config_with_overrides(path, &overrides) + .map_err(|e| -> Box { Box::new(e) })?; + + serde_json::from_value(json_value).map_err(|e| { + let err_msg = format!( + "Failed to deserialize backup-manager config from {:?}: {}", + path, e + ); + Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, err_msg)) + as Box + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_are_consistent() { + let cfg = BackupManagerConfig::default(); + assert_eq!(cfg.daemon.http_addr, "127.0.0.1:9099"); + assert_eq!(cfg.providers.default, "restic"); + assert_eq!(cfg.metrics.mode, MetricsMode::Http); + assert!(!cfg.nats.require_signed_messages); + } + + #[test] + fn service_name_matches_practice_node_artifact_path() { + assert_eq!(BackupManagerConfig::service_name(), "backup-manager"); + } + + #[test] + fn env_overrides_emit_only_set_keys() { + // Without env vars set, the override map should be empty. + // (The test runs in an isolated process; if BACKUP_* leaks from the + // host shell the assertion is loose: just ensure it's a JSON object.) + let v = BackupManagerConfig::collect_env_overrides(); + assert!(v.is_object()); + } +} diff --git a/crates/backup-manager/src/error.rs b/crates/backup-manager/src/error.rs new file mode 100644 index 0000000..4ad750a --- /dev/null +++ b/crates/backup-manager/src/error.rs @@ -0,0 +1,72 @@ +//! Error types for the backup-manager crate. +//! +//! All public APIs return [`Result`] which is shorthand for +//! `std::result::Result`. + +use thiserror::Error; + +/// Top-level error type for the backup-manager. +#[derive(Debug, Error)] +pub enum BackupError { + /// Configuration could not be loaded or validated. + #[error("configuration error: {0}")] + Config(String), + + /// A policy file could not be deserialised or violated a contract. + #[error("policy error: {0}")] + Policy(String), + + /// A backup provider (restic, kopia) failed. + #[error("provider '{provider}' error: {message}")] + Provider { + /// Provider identifier (`restic`, `kopia`, ...). + provider: String, + /// Underlying error message from the provider invocation. + message: String, + }, + + /// A destination was unreachable or rejected the operation. + #[error("destination '{destination}' error: {message}")] + Destination { + /// Destination identifier (matches `BackupPolicy.destinations[].name`). + destination: String, + /// Underlying error. + message: String, + }, + + /// The vault (secretumvault) returned an error or was unreachable. + #[error("vault error: {0}")] + Vault(String), + + /// NATS publish/subscribe error. + #[error("nats error: {0}")] + Nats(String), + + /// A Kubernetes API call failed. + #[error("kubernetes error: {0}")] + Kubernetes(String), + + /// Filesystem I/O error. + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + /// JSON (de)serialisation error. + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + + /// A pre/post hook exited non-zero. + #[error("hook '{hook}' failed: exit {exit_code}")] + HookFailed { + /// Hook identifier (`pre`, `post`, or a named hook). + hook: String, + /// Process exit code. + exit_code: i32, + }, + + /// Any other anyhow-flavoured failure that does not fit the categories above. + #[error("{0}")] + Other(#[from] anyhow::Error), +} + +/// Crate-local result alias. +pub type Result = std::result::Result; diff --git a/crates/backup-manager/src/lib.rs b/crates/backup-manager/src/lib.rs new file mode 100644 index 0000000..67beae8 --- /dev/null +++ b/crates/backup-manager/src/lib.rs @@ -0,0 +1,60 @@ +//! Multi-context backup orchestrator: restic-first with kopia opt-in, multi-destination replication, ontoref-driven docs. +//! +//! `backup-manager` runs in four modes that share a single binary, configuration +//! loader, schema set, and event surface: +//! +//! - **one-shot** — invoked by a scheduler (Kubernetes CronJob, system cron, +//! systemd timer). Loads the policy, executes the requested operation +//! (`backup`, `restore`, `verify`, `prune`, `list`), reports through metrics +//! and NATS events, terminates. +//! - **daemon** — long-running orchestrator. Holds a priority queue, enforces +//! concurrency budgets, reloads policies via [`platform_config`] watcher, +//! exposes a local HTTP/Unix socket API, and emits Prometheus metrics on a +//! dedicated endpoint. +//! - **standalone** — workstation mode. Connects to a destination directly with +//! the operator's age key and runs `mount`, `restore`, or `list` without +//! needing the cluster, the daemon, or even a Nickel runtime (the binary +//! embeds the schema subset required to deserialise policies). +//! - **coordinator** — orchestrates `BackupGroup` consistent cuts across +//! members. Tags every member's snapshot with the same `group_id` and +//! `group_ts` so a single timestamp recovers the whole group. +//! +//! ## Schema integration +//! +//! Policies are declared in Nickel (`provisioning/schemas/lib/backup_policy.ncl`, +//! `backup_group.ncl`, `system_backup.ncl`, `verify_policy.ncl`, `concerns.ncl`). +//! The Nickel layer enforces three invariants at export time: +//! +//! - `EncryptionRequired` — every [`BackupProvider`](policy::BackupProvider) +//! must declare `features.encryption = true`. +//! - `MultiDestinationRequired` — every enabled +//! [`BackupPolicy`](policy::BackupPolicy) must declare ≥2 destinations, +//! at least one with `role = 'primary`. +//! - `NonEmptyScopes` — every enabled `BackupPolicy` must list at least one +//! `BackupScope`. +//! +//! ## Custody model (ADR-011) +//! +//! Four custody boxes, never co-dependent: keys live in `secretumvault`, +//! definitions in git, data in ≥2 destinations, production in the cluster. +//! Standalone restore needs only one custody box plus the bootstrap key. +//! +//! ## ontoref integration +//! +//! This crate is registered as the `backup-manager` Practice node in +//! `.ontology/core.ncl`. `ontoref generate-mdbook` extracts this `//!` and +//! produces architecture documentation automatically. The first sentence above +//! is Jaccard-aligned with the node `description` and validated by +//! `ontoref sync diff --docs`. + +#![forbid(unsafe_code)] +#![warn(missing_docs)] +#![warn(rust_2018_idioms)] + +pub mod config; +pub mod error; +pub mod modes; +pub mod policy; + +pub use config::BackupManagerConfig; +pub use error::{BackupError, Result}; diff --git a/crates/backup-manager/src/main.rs b/crates/backup-manager/src/main.rs new file mode 100644 index 0000000..af3da2d --- /dev/null +++ b/crates/backup-manager/src/main.rs @@ -0,0 +1,348 @@ +//! `prvng-backup` — entry point for the backup-manager binary. +//! +//! Subcommands map to the four modes described in the crate-level docs. +//! This first cut implements the `policy` subcommand end-to-end (validate +//! and render Nickel-exported policies); the other modes are stubs whose +//! signatures and CLI surface are wired so the contract is visible to users +//! and downstream code while the implementations land in subsequent commits. + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use clap::{Args, Parser, Subcommand}; + +use backup_manager::modes::policy_cmds; + +/// CLI for the backup-manager. +#[derive(Debug, Parser)] +#[command( + name = "prvng-backup", + version, + about = "Multi-context backup orchestrator (restic/kopia, ontoref-integrated)" +)] +struct Cli { + /// Path to a configuration file. When absent, the standard hierarchy + /// (`~/.config/provisioning/platform/config/backup-manager.ncl`) is used. + #[arg(short, long, env = "BACKUP_CONFIG")] + config: Option, + + /// Subcommand selecting the mode of operation. + #[command(subcommand)] + command: Mode, +} + +/// Top-level mode dispatch. +#[derive(Debug, Subcommand)] +enum Mode { + /// Render and validate Nickel policy declarations. + Policy(PolicyArgs), + /// One-shot operation invoked by a scheduler (cron, systemd timer). + OneShot(OneShotArgs), + /// Long-running daemon orchestrator. + Daemon(DaemonArgs), + /// Standalone workstation mode (mount/restore/list without cluster). + Standalone(StandaloneArgs), + /// BackupGroup coordinator. + Coordinator(CoordinatorArgs), +} + +// ── policy ────────────────────────────────────────────────────────────────── + +#[derive(Debug, Args)] +struct PolicyArgs { + /// Sub-action to perform. + #[command(subcommand)] + action: PolicyAction, +} + +#[derive(Debug, Subcommand)] +enum PolicyAction { + /// Validate one or more Nickel policy files (export + deserialise). + Validate { + /// Files or directories to validate. + #[arg(required = true)] + paths: Vec, + + /// Nickel `--import-path` value. + #[arg(long, default_value = "provisioning")] + import_path: String, + }, + /// Render a policy file to JSON on stdout. + Render { + /// Policy file to render. + path: PathBuf, + /// Nickel `--import-path` value. + #[arg(long, default_value = "provisioning")] + import_path: String, + }, +} + +// ── one-shot ──────────────────────────────────────────────────────────────── + +#[derive(Debug, Args)] +struct OneShotArgs { + /// Operation to perform. + #[command(subcommand)] + op: OneShotOp, +} + +#[derive(Debug, Subcommand)] +enum OneShotOp { + /// Run a backup for a component, group, or system target. + Backup { + /// Target identifier. + target: String, + /// Optional scope filter. + #[arg(long)] + scope: Option, + }, + /// Restore a snapshot to a directory. + Restore { + /// Target identifier. + target: String, + /// Snapshot ID or `latest`. + #[arg(long)] + snapshot: String, + /// Restore destination directory. + #[arg(long)] + to: PathBuf, + }, + /// Verify snapshots (provider-level integrity checks). + Verify { + /// Target identifier. + target: String, + /// Verify level. + #[arg(long, default_value = "quick")] + level: String, + }, + /// Apply retention policy and prune old snapshots. + Prune { + /// Target identifier. + target: String, + /// Dry run (do not delete). + #[arg(long)] + dry_run: bool, + }, + /// List snapshots. + List { + /// Target identifier. + target: String, + /// Optional scope filter. + #[arg(long)] + scope: Option, + }, +} + +// ── daemon ────────────────────────────────────────────────────────────────── + +#[derive(Debug, Args)] +struct DaemonArgs { + /// Daemon control action. + #[command(subcommand)] + action: DaemonAction, +} + +#[derive(Debug, Subcommand)] +enum DaemonAction { + /// Start the daemon (foreground). + Start, + /// Reload policies (sends SIGHUP equivalent through the local API). + Reload, + /// Drain the queue and shut down cleanly. + Drain, + /// Print status as JSON. + Status, +} + +// ── standalone ────────────────────────────────────────────────────────────── + +#[derive(Debug, Args)] +struct StandaloneArgs { + /// Standalone operation. + #[command(subcommand)] + op: StandaloneOp, +} + +#[derive(Debug, Subcommand)] +enum StandaloneOp { + /// Mount a snapshot via FUSE. + Mount { + /// Component name. + component: String, + /// Snapshot ID or `latest`. + #[arg(long)] + snapshot: String, + /// Mountpoint directory. + #[arg(long)] + at: PathBuf, + /// Tag filters (`key=value`). + #[arg(long)] + tag: Vec, + }, + /// Restore directly to a directory without going through the daemon. + Restore { + /// Component name. + component: String, + /// Snapshot ID or `latest`. + #[arg(long)] + snapshot: String, + /// Restore destination directory. + #[arg(long)] + to: PathBuf, + }, + /// List snapshots (no daemon). + List { + /// Component name. + component: String, + /// Optional scope filter. + #[arg(long)] + scope: Option, + }, +} + +// ── coordinator ───────────────────────────────────────────────────────────── + +#[derive(Debug, Args)] +struct CoordinatorArgs { + /// Coordinator action. + #[command(subcommand)] + action: CoordinatorAction, +} + +#[derive(Debug, Subcommand)] +enum CoordinatorAction { + /// Run a BackupGroup. + GroupRun { + /// Group name. + name: String, + }, + /// Restore a BackupGroup at a given timestamp. + GroupRestore { + /// Group name. + name: String, + /// RFC3339 timestamp. + #[arg(long)] + at: String, + /// Restore destination directory. + #[arg(long)] + to: PathBuf, + }, +} + +#[tokio::main] +async fn main() -> ExitCode { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_env("BACKUP_LOG") + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let cli = Cli::parse(); + + match cli.command { + Mode::Policy(args) => run_policy(args), + Mode::OneShot(_) | Mode::Daemon(_) | Mode::Standalone(_) | Mode::Coordinator(_) => { + eprintln!( + "this mode is wired in the CLI surface but its implementation \ + lands in a subsequent commit. The `policy` subcommand is fully \ + functional today (validate / render Nickel-exported policies)." + ); + ExitCode::from(2) + } + } +} + +fn run_policy(args: PolicyArgs) -> ExitCode { + match args.action { + PolicyAction::Validate { paths, import_path } => run_policy_validate(&paths, &import_path), + PolicyAction::Render { path, import_path } => run_policy_render(&path, &import_path), + } +} + +fn run_policy_validate(paths: &[PathBuf], import_path: &str) -> ExitCode { + let mut total_failures = 0usize; + for path in paths { + total_failures += validate_path(path, import_path); + } + + if total_failures == 0 { + ExitCode::SUCCESS + } else { + eprintln!("{} validation failure(s)", total_failures); + ExitCode::from(1) + } +} + +fn validate_path(path: &Path, import_path: &str) -> usize { + if path.is_dir() { + validate_directory(path, import_path) + } else { + let v = policy_cmds::validate_policy(path, import_path); + print_validation(&v); + usize::from(!v.ok) + } +} + +fn validate_directory(path: &Path, import_path: &str) -> usize { + let entries = match std::fs::read_dir(path) { + Ok(e) => e, + Err(e) => { + eprintln!("could not read directory {:?}: {}", path, e); + return 1; + } + }; + + entries + .flatten() + .filter_map(|entry| { + let p = entry.path(); + if p.extension().is_some_and(|e| e == "ncl") { + Some(p) + } else { + None + } + }) + .map(|p| { + let v = policy_cmds::validate_policy(&p, import_path); + print_validation(&v); + usize::from(!v.ok) + }) + .sum() +} + +fn run_policy_render(path: &Path, import_path: &str) -> ExitCode { + let value = match policy_cmds::nickel_export_to_json(path, import_path) { + Ok(v) => v, + Err(e) => { + eprintln!("{}", e); + return ExitCode::from(1); + } + }; + + match serde_json::to_string_pretty(&value) { + Ok(rendered) => { + println!("{}", rendered); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("failed to render JSON: {}", e); + ExitCode::from(1) + } + } +} + +fn print_validation(v: &policy_cmds::PolicyValidation) { + if v.ok { + println!( + "OK {} components={} backup-enabled={}", + v.source, v.component_count, v.enabled_backup_components.len() + ); + } else { + println!( + "FAIL {} error={}", + v.source, + v.error.as_deref().unwrap_or("unknown") + ); + } +} diff --git a/crates/backup-manager/src/modes/mod.rs b/crates/backup-manager/src/modes/mod.rs new file mode 100644 index 0000000..4d4b1a8 --- /dev/null +++ b/crates/backup-manager/src/modes/mod.rs @@ -0,0 +1,7 @@ +//! Execution modes — `one-shot`, `daemon`, `standalone`, `coordinator`. +//! +//! Each mode is a distinct subcommand of the [`prvng-backup`](../../bin/main.rs) +//! binary. The mode is selected at process start; the configuration loaded +//! from [`crate::BackupManagerConfig`] is shared across all modes. + +pub mod policy_cmds; diff --git a/crates/backup-manager/src/modes/policy_cmds.rs b/crates/backup-manager/src/modes/policy_cmds.rs new file mode 100644 index 0000000..f0c4f11 --- /dev/null +++ b/crates/backup-manager/src/modes/policy_cmds.rs @@ -0,0 +1,170 @@ +//! `policy` subcommand — render and validate `BackupPolicy` declarations. +//! +//! This is the first end-to-end mode wired into the binary. It exercises: +//! +//! - Reading workspace declarations via `nickel export --import-path …`. +//! - Deserialising the resulting JSON into [`crate::policy`] structs. +//! - Reporting structured information about policies, groups, and system +//! backup definitions without touching destinations or vault. +//! +//! Subsequent modes (`backup`, `restore`, `mount`, etc.) build on the same +//! deserialisation pipeline implemented here. + +use std::path::Path; +use std::process::Command; + +use crate::error::{BackupError, Result}; +use crate::policy::BackupPolicy; + +/// Render a Nickel file to JSON via the `nickel` CLI. +/// +/// Honours the `nickel_import_path` provided in [`crate::config::PoliciesRef`]. +pub fn nickel_export_to_json(path: &Path, import_path: &str) -> Result { + let output = Command::new("nickel") + .args(["export", "--format", "json", "--import-path", import_path]) + .arg(path) + .output() + .map_err(|e| BackupError::Policy(format!( + "failed to invoke `nickel export` on {:?}: {}", path, e + )))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BackupError::Policy(format!( + "nickel export failed for {:?}: {}", path, stderr + ))); + } + + serde_json::from_slice::(&output.stdout).map_err(BackupError::from) +} + +/// Result of validating a single policy file. +#[derive(Debug)] +pub struct PolicyValidation { + /// Path to the source Nickel file. + pub source: String, + /// Whether the file successfully exported and deserialised. + pub ok: bool, + /// Error message if validation failed. + pub error: Option, + /// Component count (top-level keys in the JSON object). + pub component_count: usize, + /// Names of components for which `concerns.backup.kind == "enabled"`. + pub enabled_backup_components: Vec, +} + +/// Validate a single Nickel policy file by exporting and deserialising it. +pub fn validate_policy(path: &Path, import_path: &str) -> PolicyValidation { + let source = path.display().to_string(); + let json = match nickel_export_to_json(path, import_path) { + Ok(v) => v, + Err(e) => { + return PolicyValidation { + source, + ok: false, + error: Some(e.to_string()), + component_count: 0, + enabled_backup_components: Vec::new(), + }; + } + }; + + let object = match json.as_object() { + Some(map) => map, + None => { + return PolicyValidation { + source, + ok: false, + error: Some("policy file did not export a JSON object".to_string()), + component_count: 0, + enabled_backup_components: Vec::new(), + }; + } + }; + + let mut enabled = Vec::new(); + for (component_name, component_value) in object { + // Best-effort: locate concerns.backup.kind inside the component. + // Components produced by `ext.make_X` helpers may nest the policy in + // different ways; we don't fail on missing concerns here, we just + // skip when not found. Strict enforcement is the Nickel layer's job. + if let Some(kind) = component_value + .pointer("/concerns/backup/kind") + .and_then(serde_json::Value::as_str) + { + if kind == "enabled" { + enabled.push(component_name.clone()); + } + } + } + + PolicyValidation { + source, + ok: true, + error: None, + component_count: object.len(), + enabled_backup_components: enabled, + } +} + +/// Try to deserialise a `BackupPolicy` directly from its JSON representation. +/// Used by tests and by future scope-expansion code; not exposed via CLI yet. +pub fn deserialize_backup_policy(value: serde_json::Value) -> Result { + serde_json::from_value(value).map_err(BackupError::from) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn deserialize_minimal_backup_policy() { + let value = json!({ + "provider": { "name": "restic" }, + "destinations": [ + { + "name": "primary", + "kind": "s3", + "uri": "s3:fsn1.hetzner/test", + "cred_ref": { "path": "creds/test", "kind": "s3" }, + "role": "primary" + }, + { + "name": "replica", + "kind": "b2", + "uri": "b2:test-replica", + "cred_ref": { "path": "creds/replica", "kind": "b2" }, + "role": "replica" + } + ], + "encryption": { + "path": "backup-manager/master-key", + "algorithm": "age_x25519" + }, + "schedule": { "kind": "cron", "cron_expr": "0 2 * * *" }, + "retention": { + "keep_last": 7, + "keep_daily": 7, + "keep_weekly": 4, + "keep_monthly": 6, + "keep_yearly": 0 + }, + "scopes": [ + { + "kind": "service_full", + "name": "main", + "paths": ["/data"] + } + ], + "tag_strategy": { + "component_label": "test-component" + } + }); + + let policy = deserialize_backup_policy(value).expect("deserialise"); + assert_eq!(policy.destinations.len(), 2); + assert_eq!(policy.scopes.len(), 1); + assert_eq!(policy.provider.name, "restic"); + } +} diff --git a/crates/backup-manager/src/policy/component.rs b/crates/backup-manager/src/policy/component.rs new file mode 100644 index 0000000..4ef5f50 --- /dev/null +++ b/crates/backup-manager/src/policy/component.rs @@ -0,0 +1,324 @@ +//! Component-level policy: `BackupPolicy`, `Destination`, `RetentionPolicy`, +//! `Schedule`, `BackupProviderRef`, plus the surrounding `ServiceConcerns` +//! umbrella. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::scope::BackupScope; + +// ============================================================================ +// ServiceConcerns umbrella (ADR-008) +// ============================================================================ + +/// One of `enabled`, `disabled`, `pending`, `inherited`. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ConcernStateKind { + /// Concern is implemented; the impl payload field is populated. + Enabled, + /// Concern is explicitly opted out. + Disabled, + /// Concern is deferred; `backlog_ref` points to the tracking item. + Pending, + /// Concern is inherited from a parent component. + Inherited, +} + +/// State of one concern within [`ServiceConcerns`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConcernState { + /// Discriminator. + pub kind: ConcernStateKind, + + /// `disabled` reason. + #[serde(default)] + pub reason: Option, + + /// `disabled` since (ISO date). + #[serde(default)] + pub since: Option, + + /// `pending` backlog reference. + #[serde(default)] + pub backlog_ref: Option, + + /// `pending` target iteration. + #[serde(default)] + pub target_iteration: Option, + + /// `inherited` parent component. + #[serde(default)] + pub from: Option, + + /// `enabled` payload — only one of these is populated based on which + /// concern this state belongs to. + #[serde(default)] + pub backup_impl: Option, + + /// `enabled` payload for non-backup concerns kept opaque at this layer + /// (TLS/DNS/certs/observability/security details are not consumed by the + /// backup-manager binary). + #[serde(default, flatten)] + pub other_impl: BTreeMap, +} + +/// Mandatory umbrella in `ComponentDef.concerns`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceConcerns { + /// TLS concern state (opaque to the backup binary). + pub tls: ConcernState, + /// DNS concern state. + pub dns: ConcernState, + /// Certificates concern state. + pub certs: ConcernState, + /// Backup concern state — when `kind = Enabled`, `backup_impl` is set. + pub backup: ConcernState, + /// Observability concern state. + pub observability: ConcernState, + /// Security concern state. + pub security: ConcernState, +} + +// ============================================================================ +// BackupPolicy +// ============================================================================ + +/// Reference to a [`BackupProvider`] declared under +/// `provisioning/extensions/providers/backup//`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackupProviderRef { + /// Provider directory name (e.g. `"restic"`, `"kopia"`). + pub name: String, + + /// Pinned CLI version. The daemon emits a warning if the installed + /// binary is older. + #[serde(default)] + pub version: Option, +} + +/// Schedule discriminated union (cron / interval / NATS-event). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScheduleSpec { + /// `cron`, `interval`, or `on_event`. + pub kind: String, + + /// Cron expression when `kind = "cron"`. + #[serde(default)] + pub cron_expr: Option, + + /// Jitter seconds applied to cron firing. + #[serde(default)] + pub jitter_sec: Option, + + /// Period when `kind = "interval"`. + #[serde(default)] + pub every: Option, + + /// Random jitter for interval scheduling. + #[serde(default)] + pub jitter: Option, + + /// NATS subject when `kind = "on_event"`. + #[serde(default)] + pub subject: Option, + + /// Debounce duration for event-driven schedules. + #[serde(default)] + pub debounce: Option, +} + +/// Retention preset. Mirrors restic's `--keep-*` flags. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct RetentionPolicy { + /// `--keep-last`. + #[serde(default)] + pub keep_last: u32, + /// `--keep-daily`. + #[serde(default)] + pub keep_daily: u32, + /// `--keep-weekly`. + #[serde(default)] + pub keep_weekly: u32, + /// `--keep-monthly`. + #[serde(default)] + pub keep_monthly: u32, + /// `--keep-yearly`. + #[serde(default)] + pub keep_yearly: u32, + /// Hard upper bound on snapshot age (deleted regardless of keep_*). + #[serde(default)] + pub prune_after: Option, +} + +/// Destination protocol kind. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DestinationKind { + /// S3-compatible object storage (Hetzner, MinIO, AWS S3, etc.). + S3, + /// Backblaze B2. + B2, + /// Local filesystem (only valid for archive role). + Local, + /// SFTP target. + Sftp, + /// restic REST server. + RestServer, +} + +/// Role of a destination within a policy. The `MultiDestinationRequired` +/// Nickel contract requires at least one `Primary` and a total of ≥2 entries. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum DestinationRole { + /// First-write target. Backups land here directly. + Primary, + /// Replicated copy of `Primary` for off-site durability. + Replica, + /// Cold archive (typically slower or cheaper storage). + Archive, +} + +/// Backup destination specification. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Destination { + /// Stable identifier used in metric labels and tags. + pub name: String, + /// Protocol kind. + pub kind: DestinationKind, + /// Provider URI (e.g. `s3:fsn1.hetzner/libre-wuji-backups`). + pub uri: String, + /// Reference to credentials in `secretumvault`. + pub cred_ref: VaultCredRef, + /// Role within the policy. + pub role: DestinationRole, + /// Region (for cloud providers that support it). + #[serde(default)] + pub region: Option, +} + +/// Reference to a credentials entry in vault. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VaultCredRef { + /// Path under vault (e.g. `backup-manager/destinations/hetzner-primary`). + pub path: String, + /// Type of credential payload at the path. + pub kind: String, +} + +/// Reference to an encryption key in vault. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VaultKeyRef { + /// Path under vault. + pub path: String, + /// Algorithm (`age_x25519`, `aes_gcm_256`, etc.). + #[serde(default)] + pub algorithm: String, + /// HKDF derivation parameters when applicable. + #[serde(default)] + pub derivation: Option, +} + +/// HKDF derivation parameters. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HkdfDerivation { + /// Method (`none` or `hkdf_sha256`). + pub method: String, + /// HKDF info parameter. + pub info: String, +} + +/// Tag generation strategy for snapshots. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TagStrategy { + /// Used as the `component=` tag. + pub component_label: String, + /// Additional static tags as `k=v` strings. + #[serde(default)] + pub extra: Vec, +} + +/// Pre/post hooks executed by the manager around the backup run. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Hooks { + /// Commands run before backup; non-zero exit aborts when `abort_on_failure = true`. + #[serde(default)] + pub pre: Vec, + /// Commands run after backup, regardless of outcome. + #[serde(default)] + pub post: Vec, + /// Hook timeout (e.g. `"5m"`). + #[serde(default = "default_hook_timeout")] + pub timeout: String, + /// Whether a non-zero pre hook aborts the backup. + #[serde(default = "default_abort_on_failure")] + pub abort_on_failure: bool, +} + +fn default_hook_timeout() -> String { "5m".to_string() } +fn default_abort_on_failure() -> bool { true } + +/// Bandwidth throttle (passed to provider as `--limit-upload`/`--limit-download`). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Throttle { + /// Upload limit in KB/s. + #[serde(default)] + pub upload_kbps: Option, + /// Download limit in KB/s. + #[serde(default)] + pub download_kbps: Option, +} + +/// Reference to a verify policy (drill spec lives separately to keep policies +/// focused). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerifyPolicyRef { + /// Verify schedule. + #[serde(default)] + pub schedule: Option, + /// Level: `quick`, `deep`, `restore_drill`, `full_dr`. + #[serde(default = "default_verify_level")] + pub level: String, + /// Reference to a `DrillSpec` by name. + #[serde(default)] + pub drill_ref: Option, +} + +fn default_verify_level() -> String { "quick".to_string() } + +/// Component-level backup policy. +/// +/// The Nickel contracts `EncryptionRequired`, `MultiDestinationRequired`, and +/// `NonEmptyScopes` are enforced at policy export time, so values reaching +/// this struct already satisfy the invariants. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackupPolicy { + /// Provider reference. + pub provider: BackupProviderRef, + /// ≥2 destinations, at least one with `role = Primary`. + pub destinations: Vec, + /// Encryption key reference. + pub encryption: VaultKeyRef, + /// Schedule. + pub schedule: ScheduleSpec, + /// Retention policy. + pub retention: RetentionPolicy, + /// Scopes (≥1). + pub scopes: Vec, + /// Tag strategy. + pub tag_strategy: TagStrategy, + /// Optional hooks. + #[serde(default)] + pub hooks: Option, + /// Optional verify reference. + #[serde(default)] + pub verify: Option, + /// Optional throttle. + #[serde(default)] + pub throttle: Option, + /// Optional consistency-group name (this policy participates in a `BackupGroup`). + #[serde(default)] + pub consistency_group: Option, +} diff --git a/crates/backup-manager/src/policy/group.rs b/crates/backup-manager/src/policy/group.rs new file mode 100644 index 0000000..c598b95 --- /dev/null +++ b/crates/backup-manager/src/policy/group.rs @@ -0,0 +1,68 @@ +//! BackupGroup — multi-component consistency points. + +use serde::{Deserialize, Serialize}; + +use super::component::{Destination, RetentionPolicy, ScheduleSpec, TagStrategy, VaultKeyRef, + VerifyPolicyRef}; + +/// Member of a [`BackupGroup`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroupMember { + /// Component name. + pub component: String, + /// Optional scope filter; omitted means all scopes of the component. + #[serde(default)] + pub scope: Option, +} + +/// Coordination strategy discriminator. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CoordinationKind { + /// Snapshots fired in parallel; consistency is tag-based (group_id). + BestEffort, + /// Ordered pre-hooks pause writers, snapshot, unquiesce. Bounded downtime. + QuiesceWindow, + /// Atomic at CSI layer via Longhorn VolumeSnapshotGroup. + CsiConsistentGroup, +} + +/// Coordination strategy applied during a group run. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CoordinationStrategy { + /// Variant. + pub kind: CoordinationKind, + /// Quiesce sequence (`QuiesceWindow`). + #[serde(default)] + pub quiesce_seq: Vec, + /// Maximum downtime for `QuiesceWindow`. + #[serde(default)] + pub max_downtime: Option, + /// CSI VolumeSnapshotClass for `CsiConsistentGroup`. + #[serde(default)] + pub snapshot_class: Option, +} + +/// A group of components/scopes captured atomically (à la Chandy-Lamport). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackupGroup { + /// Group identifier (used in CLI: `--group `). + pub name: String, + /// Members participating in the consistent cut. + pub members: Vec, + /// Group schedule (independent of per-member policies). + pub schedule: ScheduleSpec, + /// Coordination strategy. + pub coordination: CoordinationStrategy, + /// Retention shared across all members for the group's snapshots. + pub retention: RetentionPolicy, + /// Destinations (same `MultiDestinationRequired` invariant). + pub destinations: Vec, + /// Encryption key. + pub encryption: VaultKeyRef, + /// Tag strategy. + pub tag_strategy: TagStrategy, + /// Optional verify reference. + #[serde(default)] + pub verify: Option, +} diff --git a/crates/backup-manager/src/policy/mod.rs b/crates/backup-manager/src/policy/mod.rs new file mode 100644 index 0000000..7d70d8a --- /dev/null +++ b/crates/backup-manager/src/policy/mod.rs @@ -0,0 +1,26 @@ +//! Policy types deserialised from Nickel-exported JSON. +//! +//! Nickel files under `provisioning/schemas/lib/` define the canonical schema +//! (BackupPolicy, BackupGroup, SystemBackupDef, ServiceConcerns, etc.). +//! The manager invokes `nickel export` on workspace declarations and parses +//! the resulting JSON into these structs. The JSON tag conventions mirror the +//! Nickel discriminated unions: `kind` field plus payload variants. +//! +//! Only the shape needed at runtime is materialised here; the full Nickel +//! schema includes optional fields (e.g. extended `dns_records.extra`) that +//! we treat as `serde_json::Value` to avoid coupling the binary to every +//! schema evolution. + +pub mod component; +pub mod group; +pub mod scope; +pub mod system; + +pub use component::{ + BackupPolicy, BackupProviderRef, ConcernState, Destination, DestinationKind, + DestinationRole, RetentionPolicy, ScheduleSpec, ServiceConcerns, +}; +pub use group::{BackupGroup, CoordinationKind, CoordinationStrategy, GroupMember}; +pub use scope::{BackupScope, DumpStrategy, DumpStrategyKind, ScopeKind}; +pub use system::{HostSelector, HostSelectorKind, SystemBackupDef, SystemBackupTarget, + SystemBackupTargetKind}; diff --git a/crates/backup-manager/src/policy/scope.rs b/crates/backup-manager/src/policy/scope.rs new file mode 100644 index 0000000..1915265 --- /dev/null +++ b/crates/backup-manager/src/policy/scope.rs @@ -0,0 +1,175 @@ +//! BackupScope and DumpStrategy types — the unit of backup work. +//! +//! Each scope expands to one provider invocation with deterministic tags. +//! The discriminator is `kind`; the rest of the fields are populated only +//! for variants that use them (Nickel emits `default = []`/`default = {}`). + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +/// Discriminator for [`BackupScope`]. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ScopeKind { + /// Snapshot a fixed list of paths. + ServiceFull, + /// Snapshot per domain (one tag set per domain). + PerDomain, + /// Snapshot per mailbox (selector-driven enumeration). + PerMailbox, + /// Snapshot a database via a [`DumpStrategy`]. + Database, + /// Snapshot a CSI volume by name. + VolumeSnapshot, + /// Archive logs from a source (loki / journald / files). + LogsArchive, + /// Export a subset of a key-value store (etcd / consul). + KvExport, +} + +/// Database engine, when scope kind is [`ScopeKind::Database`]. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum DbEngine { + /// PostgreSQL — supports streaming pg_dump and WAL archiving externally. + Postgresql, + /// MariaDB. + Mariadb, + /// MySQL. + Mysql, + /// Redis (RDB snapshot or AOF). + Redis, + /// MongoDB (mongodump). + Mongodb, + /// SurrealDB. + Surrealdb, + /// etcd (etcdctl snapshot). + Etcd, + /// SQLite (file-level). + Sqlite, +} + +/// Discriminator for [`DumpStrategy`]. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DumpStrategyKind { + /// Stream the dump straight to the provider's stdin. + StreamToStdin, + /// Write the dump to a path, then snapshot the path. + DumpToPath, + /// Like `DumpToPath` but with a custom dump command. + PreDumpThenPath, + /// CSI VolumeSnapshot CRD (crash-consistent, no app coordination). + CsiVolumeSnapshot, + /// Application quiesce hook + snapshot + unquiesce. + AppQuiesceThenSnapshot, +} + +/// Strategy for capturing a database scope. See ADR-013 for the consistency +/// trade-offs across variants. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DumpStrategy { + /// Variant discriminator. + pub kind: DumpStrategyKind, + + /// Custom dump command (optional, used by `PreDumpThenPath`). + #[serde(default)] + pub dump_command: Option, + + /// Path where the dump file lives (`DumpToPath`, `PreDumpThenPath`). + #[serde(default)] + pub path: Option, + + /// Whether to remove the dump file after the snapshot completes. + #[serde(default = "default_true")] + pub cleanup: bool, + + /// CSI volume name (`CsiVolumeSnapshot`, `AppQuiesceThenSnapshot`). + #[serde(default)] + pub volume: Option, + + /// CSI VolumeSnapshotClass. + #[serde(default)] + pub snapshot_class: Option, + + /// Quiesce command (`AppQuiesceThenSnapshot`). + #[serde(default)] + pub quiesce_cmd: Option, + + /// Unquiesce command (`AppQuiesceThenSnapshot`). + #[serde(default)] + pub unquiesce_cmd: Option, +} + +fn default_true() -> bool { true } + +/// One unit of backup work. The provider receives this via [`crate::policy::component::BackupPolicy`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackupScope { + /// Discriminator. + pub kind: ScopeKind, + + /// Identifier for the scope (used in CLI: `--scope `). + pub name: String, + + /// Path list (`ServiceFull`, `LogsArchive`). + #[serde(default)] + pub paths: Vec, + + /// Exclusion globs. + #[serde(default)] + pub exclude: Vec, + + /// Domain list (`PerDomain`). + #[serde(default)] + pub domains: Vec, + + /// Base path for per-domain / per-mailbox scopes. + #[serde(default)] + pub base_path: String, + + /// Mailbox selector (`PerMailbox`). + #[serde(default)] + pub selector: Option, + + /// Database engine (`Database`). + #[serde(default)] + pub engine: Option, + + /// Dump strategy (`Database`). + #[serde(default)] + pub dump_strategy: Option, + + /// Volume names (`VolumeSnapshot`). + #[serde(default)] + pub volumes: Vec, + + /// CSI VolumeSnapshotClass (`VolumeSnapshot`). + #[serde(default)] + pub snapshot_class: Option, + + /// Log sources (`LogsArchive`). + #[serde(default)] + pub sources: Vec, + + /// Log archive format (`LogsArchive`). + #[serde(default)] + pub format: Option, + + /// Rotation duration (`LogsArchive`). + #[serde(default)] + pub rotation: Option, + + /// KV source kind (`KvExport`). + #[serde(default)] + pub source: Option, + + /// Tag prefix prepended to determinístico tags. + #[serde(default)] + pub tag_prefix: String, + + /// Static extra tags (key-value). + #[serde(default)] + pub tags: BTreeMap, +} diff --git a/crates/backup-manager/src/policy/system.rs b/crates/backup-manager/src/policy/system.rs new file mode 100644 index 0000000..54cb9bc --- /dev/null +++ b/crates/backup-manager/src/policy/system.rs @@ -0,0 +1,196 @@ +//! SystemBackupDef — backup specifications for artefacts outside the cluster. +//! +//! Targets include etcd, k8s certs, host configs, external DNS configurations, +//! builder environment tools, provisioning state, log archives, SOPS/Age key +//! bundles, and vault state. Each target is a discriminated variant; the +//! manager dispatches based on `kind` (see ADR-011 for vault state custody). + +use serde::{Deserialize, Serialize}; + +use super::component::{ + BackupProviderRef, Destination, RetentionPolicy, ScheduleSpec, TagStrategy, Throttle, + VaultCredRef, VaultKeyRef, VerifyPolicyRef, Hooks, +}; + +/// Discriminator for [`HostSelector`]. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum HostSelectorKind { + /// Run on a single control-plane host (any). + CpOnly, + /// Run on the first control-plane host (deterministic by name sort). + CpFirst, + /// Run on all control-plane hosts. + ControlPlanes, + /// Run on all worker hosts. + Workers, + /// Run on every server in the workspace. + AllServers, + /// Run on the listed hosts. + List, +} + +/// Selects which hosts execute a system backup. Mirrors the Nickel `HostSelector`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HostSelector { + /// Variant. + pub kind: HostSelectorKind, + /// Hostname list when `kind = List`. + #[serde(default)] + pub members: Vec, +} + +/// Discriminator for [`SystemBackupTarget`]. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SystemBackupTargetKind { + /// etcd snapshot via etcdctl. + Etcd, + /// Kubernetes PKI certificates (`/etc/kubernetes/pki`). + K8sCerts, + /// Cluster resources (Secret/ConfigMap/CRD content). + ClusterResources, + /// Longhorn engine state. + LonghornEngine, + /// Host configuration files. + HostConfigs, + /// External DNS server configuration and zone files. + ExternalDns, + /// Builder environment tools and caches. + BuilderEnv, + /// Provisioning state (definitions, state, locks). + ProvisioningState, + /// Log archive (loki / journald / files). + LogsArchive, + /// SOPS/Age key bundle (separate custody chain). + SopsKeys, + /// secretumvault state (with bootstrap-key encryption per ADR-011). + VaultState, +} + +/// Discriminated target description. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SystemBackupTarget { + /// Variant. + pub kind: SystemBackupTargetKind, + + // ── 'etcd + /// etcd endpoints. + #[serde(default)] + pub endpoints: Vec, + /// CA reference. + #[serde(default)] + pub ca_ref: Option, + /// Client cert reference. + #[serde(default)] + pub cert_ref: Option, + /// Client key reference. + #[serde(default)] + pub key_ref: Option, + + // ── 'k8s_certs / 'host_configs / 'logs_archive (paths) + /// Path list. + #[serde(default)] + pub paths: Vec, + /// Exclusion globs. + #[serde(default)] + pub exclude: Vec, + + // ── 'cluster_resources + /// Namespace list. + #[serde(default)] + pub namespaces: Vec, + /// Kind list (`secret`, `certificate`, ...). + #[serde(default)] + pub kinds: Vec, + + // ── 'longhorn_engine + /// Component list (`volumes`, `engines`, `replicas`, `settings`). + #[serde(default)] + pub components: Vec, + + // ── 'external_dns + /// Source kind (`coredns`, `powerdns`, `unbound`, ...). + #[serde(default)] + pub source_kind: Option, + /// Config file paths. + #[serde(default)] + pub config_paths: Vec, + /// Zone file directories. + #[serde(default)] + pub zones_paths: Vec, + + // ── 'builder_env + /// Tool names (informational tags). + #[serde(default)] + pub tools: Vec, + /// Secret names that must accompany the artefact. + #[serde(default)] + pub secrets: Vec, + + // ── 'provisioning_state + /// Definitions directory. + #[serde(default)] + pub definitions_path: Option, + /// State directory. + #[serde(default)] + pub state_path: Option, + /// Lock directory. + #[serde(default)] + pub lock_path: Option, + + // ── 'logs_archive + /// Loki/journald selector. + #[serde(default)] + pub selector: Option, + /// Archive format. + #[serde(default)] + pub format: Option, + + // ── 'sops_keys / 'vault_state + /// Age key file paths. + #[serde(default)] + pub age_keys: Vec, + /// Recipient public keys. + #[serde(default)] + pub recipients: Vec, + /// Vault HTTP endpoint to back up. + #[serde(default)] + pub vault_endpoint: Option, + /// Specific vault paths to capture (omitted = full state). + #[serde(default)] + pub vault_paths: Vec, +} + +/// Top-level system backup definition. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SystemBackupDef { + /// Identifier (used in CLI: `prvng-backup one-shot backup `). + pub name: String, + /// Target description. + pub target: SystemBackupTarget, + /// Host selector. + pub host_selector: HostSelector, + /// Provider. + pub provider: BackupProviderRef, + /// Schedule. + pub schedule: ScheduleSpec, + /// Retention. + pub retention: RetentionPolicy, + /// Destinations. + pub destinations: Vec, + /// Encryption key. For `VaultState`, ADR-011 requires a bootstrap key + /// stored OUTSIDE vault (offline custody). + pub encryption: VaultKeyRef, + /// Tag strategy. + pub tag_strategy: TagStrategy, + /// Optional verify reference. + #[serde(default)] + pub verify: Option, + /// Optional hooks. + #[serde(default)] + pub hooks: Option, + /// Optional throttle. + #[serde(default)] + pub throttle: Option, +} diff --git a/crates/buildkit-launcher/Cargo.toml b/crates/buildkit-launcher/Cargo.toml new file mode 100644 index 0000000..2bffadd --- /dev/null +++ b/crates/buildkit-launcher/Cargo.toml @@ -0,0 +1,21 @@ +[package] +authors.workspace = true +description = "Ephemeral buildkit runner launcher — spawns hcloud VMs, runs buildctl, emits metrics (ADR-039)" +edition.workspace = true +name = "buildkit-launcher" +version.workspace = true + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "process", "time", "io-util"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +uuid = { workspace = true } + +[[bin]] +name = "buildkit-launcher" +path = "src/main.rs" diff --git a/crates/buildkit-launcher/Cargo.workspace.toml b/crates/buildkit-launcher/Cargo.workspace.toml new file mode 100644 index 0000000..9e70d70 --- /dev/null +++ b/crates/buildkit-launcher/Cargo.workspace.toml @@ -0,0 +1,19 @@ +[workspace] +members = ["crates/buildkit-launcher"] +resolver = "2" + +[workspace.package] +authors = ["Jesus Perez "] +edition = "2021" +version = "1.0.11" + +[workspace.dependencies] +anyhow = "1.0" +clap = { version = "4.5", features = ["derive", "env"] } +reqwest = { version = "0.13", features = ["json", "rustls"], default-features = false } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1.49", features = ["rt-multi-thread", "process", "time", "io-util", "macros"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1.20", features = ["v4", "serde"] } diff --git a/crates/buildkit-launcher/Dockerfile b/crates/buildkit-launcher/Dockerfile new file mode 100644 index 0000000..42341d5 --- /dev/null +++ b/crates/buildkit-launcher/Dockerfile @@ -0,0 +1,38 @@ +# syntax=docker/dockerfile:1.7 +# Build context: provisioning/ (pass --local context=. from provisioning/ root) +# Dockerfile path (buildctl): platform/crates/buildkit-launcher/Dockerfile +# Target architecture: aarch64 (CAX/CCX Woodpecker agents are ARM64) +# Cache: --export-cache / --import-cache against zot /cache/buildkit-launcher + +FROM rust:bookworm AS builder + +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config libssl-dev ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN rustup target add aarch64-unknown-linux-gnu + +WORKDIR /workspace + +# Cargo.workspace.toml is a self-contained workspace manifest: it omits all +# path deps that point outside this build context (ontoref, stratumiops, etc.). +# It must define every key that the crate's Cargo.toml inherits via .workspace = true. +COPY platform/crates/buildkit-launcher/Cargo.workspace.toml Cargo.toml +COPY platform/crates/buildkit-launcher/ crates/buildkit-launcher/ + +RUN --mount=type=cache,target=/root/.cargo/registry,sharing=locked \ + --mount=type=cache,target=/root/.cargo/git,sharing=locked \ + --mount=type=cache,target=/workspace/target,sharing=locked \ + cargo build --release --target aarch64-unknown-linux-gnu \ + --package buildkit-launcher && \ + cp target/aarch64-unknown-linux-gnu/release/buildkit-launcher /buildkit-launcher-bin + +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates openssh-client \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /buildkit-launcher-bin /buildkit-launcher + +ENTRYPOINT ["/buildkit-launcher"] diff --git a/crates/buildkit-launcher/src/buildctl_runner.rs b/crates/buildkit-launcher/src/buildctl_runner.rs new file mode 100644 index 0000000..38286c9 --- /dev/null +++ b/crates/buildkit-launcher/src/buildctl_runner.rs @@ -0,0 +1,137 @@ +use anyhow::{Context, Result}; +use std::path::Path; +use tokio::process::Command; +use tracing::{debug, info}; + +pub const OOM_EXIT_CODE: i32 = 137; + +pub struct BuildOutput { + pub cpu_secs: f64, + pub mem_peak_mb: f64, + pub duration_secs: f64, +} + +pub async fn rsync_context( + ssh_host: &str, + ssh_port: u16, + context_path: &Path, + ssh_key: &Path, +) -> Result<()> { + info!(host = %ssh_host, port = ssh_port, "rsyncing build context"); + + let status = Command::new("rsync") + .args([ + "-az", + "--delete", + "-e", + &format!( + "ssh -p {} -i {} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null", + ssh_port, + ssh_key.display() + ), + ]) + .arg(format!("{}/", context_path.display())) + .arg(format!("root@{}:/build/context/", ssh_host)) + .status() + .await + .context("rsync failed to start")?; + + if !status.success() { + anyhow::bail!("rsync exited {}", status); + } + Ok(()) +} + +pub async fn run_buildctl( + ssh_host: &str, + ssh_port: u16, + ssh_key: &Path, + image_ref: &str, + dockerfile: &str, + cache_from: Option<&str>, + cache_to: Option<&str>, +) -> Result { + let mut buildctl_args = vec![ + "build".to_string(), + "--frontend".to_string(), + "dockerfile.v0".to_string(), + "--opt".to_string(), + format!("filename={}", dockerfile), + "--local".to_string(), + "context=/build/context".to_string(), + "--local".to_string(), + "dockerfile=/build/context".to_string(), + "--output".to_string(), + format!("type=image,name={},push=true", image_ref), + ]; + + if let Some(from) = cache_from { + buildctl_args.push("--import-cache".to_string()); + buildctl_args.push(from.to_string()); + } + if let Some(to) = cache_to { + buildctl_args.push("--export-cache".to_string()); + buildctl_args.push(to.to_string()); + } + + let remote_cmd = format!("buildctl {}", buildctl_args.join(" ")); + debug!(cmd = %remote_cmd, "running buildctl on runner"); + + let start = std::time::Instant::now(); + + let output = Command::new("ssh") + .args([ + "-p", + &ssh_port.to_string(), + "-i", + &ssh_key.to_string_lossy(), + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + &format!("root@{}", ssh_host), + &remote_cmd, + ]) + .output() + .await + .context("ssh buildctl failed to start")?; + + let duration_secs = start.elapsed().as_secs_f64(); + + if !output.status.success() { + let exit_code = output.status.code().unwrap_or(-1); + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!( + "buildctl exit={}: {}", + exit_code, + stderr.lines().last().unwrap_or("(no stderr)") + ); + } + + // Parse /proc/self/status peak memory from buildkitd if available in stdout + // Fallback: estimate from image size (not accurate; real metrics come from cgroups) + let mem_peak_mb = parse_mem_peak_from_output(&String::from_utf8_lossy(&output.stdout)) + .unwrap_or(0.0); + + // cpu_secs: rough estimate — not from cgroups; orchestrator records real P95 after multiple runs + let cpu_secs = duration_secs; + + Ok(BuildOutput { cpu_secs, mem_peak_mb, duration_secs }) +} + +fn parse_mem_peak_from_output(stdout: &str) -> Option { + for line in stdout.lines() { + if let Some(val) = line.strip_prefix("MEM_PEAK_MB=") { + return val.parse().ok(); + } + } + None +} + +pub fn is_oom(err: &anyhow::Error) -> bool { + let msg = err.to_string(); + msg.contains(&format!("exit={}", OOM_EXIT_CODE)) + || msg.contains("exit=137") + || msg.contains("OOM") + || msg.contains("Killed") +} diff --git a/crates/buildkit-launcher/src/main.rs b/crates/buildkit-launcher/src/main.rs new file mode 100644 index 0000000..9f89ff3 --- /dev/null +++ b/crates/buildkit-launcher/src/main.rs @@ -0,0 +1,188 @@ +mod buildctl_runner; +mod orchestrator_client; +mod retry; +mod sizing; + +use anyhow::{Context, Result}; +use clap::Parser; +use orchestrator_client::OrchestratorClient; +use std::path::PathBuf; +use tracing::{error, info, warn}; + +#[derive(Parser)] +#[command(name = "buildkit-launcher", about = "Ephemeral buildkit runner — ADR-039")] +struct Args { + /// Workspace name (used for P95 lookup and runner labelling) + #[arg(long, env = "BUILDKIT_WORKSPACE")] + workspace: String, + + /// Build context directory (must be reachable from this host) + #[arg(long)] + context: PathBuf, + + /// Fully-qualified image reference to push after build + #[arg(long)] + image: String, + + /// Dockerfile path relative to context + #[arg(long, default_value = "Dockerfile")] + dockerfile: String, + + /// Cache-from reference (optional, e.g. type=registry,ref=zot.example/cache) + #[arg(long)] + cache_from: Option, + + /// Cache-to reference (optional) + #[arg(long)] + cache_to: Option, + + /// SSH key to use when connecting to the runner + #[arg(long, env = "BUILDKIT_SSH_KEY")] + ssh_key: PathBuf, + + /// Language hint for size defaults (rust, go, java, etc.) + #[arg(long)] + language: Option, + + /// Golden image snapshot ID or name to boot the runner from + #[arg(long, env = "BUILDKIT_RUNNER_IMAGE", default_value = "buildkit-runner-latest")] + runner_image: String, + + /// Orchestrator base URL + #[arg(long, env = "ORCHESTRATOR_URL", default_value = "http://localhost:9011")] + orchestrator_url: String, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("buildkit_launcher=info".parse()?), + ) + .init(); + + let args = Args::parse(); + let client = OrchestratorClient::new(args.orchestrator_url.clone()); + + // Resolve runner size + let p95 = client.get_p95(&args.workspace).await.unwrap_or(None); + let (p95_cpu, p95_mem) = p95 + .as_ref() + .map(|s| (Some(s.cpu_p95), Some(s.memory_mb_p95))) + .unwrap_or((None, None)); + + let size = sizing::resolve( + &args.context, + p95_cpu, + p95_mem, + args.language.as_deref(), + ) + .context("failed to resolve runner size")?; + + info!( + cpu = size.cpu, + memory_gb = size.memory_gb, + workspace = %args.workspace, + "resolved runner size" + ); + + let result = run_build(&args, &client, size.clone()).await; + + match result { + Ok(()) => Ok(()), + Err(e) if buildctl_runner::is_oom(&e) && retry::oom_retry_allowed(0) => { + warn!( + error = %e, + limit = retry::MAX_OOM_RETRIES, + "OOM detected — retrying at next size tier" + ); + + let retry_size = retry::next_size_tier(&size) + .context("OOM on largest tier — no retry possible")?; + + info!( + cpu = retry_size.cpu, + memory_gb = retry_size.memory_gb, + "retrying build at next tier" + ); + + run_build(&args, &client, retry_size).await + } + Err(e) => Err(e), + } +} + +async fn run_build(args: &Args, client: &OrchestratorClient, size: sizing::RunnerSize) -> Result<()> { + let runner = client + .spawn_runner(&size, &args.workspace, &args.runner_image) + .await + .context("failed to spawn runner")?; + + info!( + lease_id = %runner.lease_id, + ssh_host = %runner.ssh_host, + ssh_port = runner.ssh_port, + expires_at = %runner.expires_at, + "runner spawned" + ); + + let destroy = |lease_id: String| { + let client = client.clone(); + tokio::spawn(async move { + if let Err(e) = client.destroy_runner(&lease_id).await { + error!(lease_id = %lease_id, error = %e, "failed to destroy runner"); + } + }) + }; + + // Rsync build context to runner + if let Err(e) = buildctl_runner::rsync_context( + &runner.ssh_host, + runner.ssh_port, + &args.context, + &args.ssh_key, + ) + .await + { + destroy(runner.lease_id.clone()); + return Err(e.context("rsync build context failed")); + } + + // Run buildctl + let build_result = buildctl_runner::run_buildctl( + &runner.ssh_host, + runner.ssh_port, + &args.ssh_key, + &args.image, + &args.dockerfile, + args.cache_from.as_deref(), + args.cache_to.as_deref(), + ) + .await; + + // Always destroy the runner, even on build failure + destroy(runner.lease_id.clone()); + + let output = build_result.context("buildctl failed")?; + + info!( + duration_secs = output.duration_secs, + mem_peak_mb = output.mem_peak_mb, + "build complete — recording metrics" + ); + + if let Err(e) = client + .record_metrics( + &args.workspace, + output.cpu_secs, + output.mem_peak_mb, + output.duration_secs, + ) + .await + { + warn!(error = %e, "failed to record build metrics (non-fatal)"); + } + + Ok(()) +} diff --git a/crates/buildkit-launcher/src/orchestrator_client.rs b/crates/buildkit-launcher/src/orchestrator_client.rs new file mode 100644 index 0000000..f7915b3 --- /dev/null +++ b/crates/buildkit-launcher/src/orchestrator_client.rs @@ -0,0 +1,158 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::sizing::RunnerSize; + +#[derive(Debug, Serialize)] +pub struct SpawnRequest { + pub cpu: u32, + pub memory_gb: u32, + pub disk_gb: u32, + pub time_budget_min: u32, + pub workspace: String, + pub image: String, +} + +#[derive(Debug, Deserialize)] +pub struct SpawnResponse { + pub lease_id: String, + pub ssh_host: String, + pub ssh_port: u16, + pub expires_at: String, +} + +#[derive(Debug, Deserialize)] +pub struct P95Stats { + pub cpu_p95: f64, + pub memory_mb_p95: f64, + #[allow(dead_code)] + pub duration_secs_p95: f64, + #[allow(dead_code)] + pub sample_count: u64, +} + +#[derive(Debug, Deserialize)] +struct ApiResponse { + success: bool, + data: Option, + error: Option, +} + +#[derive(Clone)] +pub struct OrchestratorClient { + base_url: String, + http: reqwest::Client, +} + +impl OrchestratorClient { + pub fn new(base_url: String) -> Self { + Self { + base_url, + http: reqwest::Client::new(), + } + } + + pub async fn spawn_runner( + &self, + size: &RunnerSize, + workspace: &str, + image: &str, + ) -> Result { + let req = SpawnRequest { + cpu: size.cpu, + memory_gb: size.memory_gb, + disk_gb: size.disk_gb, + time_budget_min: size.time_budget_min, + workspace: workspace.to_string(), + image: image.to_string(), + }; + + let resp: ApiResponse = self + .http + .post(format!("{}/api/v1/vm-pool/spawn", self.base_url)) + .json(&req) + .send() + .await + .context("POST /api/v1/vm-pool/spawn failed")? + .error_for_status() + .context("orchestrator returned error for spawn")? + .json() + .await + .context("failed to deserialize SpawnResponse")?; + + if !resp.success { + anyhow::bail!( + "spawn_runner: {}", + resp.error.as_deref().unwrap_or("unknown error") + ); + } + resp.data.context("spawn_runner: missing data in response") + } + + pub async fn destroy_runner(&self, lease_id: &str) -> Result<()> { + let resp = self + .http + .delete(format!("{}/api/v1/vm-pool/{}", self.base_url, lease_id)) + .send() + .await + .context("DELETE /api/v1/vm-pool/{lease_id} failed")?; + + if !resp.status().is_success() { + anyhow::bail!("destroy_runner HTTP {}", resp.status()); + } + Ok(()) + } + + pub async fn get_p95(&self, workspace: &str) -> Result> { + let resp = self + .http + .get(format!( + "{}/api/v1/vm-pool/p95/{}", + self.base_url, workspace + )) + .send() + .await + .context("GET /api/v1/vm-pool/p95/{workspace} failed")?; + + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + + let body: ApiResponse = resp + .error_for_status() + .context("p95 endpoint error")? + .json() + .await + .context("failed to deserialize P95Stats")?; + + Ok(body.data) + } + + pub async fn record_metrics( + &self, + workspace: &str, + cpu_secs: f64, + mem_peak_mb: f64, + duration_secs: f64, + ) -> Result<()> { + let payload = serde_json::json!({ + "workspace": workspace, + "cpu_secs": cpu_secs, + "mem_peak_mb": mem_peak_mb, + "duration_secs": duration_secs, + }); + + let resp = self + .http + .post(format!("{}/api/v1/vm-pool/metrics", self.base_url)) + .json(&payload) + .send() + .await + .context("POST /api/v1/vm-pool/metrics failed")?; + + if !resp.status().is_success() { + anyhow::bail!("record_metrics HTTP {}", resp.status()); + } + Ok(()) + } +} diff --git a/crates/buildkit-launcher/src/retry.rs b/crates/buildkit-launcher/src/retry.rs new file mode 100644 index 0000000..08c8f56 --- /dev/null +++ b/crates/buildkit-launcher/src/retry.rs @@ -0,0 +1,31 @@ +use crate::sizing::RunnerSize; + +/// Hard bound on OOM auto-retries per build — ADR-039 constraint oom-retry-bounded. +/// Repeated OOM after one retry indicates misconfiguration; surface to developer. +pub const MAX_OOM_RETRIES: u8 = 1; + +/// Returns whether an OOM retry is permitted given the count of retries already consumed. +pub fn oom_retry_allowed(retries_used: u8) -> bool { + retries_used < MAX_OOM_RETRIES +} + +const SIZE_TIERS: &[(&str, u32, u32)] = &[ + ("cx22", 2, 4), + ("cx32", 4, 8), + ("cx42", 8, 16), + ("cx52", 16, 32), +]; + +pub fn next_size_tier(current: &RunnerSize) -> Option { + // Find current tier by matching cpu/memory_gb + let current_idx = SIZE_TIERS.iter().position(|(_, cpu, mem)| { + *cpu >= current.cpu && *mem >= current.memory_gb + })?; + + SIZE_TIERS.get(current_idx + 1).map(|(_, cpu, mem)| RunnerSize { + cpu: *cpu, + memory_gb: *mem, + disk_gb: current.disk_gb, + time_budget_min: current.time_budget_min, + }) +} diff --git a/crates/buildkit-launcher/src/sizing.rs b/crates/buildkit-launcher/src/sizing.rs new file mode 100644 index 0000000..a8af8f5 --- /dev/null +++ b/crates/buildkit-launcher/src/sizing.rs @@ -0,0 +1,92 @@ +use anyhow::Result; +use serde::Deserialize; +use std::path::Path; + +#[derive(Debug, Clone)] +pub struct RunnerSize { + pub cpu: u32, + pub memory_gb: u32, + pub disk_gb: u32, + pub time_budget_min: u32, +} + +#[derive(Deserialize, Default)] +struct BuildSpec { + cpu: Option, + memory_gb: Option, + disk_gb: Option, + time_budget_min: Option, + language: Option, +} + +impl RunnerSize { + fn language_default(language: &str) -> Self { + match language { + "rust" => Self { cpu: 4, memory_gb: 8, disk_gb: 50, time_budget_min: 60 }, + "go" => Self { cpu: 2, memory_gb: 4, disk_gb: 30, time_budget_min: 30 }, + "java" | "kotlin" | "scala" => Self { cpu: 4, memory_gb: 8, disk_gb: 40, time_budget_min: 45 }, + _ => Self { cpu: 2, memory_gb: 4, disk_gb: 30, time_budget_min: 30 }, + } + } + + pub fn from_p95(cpu_p95: f64, memory_mb_p95: f64) -> Self { + let cpu = ((cpu_p95 * 1.2).ceil() as u32).max(2); + let memory_gb = (((memory_mb_p95 * 1.2) / 1024.0).ceil() as u32).max(4); + Self { cpu, memory_gb, disk_gb: 30, time_budget_min: 60 } + } +} + +fn parse_build_spec(context_path: &Path) -> Option { + let spec_path = context_path.join(".build-spec.ncl"); + if !spec_path.exists() { + return None; + } + // Extract JSON from nickel export — buildkit-launcher shells out to nickel + let output = std::process::Command::new("nickel") + .args(["export", "--format", "json"]) + .arg(&spec_path) + .output() + .ok()?; + if !output.status.success() { + return None; + } + serde_json::from_slice(&output.stdout).ok() +} + +pub fn resolve( + context_path: &Path, + p95_cpu: Option, + p95_mem_mb: Option, + language_hint: Option<&str>, +) -> Result { + let spec = parse_build_spec(context_path).unwrap_or_default(); + + // Tier 1: explicit declaration in .build-spec.ncl + if spec.cpu.is_some() || spec.memory_gb.is_some() { + let base = language_hint + .or(spec.language.as_deref()) + .map(RunnerSize::language_default) + .unwrap_or_else(|| RunnerSize::language_default("default")); + return Ok(RunnerSize { + cpu: spec.cpu.unwrap_or(base.cpu), + memory_gb: spec.memory_gb.unwrap_or(base.memory_gb), + disk_gb: spec.disk_gb.unwrap_or(base.disk_gb), + time_budget_min: spec.time_budget_min.unwrap_or(base.time_budget_min), + }); + } + + // Tier 2: P95 historical × 1.2 + if let (Some(cpu_p95), Some(mem_p95)) = (p95_cpu, p95_mem_mb) { + let mut size = RunnerSize::from_p95(cpu_p95, mem_p95); + if let Some(budget) = spec.time_budget_min { + size.time_budget_min = budget; + } + return Ok(size); + } + + // Tier 3: language defaults + let lang = language_hint + .or(spec.language.as_deref()) + .unwrap_or("default"); + Ok(RunnerSize::language_default(lang)) +} diff --git a/crates/contract-tests/Cargo.toml b/crates/contract-tests/Cargo.toml new file mode 100644 index 0000000..be4c35a --- /dev/null +++ b/crates/contract-tests/Cargo.toml @@ -0,0 +1,29 @@ +[package] +authors.workspace = true +edition.workspace = true +license.workspace = true +name = "contract-tests" +repository.workspace = true +version.workspace = true +description = "G3 — CLI↔HTTP↔MCP contract tests for the provisioning-core tool Registry" +publish = false + +[lib] +path = "src/lib.rs" + +[dependencies] +provisioning-core = { path = "../provisioning-core" } +provisioning-daemon = { path = "../provisioning-daemon" } +provisioning-mcp = { path = "../mcp-server" } + +tokio = { workspace = true, features = ["full"] } +axum = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +async-trait = { workspace = true } +reqwest = { workspace = true } +jsonschema = "0.28" + +[dev-dependencies] +rustls = { version = "0.23", features = ["aws_lc_rs"] } diff --git a/crates/contract-tests/src/lib.rs b/crates/contract-tests/src/lib.rs new file mode 100644 index 0000000..f5594bc --- /dev/null +++ b/crates/contract-tests/src/lib.rs @@ -0,0 +1,257 @@ +//! G3 — CLI↔HTTP↔MCP contract-test harness. +//! +//! Provides fixture tools, a three-tier dispatcher, and envelope-normalisation +//! helpers so the invariant "same tool + same params → same payload through all +//! three surfaces" can be asserted in an integration test without touching +//! filesystem, orchestrator, or provider CLIs. + +use async_trait::async_trait; +use provisioning_core::{ + Environment, Registry, ToolError, + protocol::ToolCategory, + tool::{Context, Tool}, +}; +use serde_json::{Value, json}; +use std::sync::Arc; + +// ── Fixture tools ───────────────────────────────────────────────────────────── + +/// Returns the input params verbatim — schema allows any object. +pub struct EchoTool; + +#[async_trait] +impl Tool for EchoTool { + fn name(&self) -> &'static str { "ct_echo" } + fn description(&self) -> &'static str { "Echo the input parameters verbatim" } + fn schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "message": { "type": "string" } + }, + "required": ["message"], + "additionalProperties": true + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, _ctx: &Context) -> Result { + let message = params.get("message").and_then(|v| v.as_str()).ok_or_else(|| { + ToolError::invalid_param("message", "required string field missing") + })?; + Ok(json!({ + "items": [{ "message": message }], + "total": 1 + })) + } +} + +/// Deterministic three-item listing — same output regardless of params. +pub struct ListingTool; + +#[async_trait] +impl Tool for ListingTool { + fn name(&self) -> &'static str { "ct_listing" } + fn description(&self) -> &'static str { "Return a canonical three-item listing" } + fn schema(&self) -> Value { + json!({ "type": "object", "properties": {}, "additionalProperties": false }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, _params: Value, _ctx: &Context) -> Result { + Ok(json!({ + "items": [ + { "id": "a", "value": 1 }, + { "id": "b", "value": 2 }, + { "id": "c", "value": 3 } + ], + "total": 3 + })) + } +} + +/// Always fails with `InvalidParam` — used to validate error-path contract. +pub struct FailingTool; + +#[async_trait] +impl Tool for FailingTool { + fn name(&self) -> &'static str { "ct_fail" } + fn description(&self) -> &'static str { "Always fails with InvalidParam" } + fn schema(&self) -> Value { + json!({ "type": "object", "properties": {}, "additionalProperties": true }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, _params: Value, _ctx: &Context) -> Result { + Err(ToolError::invalid_param("expected", "fixture always rejects")) + } +} + +/// Build a Registry populated with the three fixture tools. +pub fn make_fixture_registry() -> Registry { + let mut reg = Registry::new(); + reg.register(Arc::new(EchoTool)).expect("register echo"); + reg.register(Arc::new(ListingTool)).expect("register listing"); + reg.register(Arc::new(FailingTool)).expect("register fail"); + reg +} + +// ── Output-shape schema (the contract) ──────────────────────────────────────── + +/// JSON Schema that all `Read`-category list-style tools must satisfy. +/// Generated once at build time so each contract-test case can validate +/// without re-parsing. +pub fn listing_output_schema() -> Value { + json!({ + "type": "object", + "required": ["items", "total"], + "properties": { + "items": { "type": "array" }, + "total": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": true + }) +} + +// ── Three-tier dispatcher ───────────────────────────────────────────────────── + +pub struct TierOutcome { + /// Inner payload after envelope unwrap. `None` on error. + pub payload: Option, + /// Error code on failure, `None` on success. Each tier maps ToolError + /// to its own code namespace — the normaliser strips that difference. + pub error_code: Option, + /// Error message (trimmed of tier-specific prefixes). + pub error_message: Option, +} + +impl TierOutcome { + pub fn ok(payload: Value) -> Self { + Self { payload: Some(payload), error_code: None, error_message: None } + } + pub fn err(code: i32, message: impl Into) -> Self { + Self { payload: None, error_code: Some(code), error_message: Some(message.into()) } + } +} + +/// Tier A — direct Registry invocation. The reference tier: what the other +/// two surfaces must agree with after envelope unwrapping. +pub async fn tier_registry( + registry: &Registry, + env: Arc, + tool: &str, + params: Value, +) -> TierOutcome { + let ctx = Context::new(env); + match registry.invoke(tool, params, &ctx).await { + Ok(v) => TierOutcome::ok(v), + Err(e) => { + let code = match &e { + ToolError::NotFound(_) => -32001, + ToolError::InvalidParam { .. } => -32602, + ToolError::Unauthorized(_) => -32003, + _ => -32000, + }; + TierOutcome::err(code, e.to_string()) + } + } +} + +/// Tier B — HTTP daemon. The axum router is reused directly on a TcpListener +/// bound to 127.0.0.1:0 for deterministic port isolation. +pub async fn tier_http(base_url: &str, tool: &str, params: Value) -> TierOutcome { + let client = reqwest::Client::new(); + let url = format!("{}/api/v1/tools/{}", base_url, tool); + let body = json!({ "params": params }); + + let resp = match client.post(&url).json(&body).send().await { + Ok(r) => r, + Err(e) => return TierOutcome::err(-32000, format!("http transport: {e}")), + }; + + let status = resp.status(); + let body: Value = match resp.json().await { + Ok(v) => v, + Err(e) => return TierOutcome::err(-32000, format!("http body: {e}")), + }; + + if status.is_success() { + match body.get("result").cloned() { + Some(v) => TierOutcome::ok(v), + None => TierOutcome::err(-32000, "missing result field in http response"), + } + } else { + let code = body + .get("code") + .and_then(|v| v.as_i64()) + .map(|c| c as i32) + .unwrap_or(-32000); + let msg = body + .get("error") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + TierOutcome::err(code, msg) + } +} + +/// Tier C — MCP surface via McpServer::handle_request. +/// MCP wraps success payloads in `{content: [{type:"text", text: ""}]}`; +/// the unwrap parses the text field back into JSON. +pub async fn tier_mcp(server: &provisioning_mcp_server::registry_server::McpServer, tool: &str, params: Value) -> TierOutcome { + let rpc = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { "name": tool, "arguments": params } + }); + + let resp = server.handle_request(rpc).await; + + if let Some(err) = resp.get("error") { + let code = err.get("code").and_then(|v| v.as_i64()).map(|c| c as i32).unwrap_or(-32000); + let msg = err.get("message").and_then(|v| v.as_str()).unwrap_or("").to_string(); + return TierOutcome::err(code, msg); + } + + let content = match resp.get("result").and_then(|r| r.get("content")) { + Some(c) => c, + None => return TierOutcome::err(-32000, "mcp response missing result.content"), + }; + + let text = content + .get(0) + .and_then(|c| c.get("text")) + .and_then(|t| t.as_str()); + let text = match text { + Some(t) => t, + None => return TierOutcome::err(-32000, "mcp content[0].text missing"), + }; + + match serde_json::from_str::(text) { + Ok(v) => TierOutcome::ok(v), + Err(e) => TierOutcome::err(-32000, format!("mcp text parse: {e}")), + } +} + +// ── Payload normalisation ───────────────────────────────────────────────────── + +/// Strip fields that are allowed to diverge between tiers: trace ids, timestamps, +/// uuids. The contract is on semantic payload, not byte equality. +pub fn normalise(v: &Value) -> Value { + match v { + Value::Object(map) => { + let mut out = serde_json::Map::new(); + for (k, val) in map { + if matches!(k.as_str(), "trace_id" | "span_id" | "timestamp" | "generated_at" | "request_id") + { + continue; + } + out.insert(k.clone(), normalise(val)); + } + Value::Object(out) + } + Value::Array(arr) => Value::Array(arr.iter().map(normalise).collect()), + _ => v.clone(), + } +} diff --git a/crates/contract-tests/tests/g3_contract.rs b/crates/contract-tests/tests/g3_contract.rs new file mode 100644 index 0000000..7243f64 --- /dev/null +++ b/crates/contract-tests/tests/g3_contract.rs @@ -0,0 +1,194 @@ +//! G3 — CLI↔HTTP↔MCP contract test. +//! +//! For each fixture tool, invoke via: +//! Tier A — Registry directly (reference) +//! Tier B — HTTP daemon (axum::serve on 127.0.0.1:0) +//! Tier C — MCP server (handle_request in-process) +//! +//! Assert: +//! 1. All tiers that succeed produce equal normalised payloads +//! 2. The payload validates against the listing output schema +//! 3. All tiers that fail produce the same error code +//! +//! The normaliser strips volatile fields (trace_id, timestamp, …) so the +//! contract is on semantic equivalence, not byte-for-byte equality. + +use contract_tests::{ + listing_output_schema, make_fixture_registry, normalise, + tier_http, tier_mcp, tier_registry, +}; +use jsonschema::Validator; +use provisioning_mcp_server::registry_server::McpServer; +use provisioning_core::Environment; +use provisioning_daemon::{AppState, http::router}; +use serde_json::{Value, json}; +use std::sync::{Arc, OnceLock}; + +// ── Test harness ────────────────────────────────────────────────────────────── + +static CRYPTO: OnceLock<()> = OnceLock::new(); + +fn init_crypto() { + CRYPTO.get_or_init(|| { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + }); +} + +struct Harness { + env: Arc, + registry: Arc, + mcp: McpServer, + http_base: String, + _daemon_task: tokio::task::JoinHandle<()>, +} + +async fn boot() -> Harness { + init_crypto(); + + // Shared fixture registry for all tiers. + let registry = Arc::new(make_fixture_registry()); + let env = Arc::new(Environment::default()); + + // Tier B — bind daemon on ephemeral port. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("local_addr"); + let http_base = format!("http://{addr}"); + + // AppState owns its own Arc; we build it from a fresh fixture + // registry so the daemon and the reference tier see identical tool sets. + let daemon_registry = make_fixture_registry(); + let app = AppState::new(daemon_registry, Environment::default()); + let app_router = router(app); + let task = tokio::spawn(async move { + axum::serve(listener, app_router).await.expect("daemon serve"); + }); + + // Wait for the daemon to accept connections (fast — local TCP). + for _ in 0..40 { + if reqwest::get(format!("{}/health", http_base)).await.is_ok() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + + // Tier C — in-process MCP server with its own fixture registry. + let mcp = McpServer::new(make_fixture_registry(), Environment::default(), "g3-test", "0"); + + Harness { env, registry, mcp, http_base, _daemon_task: task } +} + +fn validate_listing(v: &Value) { + let schema = listing_output_schema(); + let validator = Validator::new(&schema).expect("compile schema"); + validator + .validate(v) + .unwrap_or_else(|errors| panic!("listing schema validation failed: {errors}")); +} + +// ── Success contract ────────────────────────────────────────────────────────── + +#[tokio::test] +async fn listing_tool_agrees_across_three_tiers() { + let h = boot().await; + let params = json!({}); + + let a = tier_registry(&h.registry, h.env.clone(), "ct_listing", params.clone()).await; + let b = tier_http(&h.http_base, "ct_listing", params.clone()).await; + let c = tier_mcp(&h.mcp, "ct_listing", params.clone()).await; + + let a_payload = a.payload.expect("tier A registry must succeed"); + let b_payload = b.payload.expect("tier B http must succeed"); + let c_payload = c.payload.expect("tier C mcp must succeed"); + + validate_listing(&a_payload); + validate_listing(&b_payload); + validate_listing(&c_payload); + + assert_eq!(normalise(&a_payload), normalise(&b_payload), "A↔B diverge"); + assert_eq!(normalise(&a_payload), normalise(&c_payload), "A↔C diverge"); + assert_eq!(normalise(&b_payload), normalise(&c_payload), "B↔C diverge"); +} + +#[tokio::test] +async fn echo_tool_agrees_across_three_tiers() { + let h = boot().await; + let params = json!({ "message": "contract" }); + + let a = tier_registry(&h.registry, h.env.clone(), "ct_echo", params.clone()).await; + let b = tier_http(&h.http_base, "ct_echo", params.clone()).await; + let c = tier_mcp(&h.mcp, "ct_echo", params.clone()).await; + + let a_payload = a.payload.expect("A must succeed"); + let b_payload = b.payload.expect("B must succeed"); + let c_payload = c.payload.expect("C must succeed"); + + validate_listing(&a_payload); + validate_listing(&b_payload); + validate_listing(&c_payload); + + assert_eq!(normalise(&a_payload), normalise(&b_payload)); + assert_eq!(normalise(&a_payload), normalise(&c_payload)); + + // Exact content check — echo should round-trip the message + assert_eq!(a_payload["items"][0]["message"], "contract"); + assert_eq!(b_payload["items"][0]["message"], "contract"); + assert_eq!(c_payload["items"][0]["message"], "contract"); +} + +// ── Failure contract ────────────────────────────────────────────────────────── + +#[tokio::test] +async fn invalid_param_error_code_agrees_across_tiers() { + let h = boot().await; + let params = json!({}); // echo requires "message" → InvalidParam + + let a = tier_registry(&h.registry, h.env.clone(), "ct_echo", params.clone()).await; + let b = tier_http(&h.http_base, "ct_echo", params.clone()).await; + let c = tier_mcp(&h.mcp, "ct_echo", params.clone()).await; + + // All three must classify as InvalidParam → -32602 + assert_eq!(a.error_code, Some(-32602), "A: {:?}", a.error_message); + assert_eq!(b.error_code, Some(-32602), "B: {:?}", b.error_message); + assert_eq!(c.error_code, Some(-32602), "C: {:?}", c.error_message); +} + +#[tokio::test] +async fn failing_tool_error_code_agrees_across_tiers() { + let h = boot().await; + let params = json!({}); + + let a = tier_registry(&h.registry, h.env.clone(), "ct_fail", params.clone()).await; + let b = tier_http(&h.http_base, "ct_fail", params.clone()).await; + let c = tier_mcp(&h.mcp, "ct_fail", params.clone()).await; + + assert_eq!(a.error_code, Some(-32602)); + assert_eq!(b.error_code, Some(-32602)); + assert_eq!(c.error_code, Some(-32602)); +} + +// ── tools/list contract ─────────────────────────────────────────────────────── + +#[tokio::test] +async fn tools_list_count_agrees_across_surfaces() { + let h = boot().await; + + // Registry baseline + let reg_count = h.registry.len(); + + // HTTP + let http_body: Value = reqwest::get(format!("{}/api/v1/tools", h.http_base)) + .await + .expect("http list") + .json() + .await + .expect("parse"); + let http_count = http_body["tools"].as_array().expect("tools array").len(); + + // MCP + let rpc = json!({"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}); + let mcp_resp = h.mcp.handle_request(rpc).await; + let mcp_count = mcp_resp["result"]["tools"].as_array().expect("mcp tools").len(); + + assert_eq!(reg_count, http_count, "HTTP count diverges from registry"); + assert_eq!(reg_count, mcp_count, "MCP count diverges from registry"); +} diff --git a/crates/control-center-ui/src/api/orchestrator_client.rs b/crates/control-center-ui/src/api/orchestrator_client.rs index a15741a..4c0d827 100644 --- a/crates/control-center-ui/src/api/orchestrator_client.rs +++ b/crates/control-center-ui/src/api/orchestrator_client.rs @@ -224,6 +224,19 @@ impl OrchestratorClient { } } + /// Submit a unified component lifecycle operation. + pub async fn deploy_component(&self, operation: &str, workflow: &ComponentWorkflow) -> Result { + let path = format!("/api/v1/workflows/component/{operation}"); + let request = self.build_request("POST", &path)?; + let response = self.execute_json_request::(request, workflow).await?; + + if response.success { + Ok(response.data.unwrap_or_default()) + } else { + Err(OrchestratorError::Api(response.error.unwrap_or_default())) + } + } + /// Create cluster workflow pub async fn create_cluster_workflow(&self, workflow: &ClusterWorkflow) -> Result { let request = self.build_request("POST", "/workflows/cluster/create")?; diff --git a/crates/control-center-ui/src/api/orchestrator_types.rs b/crates/control-center-ui/src/api/orchestrator_types.rs index cfe4fa2..199ea32 100644 --- a/crates/control-center-ui/src/api/orchestrator_types.rs +++ b/crates/control-center-ui/src/api/orchestrator_types.rs @@ -84,6 +84,30 @@ pub struct ClusterWorkflow { pub wait: bool, } +/// Unified component lifecycle workflow request. +/// Submitted to `/api/v1/workflows/component/{op}`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComponentWorkflow { + pub workspace: String, + pub infra: String, + pub component: String, + pub server: String, + #[serde(default)] + pub namespace: Option, + #[serde(default = "default_ssh_user")] + pub ssh_user: String, + #[serde(default)] + pub ssh_key_path: Option, + pub settings: String, + #[serde(default)] + pub check_mode: bool, + pub provisioning: String, +} + +fn default_ssh_user() -> String { + "root".to_string() +} + /// Batch operation request #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BatchOperationRequest { diff --git a/crates/control-center/Cargo.toml b/crates/control-center/Cargo.toml index 33c4815..9944d06 100644 --- a/crates/control-center/Cargo.toml +++ b/crates/control-center/Cargo.toml @@ -18,6 +18,11 @@ tokio = { workspace = true } # Web server and API axum = { workspace = true } hyper = { workspace = true } + +# Ontoref API catalog +ontoref-ontology = { workspace = true } +ontoref-derive = { workspace = true } +inventory = { workspace = true } tower = { workspace = true } tower-http = { workspace = true } @@ -40,7 +45,7 @@ clap = { workspace = true } config = { workspace = true } # Centralized observability (logging, metrics, health, tracing) -observability = { workspace = true, features = ["logging", "metrics-prometheus", "health"] } +platform-observability = { workspace = true, features = ["logging", "metrics-prometheus", "health"] } # Error handling anyhow = { workspace = true } @@ -58,7 +63,7 @@ validator = { workspace = true } reqwest = { workspace = true } # HTTP service clients (machines, init, AI) - enables remote service calls -service-clients = { workspace = true } +platform-clients = { workspace = true } # Platform configuration management platform-config = { workspace = true } diff --git a/crates/control-center/src/api_catalog.rs b/crates/control-center/src/api_catalog.rs new file mode 100644 index 0000000..659f809 --- /dev/null +++ b/crates/control-center/src/api_catalog.rs @@ -0,0 +1,12 @@ +use std::sync::Arc; + +use axum::{extract::State, response::IntoResponse, Json}; +use control_center::AppState; +use ontoref_ontology::api::ApiRouteEntry; +use serde_json::json; + +pub async fn api_catalog(State(_state): State>) -> impl IntoResponse { + let mut routes: Vec<&'static ApiRouteEntry> = inventory::iter::().collect(); + routes.sort_by(|a, b| a.path.cmp(b.path).then(a.method.cmp(b.method))); + Json(json!({ "service": "control-center", "routes": routes })) +} diff --git a/crates/control-center/src/error.rs.old b/crates/control-center/src/error.rs.old deleted file mode 100644 index 2292881..0000000 --- a/crates/control-center/src/error.rs.old +++ /dev/null @@ -1,391 +0,0 @@ -use axum::{ - http::StatusCode, - response::{IntoResponse, Response}, - Json, -}; -use thiserror::Error; -use tracing::error; - -/// Result type alias for the control center -pub type Result = std::result::Result; - -/// Main error type for the control center service -#[derive(Error, Debug)] -pub enum ControlCenterError { - /// Database-related errors - #[error("Database error: {0}")] - Database(String), - - /// Authentication errors - #[error("Authentication error: {0}")] - Authentication(String), - - /// Authorization errors - #[error("Authorization error: {0}")] - Authorization(String), - - /// JWT token errors - #[error("Token error: {0}")] - Token(String), - - /// Validation errors - #[error("Validation error: {0}")] - Validation(String), - - /// User not found - #[error("User not found: {0}")] - UserNotFound(String), - - /// Role not found - #[error("Role not found: {0}")] - RoleNotFound(String), - - /// Permission not found - #[error("Permission not found: {0}")] - PermissionNotFound(String), - - /// Session not found - #[error("Session not found: {0}")] - SessionNotFound(String), - - /// Session expired - #[error("Session expired: {0}")] - SessionExpired(String), - - /// Resource not found - #[error("Not found: {0}")] - NotFound(String), - - /// Unauthorized access - #[error("Unauthorized: {0}")] - Unauthorized(String), - - /// Forbidden access - #[error("Forbidden: {0}")] - Forbidden(String), - - /// Conflict (e.g., duplicate resources) - #[error("Conflict: {0}")] - Conflict(String), - - /// Bad request - #[error("Bad request: {0}")] - BadRequest(String), - - /// Internal server error - #[error("Internal server error: {0}")] - Internal(String), - - /// Configuration errors - #[error("Configuration error: {0}")] - Configuration(String), - - /// External service errors - #[error("External service error: {0}")] - ExternalService(String), - - /// Rate limit exceeded - #[error("Rate limit exceeded: {0}")] - RateLimit(String), - - /// WebSocket errors - #[error("WebSocket error: {0}")] - WebSocket(String), - - /// Serialization errors - #[error("Serialization error: {0}")] - Serialization(String), - - /// Policy evaluation error - #[error("Policy evaluation error: {0}")] - PolicyEvaluation(String), - - /// Policy parsing error - #[error("Policy parsing error: {0}")] - PolicyParsing(String), - - /// Cedar policy error - #[error("Cedar policy error: {0}")] - Cedar(String), - - /// Compliance check failed - #[error("Compliance check failed: {0}")] - Compliance(String), - - /// Anomaly detection error - #[error("Anomaly detection error: {0}")] - Anomaly(String), - - /// Analysis failed error - #[error("Analysis failed: {0}")] - AnalysisFailed(String), - - /// Generic anyhow errors - #[error(transparent)] - Other(#[from] anyhow::Error), -} - -impl ControlCenterError { - /// Get the HTTP status code for this error - pub fn status_code(&self) -> StatusCode { - match self { - Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR, - Self::Authentication(_) => StatusCode::UNAUTHORIZED, - Self::Authorization(_) => StatusCode::FORBIDDEN, - Self::Token(_) => StatusCode::UNAUTHORIZED, - Self::Validation(_) => StatusCode::BAD_REQUEST, - Self::UserNotFound(_) => StatusCode::NOT_FOUND, - Self::RoleNotFound(_) => StatusCode::NOT_FOUND, - Self::PermissionNotFound(_) => StatusCode::NOT_FOUND, - Self::SessionNotFound(_) => StatusCode::NOT_FOUND, - Self::SessionExpired(_) => StatusCode::UNAUTHORIZED, - Self::NotFound(_) => StatusCode::NOT_FOUND, - Self::Unauthorized(_) => StatusCode::UNAUTHORIZED, - Self::Forbidden(_) => StatusCode::FORBIDDEN, - Self::Conflict(_) => StatusCode::CONFLICT, - Self::BadRequest(_) => StatusCode::BAD_REQUEST, - Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, - Self::Configuration(_) => StatusCode::INTERNAL_SERVER_ERROR, - Self::ExternalService(_) => StatusCode::BAD_GATEWAY, - Self::RateLimit(_) => StatusCode::TOO_MANY_REQUESTS, - Self::WebSocket(_) => StatusCode::BAD_REQUEST, - Self::Serialization(_) => StatusCode::INTERNAL_SERVER_ERROR, - Self::PolicyEvaluation(_) => StatusCode::INTERNAL_SERVER_ERROR, - Self::PolicyParsing(_) => StatusCode::BAD_REQUEST, - Self::Cedar(_) => StatusCode::INTERNAL_SERVER_ERROR, - Self::Compliance(_) => StatusCode::FORBIDDEN, - Self::Anomaly(_) => StatusCode::INTERNAL_SERVER_ERROR, - Self::AnalysisFailed(_) => StatusCode::BAD_REQUEST, - Self::Other(_) => StatusCode::INTERNAL_SERVER_ERROR, - } - } - - /// Get the error code string for API responses - pub fn error_code(&self) -> &'static str { - match self { - Self::Database(_) => "DATABASE_ERROR", - Self::Authentication(_) => "AUTHENTICATION_ERROR", - Self::Authorization(_) => "AUTHORIZATION_ERROR", - Self::Token(_) => "TOKEN_ERROR", - Self::Validation(_) => "VALIDATION_ERROR", - Self::UserNotFound(_) => "USER_NOT_FOUND", - Self::RoleNotFound(_) => "ROLE_NOT_FOUND", - Self::PermissionNotFound(_) => "PERMISSION_NOT_FOUND", - Self::SessionNotFound(_) => "SESSION_NOT_FOUND", - Self::SessionExpired(_) => "SESSION_EXPIRED", - Self::NotFound(_) => "NOT_FOUND", - Self::Unauthorized(_) => "UNAUTHORIZED", - Self::Forbidden(_) => "FORBIDDEN", - Self::Conflict(_) => "CONFLICT", - Self::BadRequest(_) => "BAD_REQUEST", - Self::Internal(_) => "INTERNAL_ERROR", - Self::Configuration(_) => "CONFIGURATION_ERROR", - Self::ExternalService(_) => "EXTERNAL_SERVICE_ERROR", - Self::RateLimit(_) => "RATE_LIMIT_EXCEEDED", - Self::WebSocket(_) => "WEBSOCKET_ERROR", - Self::Serialization(_) => "SERIALIZATION_ERROR", - Self::PolicyEvaluation(_) => "POLICY_EVALUATION_ERROR", - Self::PolicyParsing(_) => "POLICY_PARSING_ERROR", - Self::Cedar(_) => "CEDAR_ERROR", - Self::Compliance(_) => "COMPLIANCE_ERROR", - Self::Anomaly(_) => "ANOMALY_ERROR", - Self::AnalysisFailed(_) => "ANALYSIS_FAILED", - Self::Other(_) => "INTERNAL_ERROR", - } - } - - /// Check if this error should be logged at ERROR level - pub fn should_log_error(&self) -> bool { - match self { - Self::Database(_) => true, - Self::Internal(_) => true, - Self::Configuration(_) => true, - Self::Serialization(_) => true, - Self::PolicyEvaluation(_) => true, - Self::Cedar(_) => true, - Self::Anomaly(_) => true, - Self::AnalysisFailed(_) => true, - Self::Other(_) => true, - _ => false, // Client errors are logged at WARN level - } - } -} - -/// Error response structure for API -#[derive(serde::Serialize)] -pub struct ErrorResponse { - pub error: String, - pub error_code: String, - pub message: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub details: Option, - pub timestamp: chrono::DateTime, -} - -impl ErrorResponse { - pub fn new(error: &ControlCenterError) -> Self { - Self { - error: error.error_code().to_string(), - error_code: error.error_code().to_string(), - message: error.to_string(), - details: None, - timestamp: chrono::Utc::now(), - } - } - - pub fn with_details(mut self, details: serde_json::Value) -> Self { - self.details = Some(details); - self - } -} - -impl IntoResponse for ControlCenterError { - fn into_response(self) -> Response { - let status_code = self.status_code(); - - // Log the error appropriately - if self.should_log_error() { - error!("Error occurred: {} (status: {})", self, status_code); - } else { - tracing::warn!("Client error: {} (status: {})", self, status_code); - } - - let error_response = ErrorResponse::new(&self); - - (status_code, Json(error_response)).into_response() - } -} - -// Implement conversions for common error types - -impl From for ControlCenterError { - fn from(error: surrealdb::Error) -> Self { - Self::Database(error.to_string()) - } -} - -impl From for ControlCenterError { - fn from(error: jsonwebtoken::errors::Error) -> Self { - Self::Token(error.to_string()) - } -} - -impl From for ControlCenterError { - fn from(errors: validator::ValidationErrors) -> Self { - let details = errors - .field_errors() - .iter() - .map(|(field, errors)| { - let messages: Vec = errors - .iter() - .filter_map(|e| e.message.as_ref().map(|m| m.to_string())) - .collect(); - (field.to_string(), messages) - }) - .collect::>(); - - Self::Validation(format!("Validation failed: {:?}", details)) - } -} - -impl From for ControlCenterError { - fn from(error: serde_json::Error) -> Self { - Self::Serialization(error.to_string()) - } -} - -impl From for ControlCenterError { - fn from(error: std::io::Error) -> Self { - Self::Internal(format!("IO error: {}", error)) - } -} - -impl From for ControlCenterError { - fn from(error: toml::de::Error) -> Self { - Self::Configuration(format!("TOML parsing error: {}", error)) - } -} - -impl From for ControlCenterError { - fn from(error: argon2::Error) -> Self { - Self::Authentication(format!("Password hashing error: {}", error)) - } -} - -impl From for ControlCenterError { - fn from(error: reqwest::Error) -> Self { - Self::ExternalService(error.to_string()) - } -} - -impl From for ControlCenterError { - fn from(err: cedar_policy::ParseError) -> Self { - ControlCenterError::Cedar(err.to_string()) - } -} - -impl From for ControlCenterError { - fn from(err: cedar_policy::PolicySetError) -> Self { - ControlCenterError::Cedar(err.to_string()) - } -} - -impl From for ControlCenterError { - fn from(error: crate::kms::kms_service_client::KmsClientError) -> Self { - Self::ExternalService(format!("KMS error: {}", error)) - } -} - -impl From for ControlCenterError { - fn from(error: crate::kms::error::KmsError) -> Self { - Self::Internal(format!("KMS error: {}", error)) - } -} - -/// Helper macro for creating specific error types -#[macro_export] -macro_rules! control_center_error { - ($variant:ident, $msg:expr) => { - $crate::error::ControlCenterError::$variant($msg.to_string()) - }; - ($variant:ident, $fmt:expr, $($arg:tt)*) => { - $crate::error::ControlCenterError::$variant(format!($fmt, $($arg)*)) - }; -} - -/// Helper functions for creating common errors -pub fn user_not_found(identifier: &str) -> ControlCenterError { - ControlCenterError::UserNotFound(identifier.to_string()) -} - -pub fn session_not_found(session_id: &str) -> ControlCenterError { - ControlCenterError::SessionNotFound(session_id.to_string()) -} - -pub fn session_expired(session_id: &str) -> ControlCenterError { - ControlCenterError::SessionExpired(session_id.to_string()) -} - -pub fn unauthorized(message: &str) -> ControlCenterError { - ControlCenterError::Unauthorized(message.to_string()) -} - -pub fn forbidden(message: &str) -> ControlCenterError { - ControlCenterError::Forbidden(message.to_string()) -} - -pub fn bad_request(message: &str) -> ControlCenterError { - ControlCenterError::BadRequest(message.to_string()) -} - -pub fn conflict(message: &str) -> ControlCenterError { - ControlCenterError::Conflict(message.to_string()) -} - -pub fn internal_error(message: &str) -> ControlCenterError { - ControlCenterError::Internal(message.to_string()) -} - -pub fn not_found(message: &str) -> ControlCenterError { - ControlCenterError::NotFound(message.to_string()) -} diff --git a/crates/control-center/src/handlers/auth.rs b/crates/control-center/src/handlers/auth.rs index 057c25f..e00ae6b 100644 --- a/crates/control-center/src/handlers/auth.rs +++ b/crates/control-center/src/handlers/auth.rs @@ -4,6 +4,7 @@ use axum::{ extract::{Request, State}, response::Json, }; +use ontoref_derive::onto_api; use serde::{Deserialize, Serialize}; use tracing::info; @@ -14,6 +15,15 @@ use crate::services::AuthService; use crate::AppState; /// Login endpoint +#[onto_api( + method = "POST", + path = "/auth/login", + description = "Authenticate and obtain JWT tokens", + auth = "none", + actors = "developer, agent", + tags = "auth", + feature = "" +)] pub async fn login( State(app_state): State>, Json(request): Json, @@ -32,6 +42,15 @@ pub async fn login( } /// Refresh token endpoint +#[onto_api( + method = "POST", + path = "/auth/refresh", + description = "Refresh JWT access token", + auth = "none", + actors = "developer, agent", + tags = "auth", + feature = "" +)] pub async fn refresh_token( State(app_state): State>, Json(request): Json, @@ -42,6 +61,15 @@ pub async fn refresh_token( } /// Logout endpoint +#[onto_api( + method = "POST", + path = "/auth/logout", + description = "Invalidate current session and logout", + auth = "bearer", + actors = "developer, agent", + tags = "auth", + feature = "" +)] pub async fn logout( State(app_state): State>, Json(logout_request): Json, @@ -126,6 +154,15 @@ pub async fn invalidate_all_sessions( } /// Health check endpoint (no auth required) +#[onto_api( + method = "GET", + path = "/health", + description = "Service health check", + auth = "none", + actors = "developer, agent, ci", + tags = "health", + feature = "" +)] pub async fn health_check() -> Json> { Json(ApiResponse::success(HealthCheckResponse { status: "healthy".to_string(), diff --git a/crates/control-center/src/handlers/iac_deployment.rs b/crates/control-center/src/handlers/iac_deployment.rs index b70c44a..4d48641 100644 --- a/crates/control-center/src/handlers/iac_deployment.rs +++ b/crates/control-center/src/handlers/iac_deployment.rs @@ -7,6 +7,7 @@ use axum::{ http::StatusCode, Json, }; +use ontoref_derive::onto_api; use serde::{Deserialize, Serialize}; // Re-export service types to avoid duplication @@ -32,6 +33,15 @@ pub struct DeploymentQuery { } /// List deployment plans +#[onto_api( + method = "GET", + path = "/deployments", + description = "List all deployment plans", + auth = "bearer", + actors = "developer, agent", + tags = "deployments", + feature = "" +)] pub async fn list_deployments( State(state): State>, Query(params): Query, @@ -53,6 +63,15 @@ pub async fn list_deployments( } /// Get deployment plan +#[onto_api( + method = "GET", + path = "/deployments/{id}", + description = "Get a deployment plan by ID", + auth = "bearer", + actors = "developer, agent", + tags = "deployments", + feature = "" +)] pub async fn get_deployment( State(state): State>, Path(id): Path, @@ -65,6 +84,15 @@ pub async fn get_deployment( } /// Create deployment plan +#[onto_api( + method = "POST", + path = "/deployments", + description = "Create a new deployment plan", + auth = "bearer", + actors = "developer, agent", + tags = "deployments", + feature = "" +)] pub async fn create_deployment( State(state): State>, Json(plan): Json, @@ -74,6 +102,15 @@ pub async fn create_deployment( } /// Update deployment plan +#[onto_api( + method = "PUT", + path = "/deployments/{id}", + description = "Update a deployment plan", + auth = "bearer", + actors = "developer, agent", + tags = "deployments", + feature = "" +)] pub async fn update_deployment( State(state): State>, Path(id): Path, @@ -95,6 +132,15 @@ pub struct SubmissionResult { } /// Submit deployment plan to orchestrator +#[onto_api( + method = "POST", + path = "/deployments/{id}/submit", + description = "Submit a deployment plan to the orchestrator", + auth = "bearer", + actors = "developer, agent", + tags = "deployments", + feature = "" +)] pub async fn submit_deployment( State(state): State>, Path(id): Path, @@ -171,6 +217,15 @@ pub struct DeploymentStatus { } /// Get deployment execution status +#[onto_api( + method = "GET", + path = "/deployments/{id}/status", + description = "Get deployment execution status", + auth = "bearer", + actors = "developer, agent", + tags = "deployments", + feature = "" +)] pub async fn get_deployment_status( State(state): State>, Path(id): Path, diff --git a/crates/control-center/src/handlers/iac_detection.rs b/crates/control-center/src/handlers/iac_detection.rs index 6b3fc3a..ca50a1d 100644 --- a/crates/control-center/src/handlers/iac_detection.rs +++ b/crates/control-center/src/handlers/iac_detection.rs @@ -10,6 +10,7 @@ use axum::{ http::StatusCode, Json, }; +use ontoref_derive::onto_api; use serde::{Deserialize, Serialize}; // Re-export service types @@ -45,6 +46,15 @@ pub struct PagedResponse { } /// List all detection results +#[onto_api( + method = "GET", + path = "/detections", + description = "List all infrastructure detections", + auth = "bearer", + actors = "developer, agent", + tags = "detections", + feature = "" +)] pub async fn list_detections( State(state): State>, Query(params): Query, @@ -79,6 +89,15 @@ pub async fn list_detections( } /// Get single detection by ID +#[onto_api( + method = "GET", + path = "/detections/{id}", + description = "Get infrastructure detection by ID", + auth = "bearer", + actors = "developer, agent", + tags = "detections", + feature = "" +)] pub async fn get_detection( State(state): State>, Path(id): Path, @@ -104,6 +123,15 @@ pub struct AnalyzeProjectRequest { pub organization: Option, } +#[onto_api( + method = "POST", + path = "/detections/analyze", + description = "Analyze a project for infrastructure technologies", + auth = "bearer", + actors = "developer, agent", + tags = "detections", + feature = "" +)] pub async fn analyze_project( State(state): State>, Json(req): Json, diff --git a/crates/control-center/src/handlers/iac_rules.rs b/crates/control-center/src/handlers/iac_rules.rs index a519fc5..d07cb9f 100644 --- a/crates/control-center/src/handlers/iac_rules.rs +++ b/crates/control-center/src/handlers/iac_rules.rs @@ -7,6 +7,7 @@ use axum::{ http::StatusCode, Json, }; +use ontoref_derive::onto_api; use serde::{Deserialize, Serialize}; // Re-export service types to avoid duplication @@ -32,6 +33,15 @@ pub struct RulesQuery { } /// List all inference rules +#[onto_api( + method = "GET", + path = "/rules", + description = "List all inference rules", + auth = "bearer", + actors = "developer", + tags = "rules", + feature = "" +)] pub async fn list_rules( State(state): State>, Query(params): Query, @@ -53,6 +63,15 @@ pub async fn list_rules( } /// List rules for specific organization +#[onto_api( + method = "GET", + path = "/rules/org/{org}", + description = "List inference rules for a specific organization", + auth = "bearer", + actors = "developer", + tags = "rules", + feature = "" +)] pub async fn list_org_rules( State(state): State>, Path(org): Path, @@ -75,6 +94,15 @@ pub async fn list_org_rules( } /// Get single rule +#[onto_api( + method = "GET", + path = "/rules/{id}", + description = "Get an inference rule by ID", + auth = "bearer", + actors = "developer", + tags = "rules", + feature = "" +)] pub async fn get_rule( State(state): State>, Path(id): Path, @@ -83,6 +111,15 @@ pub async fn get_rule( } /// Create new rule +#[onto_api( + method = "POST", + path = "/rules", + description = "Create a new inference rule", + auth = "bearer", + actors = "developer", + tags = "rules", + feature = "" +)] pub async fn create_rule( State(state): State>, Json(rule): Json, @@ -92,6 +129,15 @@ pub async fn create_rule( } /// Update existing rule +#[onto_api( + method = "PUT", + path = "/rules/{id}", + description = "Update an inference rule", + auth = "bearer", + actors = "developer", + tags = "rules", + feature = "" +)] pub async fn update_rule( State(state): State>, Path(id): Path, @@ -102,6 +148,15 @@ pub async fn update_rule( } /// Delete rule +#[onto_api( + method = "DELETE", + path = "/rules/{id}", + description = "Delete an inference rule", + auth = "bearer", + actors = "developer", + tags = "rules", + feature = "" +)] pub async fn delete_rule( State(state): State>, Path(id): Path, @@ -124,6 +179,15 @@ pub struct TestRuleResult { pub confidence_score: f32, } +#[onto_api( + method = "POST", + path = "/rules/{id}/test", + description = "Test an inference rule against a project", + auth = "bearer", + actors = "developer", + tags = "rules", + feature = "" +)] pub async fn test_rule( State(state): State>, Path(id): Path, diff --git a/crates/control-center/src/handlers/permission.rs b/crates/control-center/src/handlers/permission.rs index 9394172..2ff2539 100644 --- a/crates/control-center/src/handlers/permission.rs +++ b/crates/control-center/src/handlers/permission.rs @@ -4,6 +4,7 @@ use axum::{ extract::{Query, Request, State}, response::Json, }; +use ontoref_derive::onto_api; use serde::Deserialize; use crate::error::{auth, http, ControlCenterError, Result}; @@ -13,6 +14,15 @@ use crate::models::PermissionResponse; use crate::AppState; /// List permissions +#[onto_api( + method = "GET", + path = "/permissions", + description = "List all permissions", + auth = "bearer", + actors = "developer", + tags = "permissions", + feature = "" +)] pub async fn list_permissions( State(app_state): State>, Query(params): Query, diff --git a/crates/control-center/src/handlers/secrets.rs b/crates/control-center/src/handlers/secrets.rs index 1de3ef6..2e546ff 100644 --- a/crates/control-center/src/handlers/secrets.rs +++ b/crates/control-center/src/handlers/secrets.rs @@ -6,6 +6,7 @@ use axum::{ response::IntoResponse, Json, }; +use ontoref_derive::onto_api; use serde::{Deserialize, Serialize}; use tracing::{error, info}; @@ -181,6 +182,15 @@ fn default_limit() -> usize { } /// Handler: Create a new secret +#[onto_api( + method = "POST", + path = "/secrets", + description = "Create a new secret", + auth = "admin", + actors = "developer", + tags = "secrets", + feature = "" +)] pub async fn create_secret( State(app_state): State>, Extension(security_ctx): Extension, @@ -269,6 +279,15 @@ pub async fn create_secret( } /// Handler: Get a secret value (decrypted) +#[onto_api( + method = "GET", + path = "/secrets/{path}", + description = "Get a secret value by path", + auth = "admin", + actors = "developer", + tags = "secrets", + feature = "" +)] pub async fn get_secret( State(app_state): State>, Extension(security_ctx): Extension, @@ -356,6 +375,15 @@ pub struct GetSecretQuery { } /// Handler: List secrets +#[onto_api( + method = "GET", + path = "/secrets", + description = "List all secrets", + auth = "admin", + actors = "developer", + tags = "secrets", + feature = "" +)] pub async fn list_secrets( State(app_state): State>, Extension(security_ctx): Extension, @@ -392,6 +420,15 @@ pub async fn list_secrets( } /// Handler: Update a secret (creates new version) +#[onto_api( + method = "PUT", + path = "/secrets/{path}", + description = "Update a secret, creating a new version", + auth = "admin", + actors = "developer", + tags = "secrets", + feature = "" +)] pub async fn update_secret( State(app_state): State>, Extension(security_ctx): Extension, @@ -497,6 +534,15 @@ pub async fn update_secret( } /// Handler: Delete a secret +#[onto_api( + method = "DELETE", + path = "/secrets/{path}", + description = "Delete a secret", + auth = "admin", + actors = "developer", + tags = "secrets", + feature = "" +)] pub async fn delete_secret( State(app_state): State>, Extension(security_ctx): Extension, @@ -579,6 +625,15 @@ pub async fn delete_secret( } /// Handler: Get secret history/versions +#[onto_api( + method = "GET", + path = "/secrets/{path}/history", + description = "Get version history for a secret", + auth = "admin", + actors = "developer", + tags = "secrets", + feature = "" +)] pub async fn get_secret_history( State(app_state): State>, Extension(security_ctx): Extension, @@ -606,6 +661,15 @@ pub async fn get_secret_history( } /// Handler: Restore a specific version of a secret +#[onto_api( + method = "POST", + path = "/secrets/{path}/restore/{version}", + description = "Restore a secret to a specific version", + auth = "admin", + actors = "developer", + tags = "secrets", + feature = "" +)] pub async fn restore_secret_version( State(app_state): State>, Extension(security_ctx): Extension, @@ -741,6 +805,15 @@ pub struct RotationStatusResponse { } /// Handler: Force rotate a secret +#[onto_api( + method = "POST", + path = "/secrets/{path}/rotate", + description = "Force rotate a secret", + auth = "admin", + actors = "developer", + tags = "secrets", + feature = "" +)] pub async fn force_rotate_secret( State(app_state): State>, Extension(security_ctx): Extension, @@ -845,6 +918,15 @@ pub async fn force_rotate_secret( } /// Handler: Get rotation status for a secret +#[onto_api( + method = "GET", + path = "/secrets/{path}/rotation-status", + description = "Get rotation status for a secret", + auth = "admin", + actors = "developer", + tags = "secrets", + feature = "" +)] pub async fn get_rotation_status( State(app_state): State>, Extension(security_ctx): Extension, @@ -922,6 +1004,15 @@ pub struct GrantResponse { } /// Handler: Create a secret grant (sharing) +#[onto_api( + method = "POST", + path = "/secrets/{path}/grant", + description = "Create a grant to share a secret", + auth = "admin", + actors = "developer", + tags = "secrets", + feature = "" +)] pub async fn create_grant( State(app_state): State>, Extension(security_ctx): Extension, @@ -998,6 +1089,15 @@ pub struct RevokeGrantRequest { } /// Handler: Revoke a secret grant +#[onto_api( + method = "POST", + path = "/secrets/grant/{grant_id}/revoke", + description = "Revoke a secret grant", + auth = "admin", + actors = "developer", + tags = "secrets", + feature = "" +)] pub async fn revoke_grant( State(app_state): State>, Extension(security_ctx): Extension, @@ -1035,6 +1135,15 @@ pub async fn revoke_grant( // ============== PHASE 3.4: MONITORING HANDLERS ============== /// Handler: Get dashboard metrics +#[onto_api( + method = "GET", + path = "/secrets/monitoring/dashboard", + description = "Get secrets monitoring dashboard metrics", + auth = "admin", + actors = "developer", + tags = "secrets", + feature = "" +)] pub async fn get_dashboard_metrics( State(app_state): State>, Extension(_security_ctx): Extension, @@ -1055,6 +1164,15 @@ pub async fn get_dashboard_metrics( } /// Handler: Get alert summary +#[onto_api( + method = "GET", + path = "/secrets/monitoring/alerts", + description = "Get secrets monitoring alert summary", + auth = "admin", + actors = "developer", + tags = "secrets", + feature = "" +)] pub async fn get_alert_summary( State(app_state): State>, Extension(_security_ctx): Extension, @@ -1072,6 +1190,15 @@ pub async fn get_alert_summary( } /// Handler: Get expiring secrets +#[onto_api( + method = "GET", + path = "/secrets/monitoring/expiring", + description = "Get list of expiring secrets", + auth = "admin", + actors = "developer", + tags = "secrets", + feature = "" +)] pub async fn get_expiring_secrets( State(app_state): State>, Extension(_security_ctx): Extension, diff --git a/crates/control-center/src/lib.rs b/crates/control-center/src/lib.rs index f9f947b..1b4808c 100644 --- a/crates/control-center/src/lib.rs +++ b/crates/control-center/src/lib.rs @@ -23,6 +23,7 @@ pub mod error; pub mod handlers; pub mod middleware; pub mod models; +pub mod ncl_config; pub mod services; pub mod simple_config; pub mod storage; diff --git a/crates/control-center/src/main.rs b/crates/control-center/src/main.rs index 75b6eb0..d46e06e 100644 --- a/crates/control-center/src/main.rs +++ b/crates/control-center/src/main.rs @@ -11,6 +11,8 @@ use axum::{ routing::{get, post}, Router, }; + +mod api_catalog; use clap::Parser; use control_center::handlers::{ auth::*, @@ -94,12 +96,22 @@ struct Cli { /// Generate default configuration file #[arg(long)] generate_config: bool, + + /// Print all #[onto_api] registered routes as JSON and exit. + /// Pipe to api-catalog-control-center.json: `just export-api-catalog` + #[arg(long)] + dump_api_catalog: bool, } #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); + if cli.dump_api_catalog { + println!("{}", ontoref_ontology::api::dump_catalog_json()); + return Ok(()); + } + // Generate default config if requested if cli.generate_config { let config_path = cli.config.unwrap_or_else(|| PathBuf::from("config.toml")); @@ -114,7 +126,7 @@ async fn main() -> Result<()> { // Check if control-center is enabled in deployment-mode.ncl if let Ok(deployment) = platform_config::load_deployment_mode() { - if let Ok(enabled) = deployment.is_service_enabled("control-center") { + if let Ok(enabled) = deployment.is_service_enabled("control_center") { if !enabled { warn!("⚠ Control Center is DISABLED in deployment-mode.ncl"); std::process::exit(1); @@ -123,25 +135,28 @@ async fn main() -> Result<()> { } } - // Try to load control-center.ncl - if let Ok(config) = platform_config::load_service_config_from_ncl("control-center") { - info!("✓ Loaded control-center configuration from NCL"); - tracing::debug!("Config: {:?}", config); + // Load configuration from NCL using the same pattern as orchestrator and vault-service: + // ControlCenterNclConfig implements ConfigLoader which reads the NCL via + // PROVISIONING_CONFIG_DIR, exports to JSON, and deserializes into typed structs. + use control_center::ncl_config::ControlCenterNclConfig; + use platform_config::format::ConfigLoader as _; + let ncl = ControlCenterNclConfig::load().map_err(|e| { + control_center::ControlCenterError::Infrastructure( + control_center::error::infrastructure::InfrastructureError::Configuration(format!( + "Failed to load control-center NCL config: {}", + e + )), + ) + })?; + let mut config = ncl.to_runtime_config()?; + + // Apply explicit CLI overrides on top of NCL values + if let Some(path) = &cli.config { + // If a TOML config file was explicitly given, merge it on top of NCL + let toml_config = Config::load_from_file(path)?; + config = toml_config; } - // Resolve config file path using new resolver - let resolver = platform_config::ConfigResolver::new() - .with_cli_config(cli.config.clone()) - .with_cli_config_dir(cli.config_dir.clone()) - .with_cli_mode(cli.mode.clone()); - - // Load configuration - let mut config = if let Some(path) = resolver.resolve("control-center") { - Config::load_from_file(path)? - } else { - Config::load()? - }; - // Apply CLI overrides if let Some(port) = cli.port { config.server.port = port; @@ -207,6 +222,7 @@ async fn create_router(app_state: Arc) -> Result { .route("/health", get(health_check)) .route("/auth/login", post(login)) .route("/auth/refresh", post(refresh_token)) + .route("/api/catalog", get(api_catalog::api_catalog)) .layer(auth_rate_limit); // Protected routes (authentication required) @@ -239,44 +255,44 @@ async fn create_router(app_state: Arc) -> Result { // .route("/permissions/actions", get(get_actions)) // Detection routes (Infrastructure-from-Code) .route("/detections", get(list_detections)) - .route("/detections/:id", get(get_detection)) + .route("/detections/{id}", get(get_detection)) .route("/detections/analyze", post(analyze_project)) // Rules routes (Inference rules) .route("/rules", get(list_rules).post(create_rule)) - .route("/rules/org/:org", get(list_org_rules)) + .route("/rules/org/{org}", get(list_org_rules)) .route( - "/rules/:id", + "/rules/{id}", get(get_rule).put(update_rule).delete(delete_rule), ) - .route("/rules/:id/test", post(test_rule)) + .route("/rules/{id}/test", post(test_rule)) // Deployment routes .route( "/deployments", get(list_deployments).post(create_deployment), ) .route( - "/deployments/:id", + "/deployments/{id}", get(get_deployment).put(update_deployment), ) - .route("/deployments/:id/submit", post(submit_deployment)) - .route("/deployments/:id/status", get(get_deployment_status)) + .route("/deployments/{id}/submit", post(submit_deployment)) + .route("/deployments/{id}/status", get(get_deployment_status)) // Secrets routes (Phase 1.5 - Now active with SecretsService state initialization) .route("/secrets", post(create_secret).get(list_secrets)) .route( - "/secrets/:path", + "/secrets/{path}", get(get_secret).put(update_secret).delete(delete_secret), ) - .route("/secrets/:path/history", get(get_secret_history)) + .route("/secrets/{path}/history", get(get_secret_history)) .route( - "/secrets/:path/restore/:version", + "/secrets/{path}/restore/{version}", post(restore_secret_version), ) // Secrets Phase 3.1: Rotation routes - .route("/secrets/:path/rotate", post(force_rotate_secret)) - .route("/secrets/:path/rotation-status", get(get_rotation_status)) + .route("/secrets/{path}/rotate", post(force_rotate_secret)) + .route("/secrets/{path}/rotation-status", get(get_rotation_status)) // Secrets Phase 3.2: Sharing routes - .route("/secrets/:path/grant", post(create_grant)) - .route("/secrets/grant/:grant_id/revoke", post(revoke_grant)) + .route("/secrets/{path}/grant", post(create_grant)) + .route("/secrets/grant/{grant_id}/revoke", post(revoke_grant)) // Secrets Phase 3.4: Monitoring routes .route("/secrets/monitoring/dashboard", get(get_dashboard_metrics)) .route("/secrets/monitoring/alerts", get(get_alert_summary)) diff --git a/crates/control-center/src/models/permission.rs b/crates/control-center/src/models/permission.rs index 52e1754..83678dc 100644 --- a/crates/control-center/src/models/permission.rs +++ b/crates/control-center/src/models/permission.rs @@ -1,13 +1,14 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use surrealdb::sql::Thing; +use surrealdb::types::{RecordId, SurrealValue}; use uuid::Uuid; use validator::Validate; /// Permission model for fine-grained access control -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] pub struct Permission { - pub id: Option, + pub id: Option, pub permission_id: Uuid, pub name: String, pub resource: String, diff --git a/crates/control-center/src/models/role.rs b/crates/control-center/src/models/role.rs index 867fc0a..975c8b3 100644 --- a/crates/control-center/src/models/role.rs +++ b/crates/control-center/src/models/role.rs @@ -1,13 +1,14 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use surrealdb::sql::Thing; +use surrealdb::types::{RecordId, SurrealValue}; use uuid::Uuid; use validator::Validate; /// Role model for permission-based access control -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] pub struct Role { - pub id: Option, + pub id: Option, pub role_id: Uuid, pub name: String, pub description: Option, diff --git a/crates/control-center/src/models/session.rs b/crates/control-center/src/models/session.rs index f5a08c4..a9024e0 100644 --- a/crates/control-center/src/models/session.rs +++ b/crates/control-center/src/models/session.rs @@ -1,12 +1,13 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use surrealdb::sql::Thing; +use surrealdb::types::{RecordId, SurrealValue}; use uuid::Uuid; /// Session model for managing user sessions and refresh tokens -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] pub struct Session { - pub id: Option, + pub id: Option, pub session_id: Uuid, pub user_id: Uuid, pub refresh_token: String, @@ -18,7 +19,8 @@ pub struct Session { } /// Client information for session tracking -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] pub struct ClientInfo { pub user_agent: Option, pub ip_address: Option, diff --git a/crates/control-center/src/models/user.rs b/crates/control-center/src/models/user.rs index db3637d..5aa6cad 100644 --- a/crates/control-center/src/models/user.rs +++ b/crates/control-center/src/models/user.rs @@ -1,13 +1,14 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use surrealdb::sql::Thing; +use surrealdb::types::{RecordId, SurrealValue}; use uuid::Uuid; use validator::Validate; /// User model for SurrealDB storage -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] pub struct User { - pub id: Option, + pub id: Option, pub user_id: Uuid, pub email: String, pub username: String, diff --git a/crates/control-center/src/ncl_config.rs b/crates/control-center/src/ncl_config.rs new file mode 100644 index 0000000..55d6c91 --- /dev/null +++ b/crates/control-center/src/ncl_config.rs @@ -0,0 +1,404 @@ +use platform_config::format::ConfigLoader; +use serde::{Deserialize, Serialize}; + +use crate::error::{infrastructure, ControlCenterError, Result}; +use crate::simple_config::{ + Config, CorsConfig, DatabaseConfig, JwtConfig, LoggingConfig, RateLimitConfig, SecurityConfig, + ServerConfig, +}; + +/// Root wrapper matching the NCL export key `control_center`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ControlCenterNclConfig { + #[serde(default)] + pub control_center: NclSettings, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct NclSettings { + #[serde(default)] + pub server: NclServer, + #[serde(default)] + pub database: NclDatabase, + #[serde(default)] + pub security: NclSecurity, + #[serde(default)] + pub logging: NclLogging, + #[serde(default)] + pub users: NclUsers, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NclServer { + #[serde(default = "default_host")] + pub host: String, + #[serde(default = "default_port")] + pub port: u16, + pub workers: Option, + pub keep_alive: Option, + pub max_connections: Option, +} + +impl Default for NclServer { + fn default() -> Self { + Self { + host: default_host(), + port: default_port(), + workers: Some(2), + keep_alive: Some(75), + max_connections: Some(100), + } + } +} + +fn default_host() -> String { + "0.0.0.0".to_string() +} +fn default_port() -> u16 { + 9012 +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct NclDatabase { + /// Internal storage backend of the SurrealDB server (e.g. "rocksdb"). + /// Does NOT determine how the Rust client connects — always WebSocket for Docker. + #[serde(default)] + pub backend: Option, + #[serde(default)] + pub mode: Option, + /// RocksDB data path (server-side configuration, not used by Rust client) + #[serde(default)] + pub path: Option, + /// WebSocket connection string for remote SurrealDB (Docker). + /// Falls back to "127.0.0.1:8000" if unset. + #[serde(default)] + pub connection_string: Option, + #[serde(default)] + pub namespace: Option, + /// SurrealDB authentication credentials (required when server runs with --user/--pass) + #[serde(default)] + pub credentials: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct NclDbCredentials { + pub username: Option, + pub password: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct NclSecurity { + #[serde(default)] + pub jwt: NclJwt, + #[serde(default)] + pub rate_limiting: NclRateLimiting, + #[serde(default)] + pub cors: NclCors, + #[serde(default)] + pub session: NclSession, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NclJwt { + #[serde(default = "default_issuer")] + pub issuer: String, + #[serde(default = "default_audience")] + pub audience: String, + /// Access token lifetime in seconds + #[serde(default = "default_expiration")] + pub expiration: i64, + /// Refresh token lifetime in seconds + #[serde(default = "default_refresh_expiration")] + pub refresh_expiration: i64, +} + +impl Default for NclJwt { + fn default() -> Self { + Self { + issuer: default_issuer(), + audience: default_audience(), + expiration: default_expiration(), + refresh_expiration: default_refresh_expiration(), + } + } +} + +fn default_issuer() -> String { + "control-center".to_string() +} +fn default_audience() -> String { + "provisioning".to_string() +} +fn default_expiration() -> i64 { + 3600 +} +fn default_refresh_expiration() -> i64 { + 86400 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NclRateLimiting { + #[serde(default)] + pub enabled: bool, + /// NCL schema types this field as String ("1000"), so we use Value to handle both. + #[serde(default = "default_max_requests_json")] + pub max_requests: serde_json::Value, + #[serde(default = "default_window_secs")] + pub window_seconds: u64, +} + +impl Default for NclRateLimiting { + fn default() -> Self { + Self { + enabled: false, + max_requests: serde_json::Value::Number(100.into()), + window_seconds: 60, + } + } +} + +impl NclRateLimiting { + fn max_requests_u32(&self) -> u32 { + match &self.max_requests { + serde_json::Value::Number(n) => n.as_u64().unwrap_or(100) as u32, + serde_json::Value::String(s) => s.parse().unwrap_or(100), + _ => 100, + } + } +} + +fn default_max_requests_json() -> serde_json::Value { + serde_json::Value::Number(100.into()) +} +fn default_window_secs() -> u64 { + 60 +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct NclCors { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub allow_credentials: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NclSession { + /// Idle timeout in seconds — drives session_cleanup_interval_minutes + #[serde(default = "default_idle_timeout")] + pub idle_timeout: u64, +} + +impl Default for NclSession { + fn default() -> Self { + Self { + idle_timeout: default_idle_timeout(), + } + } +} + +fn default_idle_timeout() -> u64 { + 3600 +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct NclUsers { + #[serde(default)] + pub sessions: NclUserSessions, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NclUserSessions { + pub max_active: Option, + #[serde(default = "default_idle_timeout")] + pub idle_timeout: u64, +} + +impl Default for NclUserSessions { + fn default() -> Self { + Self { + max_active: Some(5), + idle_timeout: 3600, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NclLogging { + #[serde(default = "default_log_level")] + pub level: String, + #[serde(default = "default_log_format")] + pub format: String, +} + +impl Default for NclLogging { + fn default() -> Self { + Self { + level: default_log_level(), + format: default_log_format(), + } + } +} + +fn default_log_level() -> String { + "info".to_string() +} +fn default_log_format() -> String { + "json".to_string() +} + +impl ConfigLoader for ControlCenterNclConfig { + fn service_name() -> &'static str { + "control-center" + } + + fn load_from_hierarchy( + ) -> std::result::Result> { + let service = Self::service_name(); + if let Some(path) = platform_config::resolve_config_path(service) { + return Self::from_path(&path); + } + Ok(Self::default()) + } + + fn from_path>( + path: P, + ) -> std::result::Result> { + let path = path.as_ref(); + let json_value = platform_config::format::load_config(path).map_err(|e| { + Box::new(e) as Box + })?; + serde_json::from_value(json_value).map_err(|e| { + Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "Failed to deserialize control-center NCL config from {:?}: {}", + path, e + ), + )) as Box + }) + } +} + +impl ControlCenterNclConfig { + /// Convert the NCL platform config into the runtime `Config` used by services. + /// + /// JWT RSA keys are generated fresh on each startup (NCL uses HS256 which + /// is incompatible with the service's RS256 implementation). For solo mode, + /// sessions do not persist across restarts, so ephemeral keys are acceptable. + pub fn to_runtime_config(&self) -> Result { + let cc = &self.control_center; + + let key_pair = + crate::services::jwt::generate_rsa_key_pair().map_err(|e| { + ControlCenterError::Infrastructure( + infrastructure::InfrastructureError::Configuration(format!( + "Failed to generate RSA key pair: {}", + e + )), + ) + })?; + + let db_url = cc + .database + .connection_string + .as_deref() + .unwrap_or("127.0.0.1:8000") + .to_string(); + + let namespace = cc + .database + .namespace + .as_deref() + .unwrap_or("control_center") + .to_string(); + + // Credentials: NCL → env vars SURREALDB_USER / SURREALDB_PASS → None + let db_username = cc + .database + .credentials + .as_ref() + .and_then(|c| c.username.clone()) + .or_else(|| std::env::var("SURREALDB_USER").ok()); + let db_password = cc + .database + .credentials + .as_ref() + .and_then(|c| c.password.clone()) + .or_else(|| std::env::var("SURREALDB_PASS").ok()); + + Ok(Config { + server: ServerConfig { + host: cc.server.host.clone(), + port: cc.server.port, + workers: cc.server.workers, + keep_alive: cc.server.keep_alive, + max_connections: cc.server.max_connections, + }, + database: DatabaseConfig { + url: db_url, + namespace, + database: "main".to_string(), + username: db_username, + password: db_password, + }, + jwt: JwtConfig { + issuer: cc.security.jwt.issuer.clone(), + audience: cc.security.jwt.audience.clone(), + access_token_expiration_hours: (cc.security.jwt.expiration / 3600).max(1), + refresh_token_expiration_hours: (cc.security.jwt.refresh_expiration / 3600).max(1), + private_key_pem: key_pair.private_key_pem, + public_key_pem: key_pair.public_key_pem, + }, + rate_limiting: RateLimitConfig { + max_requests: cc.security.rate_limiting.max_requests_u32(), + window_seconds: cc.security.rate_limiting.window_seconds, + per_ip: true, + global: false, + }, + cors: CorsConfig { + allowed_origins: vec!["*".to_string()], + allowed_methods: vec![ + "GET".to_string(), + "POST".to_string(), + "PUT".to_string(), + "DELETE".to_string(), + "PATCH".to_string(), + "OPTIONS".to_string(), + ], + allowed_headers: vec![ + "content-type".to_string(), + "authorization".to_string(), + "accept".to_string(), + "x-requested-with".to_string(), + "x-session-id".to_string(), + ], + expose_headers: vec![ + "x-total-count".to_string(), + "x-rate-limit-remaining".to_string(), + "x-rate-limit-limit".to_string(), + "x-rate-limit-reset".to_string(), + ], + max_age: 86400, + allow_credentials: cc.security.cors.allow_credentials, + }, + security: SecurityConfig { + session_cleanup_interval_minutes: (cc.security.session.idle_timeout / 60).max(1), + max_sessions_per_user: cc.users.sessions.max_active, + password_min_length: 8, + password_require_special_chars: false, + password_require_numbers: false, + password_require_uppercase: false, + failed_login_lockout_attempts: Some(5), + failed_login_lockout_duration_minutes: Some(15), + }, + logging: LoggingConfig { + level: cc.logging.level.clone(), + format: cc.logging.format.clone(), + file_path: None, + max_file_size: Some("100MB".to_string()), + max_files: Some(10), + }, + }) + } +} diff --git a/crates/control-center/src/services/database.rs b/crates/control-center/src/services/database.rs index 9720920..bccfa5f 100644 --- a/crates/control-center/src/services/database.rs +++ b/crates/control-center/src/services/database.rs @@ -1,6 +1,7 @@ use anyhow::Context; -use surrealdb::engine::local::Mem; +use surrealdb::engine::remote::ws::{Client, Ws}; use surrealdb::opt::auth::Root; +use surrealdb::types::SurrealValue; use surrealdb::Surreal; use tracing::{info, warn}; @@ -9,7 +10,7 @@ use crate::error::{auth, ControlCenterError, Result}; /// Database service for SurrealDB operations #[derive(Clone)] pub struct DatabaseService { - pub db: Surreal, + pub db: Surreal, } // Use the configuration from simple_config @@ -18,15 +19,18 @@ use crate::simple_config::DatabaseConfig; impl DatabaseService { /// Create a new database service and connect pub async fn new(config: DatabaseConfig) -> Result { - info!("Connecting to SurrealDB (in-memory) at {}", config.url); + info!("Connecting to SurrealDB at ws://{}", config.url); - let db = Surreal::new::(()) + let db = Surreal::new::(&*config.url) .await .context("Failed to connect to SurrealDB")?; - // Sign in with root credentials if provided + // Sign in only when credentials are explicitly configured if let (Some(username), Some(password)) = (&config.username, &config.password) { - db.signin(Root { username, password }) + db.signin(Root { + username: username.clone(), + password: password.clone(), + }) .await .context("Failed to sign in to SurrealDB")?; } @@ -414,7 +418,8 @@ impl DatabaseService { } /// Database statistics -#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[derive(Debug, serde::Deserialize, serde::Serialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] pub struct DatabaseStatistics { pub users_count: i64, pub roles_count: i64, diff --git a/crates/control-center/src/services/iac_deployment.rs b/crates/control-center/src/services/iac_deployment.rs index e84fa5e..92d6389 100644 --- a/crates/control-center/src/services/iac_deployment.rs +++ b/crates/control-center/src/services/iac_deployment.rs @@ -6,13 +6,15 @@ use std::sync::Arc; use anyhow::Context; use serde::{Deserialize, Serialize}; +use surrealdb::types::SurrealValue; use uuid::Uuid; use super::DatabaseService; use crate::error::{http, ControlCenterError, Result}; /// Deployment task status -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, SurrealValue)] +#[surreal(crate = "surrealdb::types")] #[serde(rename_all = "lowercase")] pub enum TaskStatus { #[default] @@ -34,7 +36,8 @@ impl std::fmt::Display for TaskStatus { } /// Deployment task -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] pub struct DeploymentTask { pub id: String, pub task_type: String, @@ -47,7 +50,8 @@ pub struct DeploymentTask { } /// Deployment plan -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] pub struct DeploymentPlan { pub id: String, pub name: String, @@ -59,7 +63,8 @@ pub struct DeploymentPlan { } /// Deployment execution status -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] pub struct DeploymentExecution { pub id: String, pub plan_id: String, diff --git a/crates/control-center/src/services/iac_detection.rs b/crates/control-center/src/services/iac_detection.rs index 658b8c7..e50ac20 100644 --- a/crates/control-center/src/services/iac_detection.rs +++ b/crates/control-center/src/services/iac_detection.rs @@ -4,6 +4,8 @@ use std::sync::Arc; +use surrealdb::types::SurrealValue; + use anyhow::Context; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -12,7 +14,8 @@ use super::DatabaseService; use crate::error::{http, ControlCenterError, Result}; /// Detected technology information -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] pub struct DetectedTechnology { pub technology: String, pub confidence: f32, @@ -21,7 +24,8 @@ pub struct DetectedTechnology { } /// Detection result with analysis metadata -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] pub struct DetectionResult { pub id: String, pub project_path: String, diff --git a/crates/control-center/src/services/iac_rules.rs b/crates/control-center/src/services/iac_rules.rs index 13b169f..e3aee3c 100644 --- a/crates/control-center/src/services/iac_rules.rs +++ b/crates/control-center/src/services/iac_rules.rs @@ -4,6 +4,8 @@ use std::sync::Arc; +use surrealdb::types::SurrealValue; + use anyhow::Context; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -12,7 +14,8 @@ use super::DatabaseService; use crate::error::{http, ControlCenterError, Result}; /// Single inference in a rule -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] pub struct RuleInference { pub technology: String, pub reason: String, @@ -21,7 +24,8 @@ pub struct RuleInference { } /// Inference rule for infrastructure completion -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] pub struct InferenceRule { pub id: String, pub name: String, diff --git a/crates/control-center/src/services/secrets.rs b/crates/control-center/src/services/secrets.rs index 8848ca0..2a34e9f 100644 --- a/crates/control-center/src/services/secrets.rs +++ b/crates/control-center/src/services/secrets.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use base64::Engine; use chrono::Utc; use serde::{Deserialize, Serialize}; +use surrealdb::types::SurrealValue; use tracing::{debug, error, info}; use crate::audit::AuditLogger; @@ -14,7 +15,8 @@ use crate::kms::kms_service_client::KmsServiceClient; use crate::storage::surrealdb_storage::SurrealDbStorage; /// Database connection information -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] pub struct DatabaseConnection { pub host: String, pub port: u16, @@ -32,7 +34,8 @@ pub enum SecretLifecycle { } /// Secret type classification -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] pub enum SecretType { /// Database credentials Database { @@ -58,7 +61,8 @@ pub enum SecretType { } /// Vault secret metadata stored in SurrealDB -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] pub struct VaultSecret { // Existing fields pub id: String, diff --git a/crates/control-center/src/simple_config.rs b/crates/control-center/src/simple_config.rs index 774810d..dffc1eb 100644 --- a/crates/control-center/src/simple_config.rs +++ b/crates/control-center/src/simple_config.rs @@ -126,7 +126,7 @@ impl Default for ServerConfig { fn default() -> Self { Self { host: "0.0.0.0".to_string(), - port: 9080, + port: 9012, workers: None, keep_alive: Some(75), max_connections: Some(1000), diff --git a/crates/control-center/src/storage/mod.rs b/crates/control-center/src/storage/mod.rs index 7b5b0b7..2d471b7 100644 --- a/crates/control-center/src/storage/mod.rs +++ b/crates/control-center/src/storage/mod.rs @@ -11,6 +11,7 @@ use std::sync::Arc; use async_trait::async_trait; pub use database::{Database, DatabaseConfig}; use serde::{Deserialize, Serialize}; +use surrealdb::types::SurrealValue; // TODO: Re-enable when policies module is fixed // use crate::policies::{PolicyMetadata, PolicyVersion}; // use crate::policies::versioning::RollbackResult; @@ -145,7 +146,8 @@ impl Default for PolicySearchQuery { } /// Policy evaluation event for audit trail -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] pub struct PolicyEvaluationEvent { pub id: String, pub policy_id: String, @@ -174,7 +176,8 @@ pub struct PolicyMetrics { } /// Compliance check result -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] pub struct ComplianceCheckResult { pub id: String, pub framework: String, diff --git a/crates/control-center/src/storage/surrealdb_storage.rs b/crates/control-center/src/storage/surrealdb_storage.rs index a14a477..5da9c2e 100644 --- a/crates/control-center/src/storage/surrealdb_storage.rs +++ b/crates/control-center/src/storage/surrealdb_storage.rs @@ -17,7 +17,8 @@ use crate::error::{auth, policy, ControlCenterError, Result}; use crate::services::secrets::SecretType; use crate::simple_config::Config; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] pub struct PolicyMetadata { pub id: String, pub name: String, @@ -32,7 +33,8 @@ pub struct PolicyMetadata { pub enabled: bool, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] pub struct PolicyVersion { pub version_id: String, pub policy_id: String, @@ -54,12 +56,14 @@ pub struct RollbackResult { use async_trait::async_trait; use surrealdb::engine::local::Mem; use surrealdb::engine::remote::ws::{Client, Ws}; -use surrealdb::{RecordId, Surreal}; +use surrealdb::types::{RecordId, SurrealValue}; +use surrealdb::Surreal; use tracing::{debug, info}; use uuid::Uuid; /// SurrealDB record for policies -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] struct PolicyRecord { pub id: RecordId, pub policy_id: String, @@ -70,21 +74,24 @@ struct PolicyRecord { } /// SurrealDB record for policy versions -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] struct PolicyVersionRecord { pub id: RecordId, pub version: PolicyVersion, } /// SurrealDB record for policy evaluations -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] struct PolicyEvaluationRecord { pub id: RecordId, pub evaluation: PolicyEvaluationEvent, } /// SurrealDB record for compliance checks -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] struct ComplianceCheckRecord { pub id: RecordId, pub result: ComplianceCheckResult, @@ -110,8 +117,11 @@ impl SurrealDbPolicyStorage { if let (Some(username), Some(password)) = (&config.database.username, &config.database.password) { - db.signin(surrealdb::opt::auth::Root { username, password }) - .await?; + db.signin(surrealdb::opt::auth::Root { + username: username.clone(), + password: password.clone(), + }) + .await?; } // Use namespace and database @@ -285,7 +295,7 @@ where { /// Generate record ID for table fn generate_record_id(&self, table: &str) -> RecordId { - RecordId::from_table_key(table, Uuid::new_v4().to_string()) + RecordId::new(table, Uuid::new_v4().to_string()) } } @@ -701,7 +711,7 @@ where pub async fn create_secret(&self, secret: &VaultSecret) -> Result<()> { let _: Option = self .db - .create(("vault_secrets", &secret.id)) + .create(("vault_secrets", secret.id.as_str())) .content(secret.clone()) .await?; @@ -866,7 +876,7 @@ where // Update current version in vault_secrets let _: Option = self .db - .update(("vault_secrets", &secret.id)) + .update(("vault_secrets", secret.id.as_str())) .content(secret.clone()) .await?; diff --git a/crates/daemon/Cargo.toml b/crates/daemon/Cargo.toml deleted file mode 100644 index b8c0fc8..0000000 --- a/crates/daemon/Cargo.toml +++ /dev/null @@ -1,48 +0,0 @@ -[package] -authors.workspace = true -edition.workspace = true -license.workspace = true -name = "daemon" -repository.workspace = true -version.workspace = true - -[[bin]] -name = "provisioning-daemon" -path = "src/main.rs" - -[dependencies] -# Core daemon library from prov-ecosystem -daemon-cli = { workspace = true } - -# Async runtime and networking -axum = { workspace = true } -tokio = { workspace = true } -tower = { workspace = true } -tower-http = { workspace = true } - -# Serialization -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -toml = { workspace = true } - -# Platform configuration -platform-config = { workspace = true } - -# Centralized observability (logging, metrics, health, tracing) -observability = { workspace = true, features = ["logging", "metrics-prometheus", "health"] } - -# Error handling -anyhow = { workspace = true } -thiserror = { workspace = true } - -# Logging -tracing = { workspace = true } -tracing-subscriber = { workspace = true } - -# CLI -clap = { workspace = true, features = ["derive"] } - -# Utilities -chrono = { workspace = true } -dirs = { workspace = true } -uuid = { workspace = true } diff --git a/crates/data/tasks/00b7d705-2ac2-4532-9ce9-d161dd320011.json b/crates/data/tasks/00b7d705-2ac2-4532-9ce9-d161dd320011.json new file mode 100644 index 0000000..7c0e767 --- /dev/null +++ b/crates/data/tasks/00b7d705-2ac2-4532-9ce9-d161dd320011.json @@ -0,0 +1,23 @@ +{ + "id": "00b7d705-2ac2-4532-9ce9-d161dd320011", + "name": "component_install_democratic_csi", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_00b7d705-2ac2-4532-9ce9-d161dd320011.tar.gz.b64' > /tmp/democratic_csi.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/democratic_csi.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/democratic_csi && mkdir -p /tmp/democratic_csi && tar xzf /tmp/democratic_csi.tar.gz -C /tmp/democratic_csi && cd /tmp/democratic_csi && sudo bash install-democratic_csi.sh install ; rc=$?; rm -f /tmp/democratic_csi.tar.gz && rm -rf /tmp/democratic_csi; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_00b7d705-2ac2-4532-9ce9-d161dd320011.tar.gz.b64' /tmp/democratic_csi.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T03:15:03.134943Z", + "started_at": "2026-04-21T03:15:03.377192Z", + "completed_at": "2026-04-21T03:15:30.771379Z", + "output": "=== democratic-csi: v0.14.6 ===\n=== democratic-csi: deploying nfs-client driver (server=10.0.8.20) ===\nnamespace/democratic-csi unchanged\n\"democratic-csi\" has been added to your repositories\nHang tight while we grab the latest from your chart repositories...\n...Successfully got an update from the \"democratic-csi\" chart repository\nUpdate Complete. ⎈Happy Helming!⎈\nserviceaccount/democratic-csi-controller-sa created\nserviceaccount/democratic-csi-node-sa created\nsecret/democratic-csi-driver-config created\nconfigmap/democratic-csi created\nstorageclass.storage.k8s.io/nfs-shared created\nclusterrole.rbac.authorization.k8s.io/democratic-csi-controller-cr created\nclusterrole.rbac.authorization.k8s.io/democratic-csi-node-cr created\nclusterrolebinding.rbac.authorization.k8s.io/democratic-csi-controller-rb created\nclusterrolebinding.rbac.authorization.k8s.io/democratic-csi-node-rb created\ndaemonset.apps/democratic-csi-node created\ndeployment.apps/democratic-csi-controller created\ncsidriver.storage.k8s.io/org.democratic-csi.nfs-client created\n=== democratic-csi: waiting for controller ===\nWaiting for deployment \"democratic-csi-controller\" rollout to finish: 0 of 1 updated replicas are available...\ndeployment \"democratic-csi-controller\" successfully rolled out\n=== democratic-csi: ready (nfs=10.0.8.20/var/lib/data/shared, class=nfs-shared) ===\n", + "error": null, + "tags": { + "type": "component", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "component": "democratic_csi", + "operation": "install", + "server": "libre-wuji-cp-0" + } +} \ No newline at end of file diff --git a/crates/data/tasks/056539f0-0782-456f-b3e0-3a3e4efc3bb6.json b/crates/data/tasks/056539f0-0782-456f-b3e0-3a3e4efc3bb6.json new file mode 100644 index 0000000..2066ff9 --- /dev/null +++ b/crates/data/tasks/056539f0-0782-456f-b3e0-3a3e4efc3bb6.json @@ -0,0 +1,23 @@ +{ + "id": "056539f0-0782-456f-b3e0-3a3e4efc3bb6", + "name": "component_install_os", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_056539f0-0782-456f-b3e0-3a3e4efc3bb6.tar.gz.b64' > /tmp/os.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/os.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/os && mkdir -p /tmp/os && tar xzf /tmp/os.tar.gz -C /tmp/os && cd /tmp/os && sudo bash install-os.sh install ; rc=$?; rm -f /tmp/os.tar.gz && rm -rf /tmp/os; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_056539f0-0782-456f-b3e0-3a3e4efc3bb6.tar.gz.b64' /tmp/os.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T01:40:00.346636Z", + "started_at": "2026-04-21T01:40:00.520733Z", + "completed_at": "2026-04-21T01:40:58.901309Z", + "output": "APT::Get::Update::SourceListWarnings::NonFreeFirmware \"false\";\nGet:1 http://mirror.hetzner.com/debian/packages bookworm InRelease [151 kB]\nGet:2 http://deb.debian.org/debian bookworm InRelease [151 kB]\nGet:3 http://security.debian.org/debian-security bookworm-security InRelease [48.0 kB]\nGet:4 http://mirror.hetzner.com/debian/packages bookworm-backports InRelease [59.4 kB]\nGet:5 http://mirror.hetzner.com/debian/packages bookworm-updates InRelease [55.4 kB]\nGet:6 http://mirror.hetzner.com/debian/security bookworm-security InRelease [48.0 kB]\nGet:7 http://deb.debian.org/debian bookworm-updates InRelease [55.4 kB]\nGet:8 http://mirror.hetzner.com/debian/packages bookworm/main arm64 Packages [8,691 kB]\nGet:9 http://mirror.hetzner.com/debian/packages bookworm/main Translation-en [6,108 kB]\nGet:10 http://mirror.hetzner.com/debian/packages bookworm/contrib arm64 Packages [45.7 kB]\nGet:11 http://mirror.hetzner.com/debian/packages bookworm/contrib Translation-en [48.4 kB]\nGet:12 http://mirror.hetzner.com/debian/packages bookworm/non-free arm64 Packages [75.8 kB]\nGet:13 http://mirror.hetzner.com/debian/packages bookworm/non-free Translation-en [68.1 kB]\nGet:14 http://mirror.hetzner.com/debian/packages bookworm/non-free-firmware arm64 Packages [5,832 B]\nGet:15 http://mirror.hetzner.com/debian/packages bookworm/non-free-firmware Translation-en [20.9 kB]\nGet:16 http://mirror.hetzner.com/debian/packages bookworm-backports/main arm64 Packages [304 kB]\nGet:17 http://mirror.hetzner.com/debian/packages bookworm-backports/main Translation-en [257 kB]\nGet:18 http://mirror.hetzner.com/debian/packages bookworm-backports/contrib arm64 Packages [5,180 B]\nGet:19 http://mirror.hetzner.com/debian/packages bookworm-backports/contrib Translation-en [5,864 B]\nGet:20 http://mirror.hetzner.com/debian/packages bookworm-backports/non-free arm64 Packages [12.6 kB]\nGet:21 http://mirror.hetzner.com/debian/packages bookworm-backports/non-free Translation-en [8,460 B]\nGet:22 http://mirror.hetzner.com/debian/packages bookworm-backports/non-free-firmware arm64 Packages [3,832 B]\nGet:23 http://mirror.hetzner.com/debian/packages bookworm-backports/non-free-firmware Translation-en [2,860 B]\nGet:24 http://mirror.hetzner.com/debian/packages bookworm-updates/main arm64 Packages [6,936 B]\nGet:25 http://mirror.hetzner.com/debian/packages bookworm-updates/main Translation-en [5,448 B]\nGet:26 http://mirror.hetzner.com/debian/security bookworm-security/main arm64 Packages [291 kB]\nGet:27 http://mirror.hetzner.com/debian/security bookworm-security/main Translation-en [180 kB]\nGet:28 http://mirror.hetzner.com/debian/security bookworm-security/contrib arm64 Packages [508 B]\nGet:29 http://mirror.hetzner.com/debian/security bookworm-security/contrib Translation-en [652 B]\nGet:30 http://security.debian.org/debian-security bookworm-security/main arm64 Packages [291 kB]\nGet:31 http://security.debian.org/debian-security bookworm-security/main Translation-en [180 kB]\nGet:32 http://security.debian.org/debian-security bookworm-security/contrib arm64 Packages [508 B]\nGet:33 http://security.debian.org/debian-security bookworm-security/contrib Translation-en [652 B]\nGet:34 http://security.debian.org/debian-security bookworm-security/non-free-firmware Translation-en [472 B]\nGet:35 http://deb.debian.org/debian bookworm/main arm64 Packages [8,691 kB]\nGet:36 http://deb.debian.org/debian bookworm/main Translation-en [6,108 kB]\nGet:37 http://deb.debian.org/debian bookworm/contrib arm64 Packages [45.7 kB]\nGet:38 http://deb.debian.org/debian bookworm/contrib Translation-en [48.4 kB]\nGet:39 http://deb.debian.org/debian bookworm/non-free arm64 Packages [75.8 kB]\nGet:40 http://deb.debian.org/debian bookworm/non-free Translation-en [68.1 kB]\nGet:41 http://deb.debian.org/debian bookworm/non-free-firmware arm64 Packages [5,832 B]\nGet:42 http://deb.debian.org/debian bookworm/non-free-firmware Translation-en [20.9 kB]\nGet:43 http://deb.debian.org/debian bookworm-updates/main arm64 Packages [6,936 B]\nGet:44 http://deb.debian.org/debian bookworm-updates/main Translation-en [5,448 B]\nFetched 32.3 MB in 3s (10.9 MB/s)\nReading package lists...\nReading package lists...\nBuilding dependency tree...\nReading state information...\nCalculating upgrade...\n0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.\nPreconfiguring packages ...\nSelecting previously unselected package perl-modules-5.36.\r\n(Reading database ... \r(Reading database ... 5%\r(Reading database ... 10%\r(Reading database ... 15%\r(Reading database ... 20%\r(Reading database ... 25%\r(Reading database ... 30%\r(Reading database ... 35%\r(Reading database ... 40%\r(Reading database ... 45%\r(Reading database ... 50%\r(Reading database ... 55%\r(Reading database ... 60%\r(Reading database ... 65%\r(Reading database ... 70%\r(Reading database ... 75%\r(Reading database ... 80%\r(Reading database ... 85%\r(Reading database ... 90%\r(Reading database ... 95%\r(Reading database ... 100%\r(Reading database ... 36165 files and directories currently installed.)\r\nPreparing to unpack .../00-perl-modules-5.36_5.36.0-7+deb12u3_all.deb ...\r\nUnpacking perl-modules-5.36 (5.36.0-7+deb12u3) ...\r\nSelecting previously unselected package libgdbm-compat4:arm64.\r\nPreparing to unpack .../01-libgdbm-compat4_1.23-3_arm64.deb ...\r\nUnpacking libgdbm-compat4:arm64 (1.23-3) ...\r\nSelecting previously unselected package libperl5.36:arm64.\r\nPreparing to unpack .../02-libperl5.36_5.36.0-7+deb12u3_arm64.deb ...\r\nUnpacking libperl5.36:arm64 (5.36.0-7+deb12u3) ...\r\nSelecting previously unselected package perl.\r\nPreparing to unpack .../03-perl_5.36.0-7+deb12u3_arm64.deb ...\r\nUnpacking perl (5.36.0-7+deb12u3) ...\r\nSelecting previously unselected package libevent-core-2.1-7:arm64.\r\nPreparing to unpack .../04-libevent-core-2.1-7_2.1.12-stable-8_arm64.deb ...\r\nUnpacking libevent-core-2.1-7:arm64 (2.1.12-stable-8) ...\r\nSelecting previously unselected package libnfsidmap1:arm64.\r\nPreparing to unpack .../05-libnfsidmap1_1%3a2.6.2-4+deb12u1_arm64.deb ...\r\nUnpacking libnfsidmap1:arm64 (1:2.6.2-4+deb12u1) ...\r\nSelecting previously unselected package rpcbind.\r\nPreparing to unpack .../06-rpcbind_1.2.6-6+b1_arm64.deb ...\r\nUnpacking rpcbind (1.2.6-6+b1) ...\r\nSelecting previously unselected package keyutils.\r\nPreparing to unpack .../07-keyutils_1.6.3-2_arm64.deb ...\r\nUnpacking keyutils (1.6.3-2) ...\r\nSelecting previously unselected package nfs-common.\r\nPreparing to unpack .../08-nfs-common_1%3a2.6.2-4+deb12u1_arm64.deb ...\r\nUnpacking nfs-common (1:2.6.2-4+deb12u1) ...\r\nSelecting previously unselected package sgml-base.\r\nPreparing to unpack .../09-sgml-base_1.31_all.deb ...\r\nUnpacking sgml-base (1.31) ...\r\nSelecting previously unselected package apt-transport-https.\r\nPreparing to unpack .../10-apt-transport-https_2.6.1_all.deb ...\r\nUnpacking apt-transport-https (2.6.1) ...\r\nSelecting previously unselected package dialog.\r\nPreparing to unpack .../11-dialog_1.3-20230209-1_arm64.deb ...\r\nUnpacking dialog (1.3-20230209-1) ...\r\nSelecting previously unselected package fuse3.\r\nPreparing to unpack .../12-fuse3_3.14.0-4_arm64.deb ...\r\nUnpacking fuse3 (3.14.0-4) ...\r\nSelecting previously unselected package libgirepository-1.0-1:arm64.\r\nPreparing to unpack .../13-libgirepository-1.0-1_1.74.0-3_arm64.deb ...\r\nUnpacking libgirepository-1.0-1:arm64 (1.74.0-3) ...\r\nSelecting previously unselected package gir1.2-glib-2.0:arm64.\r\nPreparing to unpack .../14-gir1.2-glib-2.0_1.74.0-3_arm64.deb ...\r\nUnpacking gir1.2-glib-2.0:arm64 (1.74.0-3) ...\r\nSelecting previously unselected package libpackagekit-glib2-18:arm64.\r\nPreparing to unpack .../15-libpackagekit-glib2-18_1.2.6-5_arm64.deb ...\r\nUnpacking libpackagekit-glib2-18:arm64 (1.2.6-5) ...\r\nSelecting previously unselected package gir1.2-packagekitglib-1.0.\r\nPreparing to unpack .../16-gir1.2-packagekitglib-1.0_1.2.6-5_arm64.deb ...\r\nUnpacking gir1.2-packagekitglib-1.0 (1.2.6-5) ...\r\nSelecting previously unselected package liberror-perl.\r\nPreparing to unpack .../17-liberror-perl_0.17029-2_all.deb ...\r\nUnpacking liberror-perl (0.17029-2) ...\r\nSelecting previously unselected package git-man.\r\nPreparing to unpack .../18-git-man_1%3a2.39.5-0+deb12u3_all.deb ...\r\nUnpacking git-man (1:2.39.5-0+deb12u3) ...\r\nSelecting previously unselected package git.\r\nPreparing to unpack .../19-git_1%3a2.39.5-0+deb12u3_arm64.deb ...\r\nUnpacking git (1:2.39.5-0+deb12u3) ...\r\nSelecting previously unselected package iso-codes.\r\nPreparing to unpack .../20-iso-codes_4.15.0-1_all.deb ...\r\nUnpacking iso-codes (4.15.0-1) ...\r\nSelecting previously unselected package libonig5:arm64.\r\nPreparing to unpack .../21-libonig5_6.9.8-1_arm64.deb ...\r\nUnpacking libonig5:arm64 (6.9.8-1) ...\r\nSelecting previously unselected package libjq1:arm64.\r\nPreparing to unpack .../22-libjq1_1.6-2.1+deb12u1_arm64.deb ...\r\nUnpacking libjq1:arm64 (1.6-2.1+deb12u1) ...\r\nSelecting previously unselected package jq.\r\nPreparing to unpack .../23-jq_1.6-2.1+deb12u1_arm64.deb ...\r\nUnpacking jq (1.6-2.1+deb12u1) ...\r\nSelecting previously unselected package libstemmer0d:arm64.\r\nPreparing to unpack .../24-libstemmer0d_2.2.0-2_arm64.deb ...\r\nUnpacking libstemmer0d:arm64 (2.2.0-2) ...\r\nSelecting previously unselected package libxmlb2:arm64.\r\nPreparing to unpack .../25-libxmlb2_0.3.10-2_arm64.deb ...\r\nUnpacking libxmlb2:arm64 (0.3.10-2) ...\r\nSelecting previously unselected package libappstream4:arm64.\r\nPreparing to unpack .../26-libappstream4_0.16.1-2_arm64.deb ...\r\nUnpacking libappstream4:arm64 (0.16.1-2) ...\r\nSelecting previously unselected package libbluetooth3:arm64.\r\nPreparing to unpack .../27-libbluetooth3_5.66-1+deb12u2_arm64.deb ...\r\nUnpacking libbluetooth3:arm64 (5.66-1+deb12u2) ...\r\nSelecting previously unselected package libduktape207:arm64.\r\nPreparing to unpack .../28-libduktape207_2.7.0-2_arm64.deb ...\r\nUnpacking libduktape207:arm64 (2.7.0-2) ...\r\nSelecting previously unselected package libdw1:arm64.\r\nPreparing to unpack .../29-libdw1_0.188-2.1_arm64.deb ...\r\nUnpacking libdw1:arm64 (0.188-2.1) ...\r\nSelecting previously unselected package libglib2.0-bin.\r\nPreparing to unpack .../30-libglib2.0-bin_2.74.6-2+deb12u8_arm64.deb ...\r\nUnpacking libglib2.0-bin (2.74.6-2+deb12u8) ...\r\nSelecting previously unselected package libunwind8:arm64.\r\nPreparing to unpack .../31-libunwind8_1.6.2-3_arm64.deb ...\r\nUnpacking libunwind8:arm64 (1.6.2-3) ...\r\nSelecting previously unselected package libgstreamer1.0-0:arm64.\r\nPreparing to unpack .../32-libgstreamer1.0-0_1.22.0-2+deb12u1_arm64.deb ...\r\nUnpacking libgstreamer1.0-0:arm64 (1.22.0-2+deb12u1) ...\r\nSelecting previously unselected package libmm-glib0:arm64.\r\nPreparing to unpack .../33-libmm-glib0_1.20.4-1_arm64.deb ...\r\nUnpacking libmm-glib0:arm64 (1.20.4-1) ...\r\nSelecting previously unselected package libndp0:arm64.\r\nPreparing to unpack .../34-libndp0_1.8-1+deb12u1_arm64.deb ...\r\nUnpacking libndp0:arm64 (1.8-1+deb12u1) ...\r\nSelecting previously unselected package libnm0:arm64.\r\nPreparing to unpack .../35-libnm0_1.42.4-1+deb12u1_arm64.deb ...\r\nUnpacking libnm0:arm64 (1.42.4-1+deb12u1) ...\r\nSelecting previously unselected package libpolkit-gobject-1-0:arm64.\r\nPreparing to unpack .../36-libpolkit-gobject-1-0_122-3_arm64.deb ...\r\nUnpacking libpolkit-gobject-1-0:arm64 (122-3) ...\r\nSelecting previously unselected package libpolkit-agent-1-0:arm64.\r\nPreparing to unpack .../37-libpolkit-agent-1-0_122-3_arm64.deb ...\r\nUnpacking libpolkit-agent-1-0:arm64 (122-3) ...\r\nSelecting previously unselected package libsensors-config.\r\nPreparing to unpack .../38-libsensors-config_1%3a3.6.0-7.1_all.deb ...\r\nUnpacking libsensors-config (1:3.6.0-7.1) ...\r\nSelecting previously unselected package libsensors5:arm64.\r\nPreparing to unpack .../39-libsensors5_1%3a3.6.0-7.1_arm64.deb ...\r\nUnpacking libsensors5:arm64 (1:3.6.0-7.1) ...\r\nSelecting previously unselected package libteamdctl0:arm64.\r\nPreparing to unpack .../40-libteamdctl0_1.31-1_arm64.deb ...\r\nUnpacking libteamdctl0:arm64 (1.31-1) ...\r\nSelecting previously unselected package xml-core.\r\nPreparing to unpack .../41-xml-core_0.18+nmu1_all.deb ...\r\nUnpacking xml-core (0.18+nmu1) ...\r\nSelecting previously unselected package polkitd.\r\nPreparing to unpack .../42-polkitd_122-3_arm64.deb ...\r\nUnpacking polkitd (122-3) ...\r\nSelecting previously unselected package network-manager.\r\nPreparing to unpack .../43-network-manager_1.42.4-1+deb12u1_arm64.deb ...\r\nUnpacking network-manager (1.42.4-1+deb12u1) ...\r\nSelecting previously unselected package packagekit.\r\nPreparing to unpack .../44-packagekit_1.2.6-5_arm64.deb ...\r\nUnpacking packagekit (1.2.6-5) ...\r\nSelecting previously unselected package python3-gi.\r\nPreparing to unpack .../45-python3-gi_3.42.2-3+b1_arm64.deb ...\r\nUnpacking python3-gi (3.42.2-3+b1) ...\r\nSelecting previously unselected package python3-lazr.uri.\r\nPreparing to unpack .../46-python3-lazr.uri_1.0.6-3_all.deb ...\r\nUnpacking python3-lazr.uri (1.0.6-3) ...\r\nSelecting previously unselected package python3-wadllib.\r\nPreparing to unpack .../47-python3-wadllib_1.3.6-4_all.deb ...\r\nUnpacking python3-wadllib (1.3.6-4) ...\r\nSelecting previously unselected package python3-lazr.restfulclient.\r\nPreparing to unpack .../48-python3-lazr.restfulclient_0.14.5-1_all.deb ...\r\nUnpacking python3-lazr.restfulclient (0.14.5-1) ...\r\nSelecting previously unselected package python3-software-properties.\r\nPreparing to unpack .../49-python3-software-properties_0.99.30-4.1~deb12u1_all.deb ...\r\nUnpacking python3-software-properties (0.99.30-4.1~deb12u1) ...\r\nSelecting previously unselected package software-properties-common.\r\nPreparing to unpack .../50-software-properties-common_0.99.30-4.1~deb12u1_all.deb ...\r\nUnpacking software-properties-common (0.99.30-4.1~deb12u1) ...\r\nSelecting previously unselected package sshfs.\r\nPreparing to unpack .../51-sshfs_3.7.3-1.1_arm64.deb ...\r\nUnpacking sshfs (3.7.3-1.1) ...\r\nSelecting previously unselected package sysstat.\r\nPreparing to unpack .../52-sysstat_12.6.1-1_arm64.deb ...\r\nUnpacking sysstat (12.6.1-1) ...\r\nSetting up libdw1:arm64 (0.188-2.1) ...\r\nSetting up libnfsidmap1:arm64 (1:2.6.2-4+deb12u1) ...\r\nSetting up apt-transport-https (2.6.1) ...\r\nSetting up libxmlb2:arm64 (0.3.10-2) ...\r\nSetting up libsensors-config (1:3.6.0-7.1) ...\r\nSetting up libglib2.0-bin (2.74.6-2+deb12u8) ...\r\nSetting up libpackagekit-glib2-18:arm64 (1.2.6-5) ...\r\nSetting up rpcbind (1.2.6-6+b1) ...\r\nCreated symlink /etc/systemd/system/multi-user.target.wants/rpcbind.service → /lib/systemd/system/rpcbind.service.\r\r\nCreated symlink /etc/systemd/system/sockets.target.wants/rpcbind.socket → /lib/systemd/system/rpcbind.socket.\r\r\nSetting up python3-lazr.uri (1.0.6-3) ...\r\nSetting up libunwind8:arm64 (1.6.2-3) ...\r\nSetting up fuse3 (3.14.0-4) ...\r\nupdate-initramfs: deferring update (trigger activated)\r\nSetting up perl-modules-5.36 (5.36.0-7+deb12u3) ...\r\nSetting up dialog (1.3-20230209-1) ...\r\nSetting up libteamdctl0:arm64 (1.31-1) ...\r\nSetting up python3-wadllib (1.3.6-4) ...\r\nSetting up libevent-core-2.1-7:arm64 (2.1.12-stable-8) ...\r\nSetting up libgdbm-compat4:arm64 (1.23-3) ...\r\nSetting up libsensors5:arm64 (1:3.6.0-7.1) ...\r\nSetting up libnm0:arm64 (1.42.4-1+deb12u1) ...\r\nSetting up keyutils (1.6.3-2) ...\r\nSetting up libduktape207:arm64 (2.7.0-2) ...\r\nSetting up libmm-glib0:arm64 (1.20.4-1) ...\r\nSetting up libbluetooth3:arm64 (5.66-1+deb12u2) ...\r\nSetting up git-man (1:2.39.5-0+deb12u3) ...\r\nSetting up libgirepository-1.0-1:arm64 (1.74.0-3) ...\r\nSetting up sgml-base (1.31) ...\r\nSetting up libstemmer0d:arm64 (2.2.0-2) ...\r\nSetting up libndp0:arm64 (1.8-1+deb12u1) ...\r\nSetting up python3-lazr.restfulclient (0.14.5-1) ...\r\nSetting up sysstat (12.6.1-1) ...\r\n\r\nCreating config file /etc/default/sysstat with new version\r\nupdate-alternatives: using /usr/bin/sar.sysstat to provide /usr/bin/sar (sar) in auto mode\r\nCreated symlink /etc/systemd/system/sysstat.service.wants/sysstat-collect.timer → /lib/systemd/system/sysstat-collect.timer.\r\r\nCreated symlink /etc/systemd/system/sysstat.service.wants/sysstat-summary.timer → /lib/systemd/system/sysstat-summary.timer.\r\r\nCreated symlink /etc/systemd/system/multi-user.target.wants/sysstat.service → /lib/systemd/system/sysstat.service.\r\r\nSetting up libperl5.36:arm64 (5.36.0-7+deb12u3) ...\r\nSetting up iso-codes (4.15.0-1) ...\r\nSetting up libonig5:arm64 (6.9.8-1) ...\r\nSetting up libpolkit-gobject-1-0:arm64 (122-3) ...\r\nSetting up libgstreamer1.0-0:arm64 (1.22.0-2+deb12u1) ...\r\nSetcap worked! gst-ptp-helper is not suid!\r\nSetting up libjq1:arm64 (1.6-2.1+deb12u1) ...\r\nSetting up sshfs (3.7.3-1.1) ...\r\nSetting up libappstream4:arm64 (0.16.1-2) ...\r\nSetting up nfs-common (1:2.6.2-4+deb12u1) ...\r\n\r\nCreating config file /etc/idmapd.conf with new version\r\n\r\nCreating config file /etc/nfs.conf with new version\r\nAdding system user `statd' (UID 103) ...\r\nAdding new user `statd' (UID 103) with group `nogroup' ...\r\nNot creating home directory `/var/lib/nfs'.\r\nCreated symlink /etc/systemd/system/multi-user.target.wants/nfs-client.target → /lib/systemd/system/nfs-client.target.\r\r\nCreated symlink /etc/systemd/system/remote-fs.target.wants/nfs-client.target → /lib/systemd/system/nfs-client.target.\r\r\nauth-rpcgss-module.service is a disabled or a static unit, not starting it.\r\nnfs-idmapd.service is a disabled or a static unit, not starting it.\r\nnfs-utils.service is a disabled or a static unit, not starting it.\r\nproc-fs-nfsd.mount is a disabled or a static unit, not starting it.\r\nrpc-gssd.service is a disabled or a static unit, not starting it.\r\nrpc-statd-notify.service is a disabled or a static unit, not starting it.\r\nrpc-statd.service is a disabled or a static unit, not starting it.\r\nrpc-svcgssd.service is a disabled or a static unit, not starting it.\r\nrpc_pipefs.target is a disabled or a static unit, not starting it.\r\nvar-lib-nfs-rpc_pipefs.mount is a disabled or a static unit, not starting it.\r\nSetting up perl (5.36.0-7+deb12u3) ...\r\nSetting up gir1.2-glib-2.0:arm64 (1.74.0-3) ...\r\nSetting up xml-core (0.18+nmu1) ...\r\nSetting up jq (1.6-2.1+deb12u1) ...\r\nSetting up libpolkit-agent-1-0:arm64 (122-3) ...\r\nSetting up liberror-perl (0.17029-2) ...\r\nSetting up gir1.2-packagekitglib-1.0 (1.2.6-5) ...\r\nSetting up python3-gi (3.42.2-3+b1) ...\r\nSetting up git (1:2.39.5-0+deb12u3) ...\r\nSetting up python3-software-properties (0.99.30-4.1~deb12u1) ...\r\nProcessing triggers for initramfs-tools (0.142+deb12u3) ...\r\nupdate-initramfs: Generating /boot/initrd.img-6.1.0-44-arm64\r\nProcessing triggers for libc-bin (2.36-9+deb12u13) ...\r\nProcessing triggers for man-db (2.11.2-2) ...\r\nProcessing triggers for dbus (1.14.10-1~deb12u1) ...\r\nProcessing triggers for sgml-base (1.31) ...\r\nSetting up polkitd (122-3) ...\r\nCreating group 'polkitd' with GID 996.\r\r\nCreating user 'polkitd' (polkit) with UID 996 and GID 996.\r\r\nSetting up network-manager (1.42.4-1+deb12u1) ...\r\nCreated symlink /etc/systemd/system/dbus-org.freedesktop.nm-dispatcher.service → /lib/systemd/system/NetworkManager-dispatcher.service.\r\r\nCreated symlink /etc/systemd/system/network-online.target.wants/NetworkManager-wait-online.service → /lib/systemd/system/NetworkManager-wait-online.service.\r\r\nCreated symlink /etc/systemd/system/multi-user.target.wants/NetworkManager.service → /lib/systemd/system/NetworkManager.service.\r\r\nSetting up packagekit (1.2.6-5) ...\r\nCreated symlink /etc/systemd/user/sockets.target.wants/pk-debconf-helper.socket → /usr/lib/systemd/user/pk-debconf-helper.socket.\r\r\nSetting up software-properties-common (0.99.30-4.1~deb12u1) ...\r\nProcessing triggers for dbus (1.14.10-1~deb12u1) ...\r\nReading package lists...\nBuilding dependency tree...\nReading state information...\n0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.\n", + "error": null, + "tags": { + "component": "os", + "server": "libre-wuji-cp-0", + "type": "component", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/059e4edd-7f6b-4766-85d8-379c9fc92694.json b/crates/data/tasks/059e4edd-7f6b-4766-85d8-379c9fc92694.json new file mode 100644 index 0000000..e1da694 --- /dev/null +++ b/crates/data/tasks/059e4edd-7f6b-4766-85d8-379c9fc92694.json @@ -0,0 +1,23 @@ +{ + "id": "059e4edd-7f6b-4766-85d8-379c9fc92694", + "name": "component_install_os", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_059e4edd-7f6b-4766-85d8-379c9fc92694.tar.gz.b64' > /tmp/os.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/os.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/os && mkdir -p /tmp/os && tar xzf /tmp/os.tar.gz -C /tmp/os && cd /tmp/os && sudo bash install-os.sh install ; rc=$?; rm -f /tmp/os.tar.gz && rm -rf /tmp/os; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_059e4edd-7f6b-4766-85d8-379c9fc92694.tar.gz.b64' /tmp/os.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Failed", + "created_at": "2026-04-21T01:37:11.770013Z", + "started_at": "2026-04-21T01:37:11.879953Z", + "completed_at": "2026-04-21T01:37:12.257125Z", + "output": null, + "error": "Command execution failed: @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\n@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\nIT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!\nSomeone could be eavesdropping on you right now (man-in-the-middle attack)!\nIt is also possible that a host key has just been changed.\nThe fingerprint for the ED25519 key sent by the remote host is\nSHA256:n47ih/YVibQVEAIYpId7Q5YYswXe0aBwPp/FrJrtESY.\nPlease contact your system administrator.\nAdd correct host key in /Users/jesusperezlorenzo/.ssh/known_hosts to get rid of this message.\nOffending ECDSA key in /Users/jesusperezlorenzo/.ssh/known_hosts:228\nHost key for libre-wuji-cp-0 has changed and you have requested strict checking.\nHost key verification failed.\nscp: Connection closed\n", + "tags": { + "type": "component", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "component": "os", + "server": "libre-wuji-cp-0", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/0b72338e-1d92-4370-8ac3-f0c725940c82.json b/crates/data/tasks/0b72338e-1d92-4370-8ac3-f0c725940c82.json new file mode 100644 index 0000000..fbf0c8b --- /dev/null +++ b/crates/data/tasks/0b72338e-1d92-4370-8ac3-f0c725940c82.json @@ -0,0 +1,23 @@ +{ + "id": "0b72338e-1d92-4370-8ac3-f0c725940c82", + "name": "component_install_democratic_csi", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_0b72338e-1d92-4370-8ac3-f0c725940c82.tar.gz.b64' > /tmp/democratic_csi.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/democratic_csi.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/democratic_csi && mkdir -p /tmp/democratic_csi && tar xzf /tmp/democratic_csi.tar.gz -C /tmp/democratic_csi && cd /tmp/democratic_csi && sudo bash install-democratic_csi.sh install ; rc=$?; rm -f /tmp/democratic_csi.tar.gz && rm -rf /tmp/democratic_csi; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_0b72338e-1d92-4370-8ac3-f0c725940c82.tar.gz.b64' /tmp/democratic_csi.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T02:44:06.586118Z", + "started_at": "2026-04-21T02:44:06.727424Z", + "completed_at": "2026-04-21T02:44:10.649693Z", + "output": "=== democratic-csi: v0.14.6 ===\n=== democratic-csi: installing helm ===\nDownloading https://get.helm.sh/helm-v3.20.2-linux-arm64.tar.gz\nVerifying checksum... Done.\nPreparing to install helm into /usr/local/bin\nhelm installed into /usr/local/bin/helm\n=== democratic-csi: nfs_server empty — deferred (namespace only) ===\nnamespace/democratic-csi created\n Set nfs_server to the private IP of the external_nfs node and re-run.\n", + "error": null, + "tags": { + "component": "democratic_csi", + "server": "libre-wuji-cp-0", + "operation": "install", + "type": "component", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji" + } +} \ No newline at end of file diff --git a/crates/data/tasks/0c6642ff-9cfc-4123-9c2d-b185029f02bf.json b/crates/data/tasks/0c6642ff-9cfc-4123-9c2d-b185029f02bf.json new file mode 100644 index 0000000..7240fe6 --- /dev/null +++ b/crates/data/tasks/0c6642ff-9cfc-4123-9c2d-b185029f02bf.json @@ -0,0 +1,17 @@ +{ + "id": "0c6642ff-9cfc-4123-9c2d-b185029f02bf", + "name": "execute_servers_script_libre-wuji-cp-0", + "command": "bash", + "args": [ + "-c", + "base64 -d < /tmp/orchestrator_script_0c6642ff-9cfc-4123-9c2d-b185029f02bf.tar.gz.b64 | gunzip | tar -xOf - script.sh | bash +x" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T00:47:28.933303Z", + "started_at": "2026-04-21T00:47:29.292291Z", + "completed_at": "2026-04-21T00:47:56.758101Z", + "output": "✓ Prerequisites: HCLOUD_TOKEN set, hcloud CLI available\n\n=== Managing SSH Keys ===\n✓ SSH public key found: /Users/jesusperezlorenzo/.ssh/htz_ops.pub\nChecking if SSH key 'htz_ops' exists in Hetzner...\n✓ SSH key htz_ops already exists with ID: 106168627\n\n=== SSH Key Management Complete ===\nSSH_KEY_ID: 106168627\nSSH_KEY_NAME: htz_ops\nEnvironment exported to: /tmp/provisioning-wuji-20260421-014729/.env\n\n=== Managing Network ===\n✓ Network config validated: 10.0.8.0/22 with subnet 10.0.8.0/24 in zone eu-central\nChecking if network 'wuwei' exists...\n✓ Network 'wuwei' already exists with ID: 11875940\n ✓ Subnet 10.0.8.0/24 already present\n\n=== Network Management Complete ===\nNETWORK_ID: 11875940\nNETWORK_NAME: wuwei\nEnvironment exported to: /tmp/provisioning-wuji-20260421-014729/.env\n\u001b[0;34m[INFO]\u001b[0m Step 1/3: Checking server 'libre-wuji-cp-0'...\n\u001b[0;34m[INFO]\u001b[0m Creating server 'libre-wuji-cp-0'...\n\u001b[0;32m[✓]\u001b[0m 127585130 libre-wuji-cp-0 createdIPv4: \n\u001b[0;32m[✓]\u001b[0m Server created: ID=127585130\n\u001b[0;34m[INFO]\u001b[0m Waiting for server 'libre-wuji-cp-0' to reach running state...\n\u001b[0;32m[✓]\u001b[0m Server 'libre-wuji-cp-0' is running\nServer 127585130 attached to Network 11875940\n\u001b[0;32m[✓]\u001b[0m Attached to network wuwei with IP 10.0.8.20\nResource protection enabled for Server 127585130\n\u001b[0;32m[✓]\u001b[0m Protection enabled: delete rebuild\n\u001b[0;34m[INFO]\u001b[0m Step 2/2: Attaching server to firewall 'librecloud-fw'...\nFirewall 10819279 applied to resource\n\u001b[0;32m[✓]\u001b[0m Server attached to firewall 'librecloud-fw'\n\u001b[0;32m[✓]\u001b[0m ✓ Server provisioning complete\n\u001b[0;34m[INFO]\u001b[0m Summary:\n\u001b[0;34m[INFO]\u001b[0m • Server: libre-wuji-cp-0 (ID: 127585130)\n\u001b[0;34m[INFO]\u001b[0m • Firewall: librecloud-fw\n\u001b[0;34m[INFO]\u001b[0m • Status: Ready for deployment\n", + "error": null, + "tags": {} +} \ No newline at end of file diff --git a/crates/data/tasks/0db48326-6bd1-4015-a0b9-5146e842c318.json b/crates/data/tasks/0db48326-6bd1-4015-a0b9-5146e842c318.json new file mode 100644 index 0000000..9d6ee20 --- /dev/null +++ b/crates/data/tasks/0db48326-6bd1-4015-a0b9-5146e842c318.json @@ -0,0 +1,23 @@ +{ + "id": "0db48326-6bd1-4015-a0b9-5146e842c318", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_0db48326-6bd1-4015-a0b9-5146e842c318.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_0db48326-6bd1-4015-a0b9-5146e842c318.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T04:42:26.575558Z", + "started_at": "2026-04-21T04:42:26.914686Z", + "completed_at": "2026-04-21T04:42:39.104765Z", + "output": "", + "error": null, + "tags": { + "server": "libre-wuji-wrk-0", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "type": "component", + "operation": "install", + "component": "kubernetes" + } +} \ No newline at end of file diff --git a/crates/data/tasks/1055cc82-4c97-45ab-9095-871d5a5bf254.json b/crates/data/tasks/1055cc82-4c97-45ab-9095-871d5a5bf254.json new file mode 100644 index 0000000..7b72db7 --- /dev/null +++ b/crates/data/tasks/1055cc82-4c97-45ab-9095-871d5a5bf254.json @@ -0,0 +1,23 @@ +{ + "id": "1055cc82-4c97-45ab-9095-871d5a5bf254", + "name": "component_install_cilium", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_1055cc82-4c97-45ab-9095-871d5a5bf254.tar.gz.b64' > /tmp/cilium.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/cilium.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/cilium && mkdir -p /tmp/cilium && tar xzf /tmp/cilium.tar.gz -C /tmp/cilium && cd /tmp/cilium && sudo bash install-cilium.sh install ; rc=$?; rm -f /tmp/cilium.tar.gz && rm -rf /tmp/cilium; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_1055cc82-4c97-45ab-9095-871d5a5bf254.tar.gz.b64' /tmp/cilium.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Failed", + "created_at": "2026-04-20T11:14:59.966046Z", + "started_at": "2026-04-20T11:15:00.300158Z", + "completed_at": "2026-04-20T11:15:06.758918Z", + "output": null, + "error": "Command execution failed: \nError: Unable to install Cilium: Kubernetes cluster unreachable: Get \"https://10.0.8.20:6443/version\": dial tcp 10.0.8.20:6443: connect: connection refused\n", + "tags": { + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "component": "cilium", + "server": "libre-wuji-cp-0", + "operation": "install", + "type": "component" + } +} \ No newline at end of file diff --git a/crates/data/tasks/12e0f651-c7ea-403d-b82f-84be04f899bd.json b/crates/data/tasks/12e0f651-c7ea-403d-b82f-84be04f899bd.json new file mode 100644 index 0000000..d9d2fb4 --- /dev/null +++ b/crates/data/tasks/12e0f651-c7ea-403d-b82f-84be04f899bd.json @@ -0,0 +1,23 @@ +{ + "id": "12e0f651-c7ea-403d-b82f-84be04f899bd", + "name": "component_install_resolv", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_12e0f651-c7ea-403d-b82f-84be04f899bd.tar.gz.b64' > /tmp/resolv.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/resolv.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/resolv && mkdir -p /tmp/resolv && tar xzf /tmp/resolv.tar.gz -C /tmp/resolv && cd /tmp/resolv && sudo bash install-resolv.sh install ; rc=$?; rm -f /tmp/resolv.tar.gz && rm -rf /tmp/resolv; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_12e0f651-c7ea-403d-b82f-84be04f899bd.tar.gz.b64' /tmp/resolv.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T02:14:25.735155Z", + "started_at": "2026-04-21T02:14:25.900745Z", + "completed_at": "2026-04-21T02:14:28.703065Z", + "output": "", + "error": null, + "tags": { + "type": "component", + "server": "libre-wuji-wrk-0", + "component": "resolv", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/147f51f7-cf97-4952-84da-4e32c8473e8d.json b/crates/data/tasks/147f51f7-cf97-4952-84da-4e32c8473e8d.json new file mode 100644 index 0000000..c9a8123 --- /dev/null +++ b/crates/data/tasks/147f51f7-cf97-4952-84da-4e32c8473e8d.json @@ -0,0 +1,23 @@ +{ + "id": "147f51f7-cf97-4952-84da-4e32c8473e8d", + "name": "component_install_hetzner_csi", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_147f51f7-cf97-4952-84da-4e32c8473e8d.tar.gz.b64' > /tmp/hetzner_csi.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/hetzner_csi.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/hetzner_csi && mkdir -p /tmp/hetzner_csi && tar xzf /tmp/hetzner_csi.tar.gz -C /tmp/hetzner_csi && cd /tmp/hetzner_csi && sudo bash install-hetzner_csi.sh install ; rc=$?; rm -f /tmp/hetzner_csi.tar.gz && rm -rf /tmp/hetzner_csi; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_147f51f7-cf97-4952-84da-4e32c8473e8d.tar.gz.b64' /tmp/hetzner_csi.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T02:26:38.339315Z", + "started_at": "2026-04-21T02:26:38.692203Z", + "completed_at": "2026-04-21T02:26:52.261401Z", + "output": "=== hetzner-csi: checking prerequisites ===\n=== hetzner-csi: deploying v2.9.0 ===\nserviceaccount/hcloud-csi-controller created\nclusterrole.rbac.authorization.k8s.io/hcloud-csi-controller created\nclusterrolebinding.rbac.authorization.k8s.io/hcloud-csi-controller created\nservice/hcloud-csi-controller-metrics created\nservice/hcloud-csi-node-metrics created\ndaemonset.apps/hcloud-csi-node created\ndeployment.apps/hcloud-csi-controller created\ncsidriver.storage.k8s.io/csi.hetzner.cloud created\n=== hetzner-csi: creating StorageClass hcloud-volumes (reclaimPolicy=Retain) ===\nstorageclass.storage.k8s.io/hcloud-volumes created\nstorageclass.storage.k8s.io/hcloud-volumes annotated\n=== hetzner-csi: waiting for controller pod ===\nWaiting for deployment \"hcloud-csi-controller\" rollout to finish: 0 of 1 updated replicas are available...\ndeployment \"hcloud-csi-controller\" successfully rolled out\n=== hetzner-csi: ready (StorageClass=hcloud-volumes, policy=Retain, fs=ext4) ===\n", + "error": null, + "tags": { + "server": "libre-wuji-cp-0", + "operation": "install", + "type": "component", + "component": "hetzner_csi", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji" + } +} \ No newline at end of file diff --git a/crates/data/tasks/17ebdbae-5a51-4bc0-881b-24c168c70396.json b/crates/data/tasks/17ebdbae-5a51-4bc0-881b-24c168c70396.json new file mode 100644 index 0000000..461c90d --- /dev/null +++ b/crates/data/tasks/17ebdbae-5a51-4bc0-881b-24c168c70396.json @@ -0,0 +1,23 @@ +{ + "id": "17ebdbae-5a51-4bc0-881b-24c168c70396", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_17ebdbae-5a51-4bc0-881b-24c168c70396.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_17ebdbae-5a51-4bc0-881b-24c168c70396.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T05:10:57.138024Z", + "started_at": "2026-04-21T05:10:57.371587Z", + "completed_at": "2026-04-21T05:11:09.302706Z", + "output": "", + "error": null, + "tags": { + "operation": "install", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "type": "component", + "component": "kubernetes", + "server": "libre-wuji-wrk-0" + } +} \ No newline at end of file diff --git a/crates/data/tasks/18c5c3f1-82c7-460c-a7c4-463e1c17017f.json b/crates/data/tasks/18c5c3f1-82c7-460c-a7c4-463e1c17017f.json new file mode 100644 index 0000000..aed725e --- /dev/null +++ b/crates/data/tasks/18c5c3f1-82c7-460c-a7c4-463e1c17017f.json @@ -0,0 +1,23 @@ +{ + "id": "18c5c3f1-82c7-460c-a7c4-463e1c17017f", + "name": "component_install_containerd", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_18c5c3f1-82c7-460c-a7c4-463e1c17017f.tar.gz.b64' > /tmp/containerd.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/containerd.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/containerd && mkdir -p /tmp/containerd && tar xzf /tmp/containerd.tar.gz -C /tmp/containerd && cd /tmp/containerd && sudo bash install-containerd.sh install ; rc=$?; rm -f /tmp/containerd.tar.gz && rm -rf /tmp/containerd; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_18c5c3f1-82c7-460c-a7c4-463e1c17017f.tar.gz.b64' /tmp/containerd.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Failed", + "created_at": "2026-04-20T10:28:47.849117Z", + "started_at": "2026-04-20T10:28:48.291233Z", + "completed_at": "2026-04-20T10:28:55.043273Z", + "output": null, + "error": "Command execution failed: tar: Ignoring unknown extended header keyword 'LIBARCHIVE.xattr.com.apple.provenance'\ntar: Ignoring unknown extended header keyword 'LIBARCHIVE.xattr.com.apple.provenance'\ntar: Ignoring unknown extended header keyword 'LIBARCHIVE.xattr.com.apple.provenance'\ntar: Ignoring unknown extended header keyword 'LIBARCHIVE.xattr.com.apple.provenance'\ntar: Ignoring unknown extended header keyword 'LIBARCHIVE.xattr.com.apple.provenance'\ntar: Ignoring unknown extended header keyword 'LIBARCHIVE.xattr.com.apple.provenance'\ntar: Ignoring unknown extended header keyword 'LIBARCHIVE.xattr.com.apple.provenance'\ninstall-containerd.sh: line 42: _common_service_stop: command not found\ninstall-containerd.sh: line 93: _common_service_start: command not found\n", + "tags": { + "type": "component", + "server": "libre-wuji-cp-0", + "component": "containerd", + "operation": "install", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji" + } +} \ No newline at end of file diff --git a/crates/data/tasks/1de586af-a397-47c3-bff4-88e77d138496.json b/crates/data/tasks/1de586af-a397-47c3-bff4-88e77d138496.json new file mode 100644 index 0000000..f63f2ae --- /dev/null +++ b/crates/data/tasks/1de586af-a397-47c3-bff4-88e77d138496.json @@ -0,0 +1,23 @@ +{ + "id": "1de586af-a397-47c3-bff4-88e77d138496", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_1de586af-a397-47c3-bff4-88e77d138496.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_1de586af-a397-47c3-bff4-88e77d138496.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Failed", + "created_at": "2026-04-20T11:01:10.252268Z", + "started_at": "2026-04-20T11:01:10.486569Z", + "completed_at": "2026-04-20T11:01:13.593520Z", + "output": null, + "error": "Command execution failed: resources/kubeadm-config.yaml not found\n", + "tags": { + "component": "kubernetes", + "server": "libre-wuji-cp-0", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install", + "type": "component" + } +} \ No newline at end of file diff --git a/crates/data/tasks/1fd7f217-ea89-442b-8983-e21c1f07b887.json b/crates/data/tasks/1fd7f217-ea89-442b-8983-e21c1f07b887.json new file mode 100644 index 0000000..15db603 --- /dev/null +++ b/crates/data/tasks/1fd7f217-ea89-442b-8983-e21c1f07b887.json @@ -0,0 +1,23 @@ +{ + "id": "1fd7f217-ea89-442b-8983-e21c1f07b887", + "name": "component_install_cilium", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_1fd7f217-ea89-442b-8983-e21c1f07b887.tar.gz.b64' > /tmp/cilium.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/cilium.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/cilium && mkdir -p /tmp/cilium && tar xzf /tmp/cilium.tar.gz -C /tmp/cilium && cd /tmp/cilium && sudo bash install-cilium.sh install ; rc=$?; rm -f /tmp/cilium.tar.gz && rm -rf /tmp/cilium; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_1fd7f217-ea89-442b-8983-e21c1f07b887.tar.gz.b64' /tmp/cilium.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T03:07:22.945422Z", + "started_at": "2026-04-21T03:07:23.115771Z", + "completed_at": "2026-04-21T03:07:48.629717Z", + "output": "=== cilium-cli: installing v0.19.0 ===\ncilium-linux-arm64.tar.gz: OK\n=== cilium: installing v1.19.3 ===\n kube-proxy replacement: enabled (eBPF datapath)\n envoy: disabled (L7 policies unavailable — saves ~200MB RAM on constrained nodes)\nℹ️ Using Cilium version 1.19.3\nℹ️ Using cluster name \"libre-wuji\"\n🔮 Auto-detected kube-proxy has not been installed\nℹ️ Cilium will fully replace all functionalities of kube-proxy\n=== cilium: waiting for status OK ===\n\u001b[33m /¯¯\\\n\u001b[36m /¯¯\u001b[33m\\__/\u001b[32m¯¯\\\u001b[0m Cilium: \u001b[31m1 errors\u001b[0m, \u001b[33m1 warnings\u001b[0m\n\u001b[36m \\__\u001b[31m/¯¯\\\u001b[32m__/\u001b[0m Operator: \u001b[31m1 errors\u001b[0m, \u001b[33m2 warnings\u001b[0m\n\u001b[32m /¯¯\u001b[31m\\__/\u001b[35m¯¯\\\u001b[0m Envoy DaemonSet: \u001b[36mdisabled (using embedded mode)\u001b[0m\n\u001b[32m \\__\u001b[34m/¯¯\\\u001b[35m__/\u001b[0m Hubble Relay: \u001b[36mdisabled\u001b[0m\n\u001b[34m\u001b[34m\u001b[34m \\__/\u001b[0m ClusterMesh: \u001b[36mdisabled\u001b[0m\n\nDaemonSet cilium Desired: 1, Unavailable: \u001b[31m1/1\u001b[0m\nDeployment cilium-operator Desired: 1, Unavailable: \u001b[31m1/1\u001b[0m\nContainers: cilium Pending: \u001b[32m1\u001b[0m\n cilium-operator Pending: \u001b[32m1\u001b[0m\n clustermesh-apiserver \n hubble-relay \nCluster Pods: 0/2 managed by Cilium\nHelm chart version: 1.19.3\nImage versions cilium quay.io/cilium/cilium:v1.19.3@sha256:2e61680593cddca8b6c055f6d4c849d87a26a1c91c7e3b8b56c7fb76ab7b7b10: 1\n cilium-operator quay.io/cilium/operator-generic:v1.19.3@sha256:205b09b0ed6accbf9fe688d312a9f0fcfc6a316fc081c23fbffb472af5dd62cd: 1\nErrors: cilium cilium 1 pods of DaemonSet cilium are not ready\n cilium-operator cilium-operator 1 pods of Deployment cilium-operator are not ready\nWarnings: cilium cilium-9xcht pod is pending\n cilium-operator cilium-operator-68ccfdd8f6-xcrmw pod is pending\n cilium-operator cilium-operator-68ccfdd8f6-xcrmw pod is pending\n\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[33m /¯¯\\\n\u001b[36m /¯¯\u001b[33m\\__/\u001b[32m¯¯\\\u001b[0m Cilium: \u001b[31m1 errors\u001b[0m, \u001b[33m1 warnings\u001b[0m\n\u001b[36m \\__\u001b[31m/¯¯\\\u001b[32m__/\u001b[0m Operator: \u001b[32mOK\u001b[0m\n\u001b[32m /¯¯\u001b[31m\\__/\u001b[35m¯¯\\\u001b[0m Envoy DaemonSet: \u001b[36mdisabled (using embedded mode)\u001b[0m\n\u001b[32m \\__\u001b[34m/¯¯\\\u001b[35m__/\u001b[0m Hubble Relay: \u001b[36mdisabled\u001b[0m\n\u001b[34m\u001b[34m\u001b[34m \\__/\u001b[0m ClusterMesh: \u001b[36mdisabled\u001b[0m\n\nDaemonSet cilium Desired: 1, Unavailable: \u001b[31m1/1\u001b[0m\nDeployment cilium-operator Desired: 1, Ready: \u001b[32m1/1\u001b[0m, Available: \u001b[32m1/1\u001b[0m\nContainers: cilium Pending: \u001b[32m1\u001b[0m\n cilium-operator Running: \u001b[32m1\u001b[0m\n clustermesh-apiserver \n hubble-relay \nCluster Pods: 0/2 managed by Cilium\nHelm chart version: 1.19.3\nImage versions cilium quay.io/cilium/cilium:v1.19.3@sha256:2e61680593cddca8b6c055f6d4c849d87a26a1c91c7e3b8b56c7fb76ab7b7b10: 1\n cilium-operator quay.io/cilium/operator-generic:v1.19.3@sha256:205b09b0ed6accbf9fe688d312a9f0fcfc6a316fc081c23fbffb472af5dd62cd: 1\nErrors: cilium cilium 1 pods of DaemonSet cilium are not ready\nWarnings: cilium cilium-9xcht pod is pending\n\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[33m /¯¯\\\n\u001b[36m /¯¯\u001b[33m\\__/\u001b[32m¯¯\\\u001b[0m Cilium: \u001b[31m1 errors\u001b[0m, \u001b[33m1 warnings\u001b[0m\n\u001b[36m \\__\u001b[31m/¯¯\\\u001b[32m__/\u001b[0m Operator: \u001b[32mOK\u001b[0m\n\u001b[32m /¯¯\u001b[31m\\__/\u001b[35m¯¯\\\u001b[0m Envoy DaemonSet: \u001b[36mdisabled (using embedded mode)\u001b[0m\n\u001b[32m \\__\u001b[34m/¯¯\\\u001b[35m__/\u001b[0m Hubble Relay: \u001b[36mdisabled\u001b[0m\n\u001b[34m\u001b[34m\u001b[34m \\__/\u001b[0m ClusterMesh: \u001b[36mdisabled\u001b[0m\n\nDaemonSet cilium Desired: 1, Unavailable: \u001b[31m1/1\u001b[0m\nDeployment cilium-operator Desired: 1, Ready: \u001b[32m1/1\u001b[0m, Available: \u001b[32m1/1\u001b[0m\nContainers: cilium Pending: \u001b[32m1\u001b[0m\n cilium-operator Running: \u001b[32m1\u001b[0m\n clustermesh-apiserver \n hubble-relay \nCluster Pods: 0/2 managed by Cilium\nHelm chart version: 1.19.3\nImage versions cilium quay.io/cilium/cilium:v1.19.3@sha256:2e61680593cddca8b6c055f6d4c849d87a26a1c91c7e3b8b56c7fb76ab7b7b10: 1\n cilium-operator quay.io/cilium/operator-generic:v1.19.3@sha256:205b09b0ed6accbf9fe688d312a9f0fcfc6a316fc081c23fbffb472af5dd62cd: 1\nErrors: cilium cilium 1 pods of DaemonSet cilium are not ready\nWarnings: cilium cilium-9xcht pod is pending\n\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[33m /¯¯\\\n\u001b[36m /¯¯\u001b[33m\\__/\u001b[32m¯¯\\\u001b[0m Cilium: \u001b[31m3 errors\u001b[0m\n\u001b[36m \\__\u001b[31m/¯¯\\\u001b[32m__/\u001b[0m Operator: \u001b[32mOK\u001b[0m\n\u001b[32m /¯¯\u001b[31m\\__/\u001b[35m¯¯\\\u001b[0m Envoy DaemonSet: \u001b[36mdisabled (using embedded mode)\u001b[0m\n\u001b[32m \\__\u001b[34m/¯¯\\\u001b[35m__/\u001b[0m Hubble Relay: \u001b[36mdisabled\u001b[0m\n\u001b[34m\u001b[34m\u001b[34m \\__/\u001b[0m ClusterMesh: \u001b[36mdisabled\u001b[0m\n\nDaemonSet cilium Desired: 1, Unavailable: \u001b[31m1/1\u001b[0m\nDeployment cilium-operator Desired: 1, Ready: \u001b[32m1/1\u001b[0m, Available: \u001b[32m1/1\u001b[0m\nContainers: cilium Running: \u001b[32m1\u001b[0m\n cilium-operator Running: \u001b[32m1\u001b[0m\n clustermesh-apiserver \n hubble-relay \nCluster Pods: 0/2 managed by Cilium\nHelm chart version: 1.19.3\nImage versions cilium quay.io/cilium/cilium:v1.19.3@sha256:2e61680593cddca8b6c055f6d4c849d87a26a1c91c7e3b8b56c7fb76ab7b7b10: 1\n cilium-operator quay.io/cilium/operator-generic:v1.19.3@sha256:205b09b0ed6accbf9fe688d312a9f0fcfc6a316fc081c23fbffb472af5dd62cd: 1\nErrors: cilium cilium 1 pods of DaemonSet cilium are not ready\n cilium cilium-9xcht unable to retrieve cilium status: command failed (pod=kube-system/cilium-9xcht, container=cilium-agent): command terminated with exit code 1: \"Get \\\"http://localhost/v1/healthz\\\": dial unix /var/run/cilium/cilium.sock: connect: no such file or directory\\nIs the agent running?\\n\"\n cilium cilium-9xcht unable to retrieve cilium endpoint information: command failed (pod=kube-system/cilium-9xcht, container=cilium-agent): command terminated with exit code 1: \"Error: cannot get endpoint list: Get \\\"http://localhost/v1/endpoint\\\": dial unix /var/run/cilium/cilium.sock: connect: no such file or directory\\nIs the agent running?\\n\\n\"\n\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[33m /¯¯\\\n\u001b[36m /¯¯\u001b[33m\\__/\u001b[32m¯¯\\\u001b[0m Cilium: \u001b[31m3 errors\u001b[0m\n\u001b[36m \\__\u001b[31m/¯¯\\\u001b[32m__/\u001b[0m Operator: \u001b[32mOK\u001b[0m\n\u001b[32m /¯¯\u001b[31m\\__/\u001b[35m¯¯\\\u001b[0m Envoy DaemonSet: \u001b[36mdisabled (using embedded mode)\u001b[0m\n\u001b[32m \\__\u001b[34m/¯¯\\\u001b[35m__/\u001b[0m Hubble Relay: \u001b[36mdisabled\u001b[0m\n\u001b[34m\u001b[34m\u001b[34m \\__/\u001b[0m ClusterMesh: \u001b[36mdisabled\u001b[0m\n\nDaemonSet cilium Desired: 1, Unavailable: \u001b[31m1/1\u001b[0m\nDeployment cilium-operator Desired: 1, Ready: \u001b[32m1/1\u001b[0m, Available: \u001b[32m1/1\u001b[0m\nContainers: cilium Running: \u001b[32m1\u001b[0m\n cilium-operator Running: \u001b[32m1\u001b[0m\n clustermesh-apiserver \n hubble-relay \nCluster Pods: 0/2 managed by Cilium\nHelm chart version: 1.19.3\nImage versions cilium quay.io/cilium/cilium:v1.19.3@sha256:2e61680593cddca8b6c055f6d4c849d87a26a1c91c7e3b8b56c7fb76ab7b7b10: 1\n cilium-operator quay.io/cilium/operator-generic:v1.19.3@sha256:205b09b0ed6accbf9fe688d312a9f0fcfc6a316fc081c23fbffb472af5dd62cd: 1\nErrors: cilium cilium 1 pods of DaemonSet cilium are not ready\n cilium cilium-9xcht unable to retrieve cilium status: command failed (pod=kube-system/cilium-9xcht, container=cilium-agent): command terminated with exit code 1: \"Get \\\"http://localhost/v1/healthz\\\": dial unix /var/run/cilium/cilium.sock: connect: no such file or directory\\nIs the agent running?\\n\"\n cilium cilium-9xcht unable to retrieve cilium endpoint information: command failed (pod=kube-system/cilium-9xcht, container=cilium-agent): command terminated with exit code 1: \"Error: cannot get endpoint list: Get \\\"http://localhost/v1/endpoint\\\": dial unix /var/run/cilium/cilium.sock: connect: no such file or directory\\nIs the agent running?\\n\\n\"\n\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[33m /¯¯\\\n\u001b[36m /¯¯\u001b[33m\\__/\u001b[32m¯¯\\\u001b[0m Cilium: \u001b[31m1 errors\u001b[0m, \u001b[33m1 warnings\u001b[0m\n\u001b[36m \\__\u001b[31m/¯¯\\\u001b[32m__/\u001b[0m Operator: \u001b[32mOK\u001b[0m\n\u001b[32m /¯¯\u001b[31m\\__/\u001b[35m¯¯\\\u001b[0m Envoy DaemonSet: \u001b[36mdisabled (using embedded mode)\u001b[0m\n\u001b[32m \\__\u001b[34m/¯¯\\\u001b[35m__/\u001b[0m Hubble Relay: \u001b[36mdisabled\u001b[0m\n\u001b[34m\u001b[34m\u001b[34m \\__/\u001b[0m ClusterMesh: \u001b[36mdisabled\u001b[0m\n\nDaemonSet cilium Desired: 1, Unavailable: \u001b[31m1/1\u001b[0m\nDeployment cilium-operator Desired: 1, Ready: \u001b[32m1/1\u001b[0m, Available: \u001b[32m1/1\u001b[0m\nContainers: cilium Running: \u001b[32m1\u001b[0m\n cilium-operator Running: \u001b[32m1\u001b[0m\n clustermesh-apiserver \n hubble-relay \nCluster Pods: 0/2 managed by Cilium\nHelm chart version: 1.19.3\nImage versions cilium quay.io/cilium/cilium:v1.19.3@sha256:2e61680593cddca8b6c055f6d4c849d87a26a1c91c7e3b8b56c7fb76ab7b7b10: 1\n cilium-operator quay.io/cilium/operator-generic:v1.19.3@sha256:205b09b0ed6accbf9fe688d312a9f0fcfc6a316fc081c23fbffb472af5dd62cd: 1\nErrors: cilium cilium 1 pods of DaemonSet cilium are not ready\nWarnings: cilium cilium-9xcht 1 endpoints are not ready\n\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[33m /¯¯\\\n\u001b[36m /¯¯\u001b[33m\\__/\u001b[32m¯¯\\\u001b[0m Cilium: \u001b[33m1 warnings\u001b[0m\n\u001b[36m \\__\u001b[31m/¯¯\\\u001b[32m__/\u001b[0m Operator: \u001b[32mOK\u001b[0m\n\u001b[32m /¯¯\u001b[31m\\__/\u001b[35m¯¯\\\u001b[0m Envoy DaemonSet: \u001b[36mdisabled (using embedded mode)\u001b[0m\n\u001b[32m \\__\u001b[34m/¯¯\\\u001b[35m__/\u001b[0m Hubble Relay: \u001b[36mdisabled\u001b[0m\n\u001b[34m\u001b[34m\u001b[34m \\__/\u001b[0m ClusterMesh: \u001b[36mdisabled\u001b[0m\n\nDaemonSet cilium Desired: 1, Ready: \u001b[32m1/1\u001b[0m, Available: \u001b[32m1/1\u001b[0m\nDeployment cilium-operator Desired: 1, Ready: \u001b[32m1/1\u001b[0m, Available: \u001b[32m1/1\u001b[0m\nContainers: cilium Running: \u001b[32m1\u001b[0m\n cilium-operator Running: \u001b[32m1\u001b[0m\n clustermesh-apiserver \n hubble-relay \nCluster Pods: 2/2 managed by Cilium\nHelm chart version: 1.19.3\nImage versions cilium quay.io/cilium/cilium:v1.19.3@sha256:2e61680593cddca8b6c055f6d4c849d87a26a1c91c7e3b8b56c7fb76ab7b7b10: 1\n cilium-operator quay.io/cilium/operator-generic:v1.19.3@sha256:205b09b0ed6accbf9fe688d312a9f0fcfc6a316fc081c23fbffb472af5dd62cd: 1\nWarnings: cilium cilium-9xcht 3 endpoints are not ready\n\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[33m /¯¯\\\n\u001b[36m /¯¯\u001b[33m\\__/\u001b[32m¯¯\\\u001b[0m Cilium: \u001b[33m1 warnings\u001b[0m\n\u001b[36m \\__\u001b[31m/¯¯\\\u001b[32m__/\u001b[0m Operator: \u001b[32mOK\u001b[0m\n\u001b[32m /¯¯\u001b[31m\\__/\u001b[35m¯¯\\\u001b[0m Envoy DaemonSet: \u001b[36mdisabled (using embedded mode)\u001b[0m\n\u001b[32m \\__\u001b[34m/¯¯\\\u001b[35m__/\u001b[0m Hubble Relay: \u001b[36mdisabled\u001b[0m\n\u001b[34m\u001b[34m\u001b[34m \\__/\u001b[0m ClusterMesh: \u001b[36mdisabled\u001b[0m\n\nDaemonSet cilium Desired: 1, Ready: \u001b[32m1/1\u001b[0m, Available: \u001b[32m1/1\u001b[0m\nDeployment cilium-operator Desired: 1, Ready: \u001b[32m1/1\u001b[0m, Available: \u001b[32m1/1\u001b[0m\nContainers: cilium Running: \u001b[32m1\u001b[0m\n cilium-operator Running: \u001b[32m1\u001b[0m\n clustermesh-apiserver \n hubble-relay \nCluster Pods: 2/2 managed by Cilium\nHelm chart version: 1.19.3\nImage versions cilium quay.io/cilium/cilium:v1.19.3@sha256:2e61680593cddca8b6c055f6d4c849d87a26a1c91c7e3b8b56c7fb76ab7b7b10: 1\n cilium-operator quay.io/cilium/operator-generic:v1.19.3@sha256:205b09b0ed6accbf9fe688d312a9f0fcfc6a316fc081c23fbffb472af5dd62cd: 1\nWarnings: cilium cilium-9xcht 3 endpoints are not ready\n\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[33m /¯¯\\\n\u001b[36m /¯¯\u001b[33m\\__/\u001b[32m¯¯\\\u001b[0m Cilium: \u001b[32mOK\u001b[0m\n\u001b[36m \\__\u001b[31m/¯¯\\\u001b[32m__/\u001b[0m Operator: \u001b[32mOK\u001b[0m\n\u001b[32m /¯¯\u001b[31m\\__/\u001b[35m¯¯\\\u001b[0m Envoy DaemonSet: \u001b[36mdisabled (using embedded mode)\u001b[0m\n\u001b[32m \\__\u001b[34m/¯¯\\\u001b[35m__/\u001b[0m Hubble Relay: \u001b[36mdisabled\u001b[0m\n\u001b[34m\u001b[34m\u001b[34m \\__/\u001b[0m ClusterMesh: \u001b[36mdisabled\u001b[0m\n\nDaemonSet cilium Desired: 1, Ready: \u001b[32m1/1\u001b[0m, Available: \u001b[32m1/1\u001b[0m\nDeployment cilium-operator Desired: 1, Ready: \u001b[32m1/1\u001b[0m, Available: \u001b[32m1/1\u001b[0m\nContainers: cilium Running: \u001b[32m1\u001b[0m\n cilium-operator Running: \u001b[32m1\u001b[0m\n clustermesh-apiserver \n hubble-relay \nCluster Pods: 2/2 managed by Cilium\nHelm chart version: 1.19.3\nImage versions cilium quay.io/cilium/cilium:v1.19.3@sha256:2e61680593cddca8b6c055f6d4c849d87a26a1c91c7e3b8b56c7fb76ab7b7b10: 1\n cilium-operator quay.io/cilium/operator-generic:v1.19.3@sha256:205b09b0ed6accbf9fe688d312a9f0fcfc6a316fc081c23fbffb472af5dd62cd: 1\n=== cilium: ready ===\n", + "error": null, + "tags": { + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "component": "cilium", + "operation": "install", + "server": "libre-wuji-cp-0", + "type": "component" + } +} \ No newline at end of file diff --git a/crates/data/tasks/21a41f63-1a83-4178-8e4a-82211f07883c.json b/crates/data/tasks/21a41f63-1a83-4178-8e4a-82211f07883c.json new file mode 100644 index 0000000..9b68d35 --- /dev/null +++ b/crates/data/tasks/21a41f63-1a83-4178-8e4a-82211f07883c.json @@ -0,0 +1,23 @@ +{ + "id": "21a41f63-1a83-4178-8e4a-82211f07883c", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_21a41f63-1a83-4178-8e4a-82211f07883c.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_21a41f63-1a83-4178-8e4a-82211f07883c.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-20T11:10:47.927467Z", + "started_at": "2026-04-20T11:10:48.320641Z", + "completed_at": "2026-04-20T11:11:21.080176Z", + "output": "[init] Using Kubernetes version: v1.35.3\n[preflight] Running pre-flight checks\n[preflight] Pulling images required for setting up a Kubernetes cluster\n[preflight] This might take a minute or two, depending on the speed of your internet connection\n[preflight] You can also perform this action beforehand using 'kubeadm config images pull'\n[certs] Using certificateDir folder \"/etc/kubernetes/pki\"\n[certs] Generating \"ca\" certificate and key\n[certs] Generating \"apiserver\" certificate and key\n[certs] apiserver serving cert is signed for DNS names [kubernetes kubernetes.default kubernetes.default.svc kubernetes.default.svc.libre-wuji.local libre-wuji libre-wuji-cp-0] and IPs [10.96.0.1 10.0.8.20 127.0.0.1]\n[certs] Generating \"apiserver-kubelet-client\" certificate and key\n[certs] Generating \"front-proxy-ca\" certificate and key\n[certs] Generating \"front-proxy-client\" certificate and key\n[certs] Generating \"etcd/ca\" certificate and key\n[certs] Generating \"etcd/server\" certificate and key\n[certs] etcd/server serving cert is signed for DNS names [libre-wuji-cp-0 localhost] and IPs [10.0.8.20 127.0.0.1 ::1]\n[certs] Generating \"etcd/peer\" certificate and key\n[certs] etcd/peer serving cert is signed for DNS names [libre-wuji-cp-0 localhost] and IPs [10.0.8.20 127.0.0.1 ::1]\n[certs] Generating \"etcd/healthcheck-client\" certificate and key\n[certs] Generating \"apiserver-etcd-client\" certificate and key\n[certs] Generating \"sa\" key and public key\n[kubeconfig] Using kubeconfig folder \"/etc/kubernetes\"\n[kubeconfig] Writing \"admin.conf\" kubeconfig file\n[kubeconfig] Writing \"super-admin.conf\" kubeconfig file\n[kubeconfig] Writing \"kubelet.conf\" kubeconfig file\n[kubeconfig] Writing \"controller-manager.conf\" kubeconfig file\n[kubeconfig] Writing \"scheduler.conf\" kubeconfig file\n[etcd] Creating static Pod manifest for local etcd in \"/etc/kubernetes/manifests\"\n[control-plane] Using manifest folder \"/etc/kubernetes/manifests\"\n[control-plane] Creating static Pod manifest for \"kube-apiserver\"\n[control-plane] Creating static Pod manifest for \"kube-controller-manager\"\n[control-plane] Creating static Pod manifest for \"kube-scheduler\"\n[kubelet-start] Writing kubelet environment file with flags to file \"/var/lib/kubelet/kubeadm-flags.env\"\n[kubelet-start] Writing kubelet configuration to file \"/var/lib/kubelet/instance-config.yaml\"\n[patches] Applied patch of type \"application/strategic-merge-patch+json\" to target \"kubeletconfiguration\"\n[kubelet-start] Writing kubelet configuration to file \"/var/lib/kubelet/config.yaml\"\n[kubelet-start] Starting the kubelet\n[wait-control-plane] Waiting for the kubelet to boot up the control plane as static Pods from directory \"/etc/kubernetes/manifests\"\n[kubelet-check] Waiting for a healthy kubelet at http://127.0.0.1:10248/healthz. This can take up to 4m0s\n[kubelet-check] The kubelet is healthy after 502.217497ms\n[control-plane-check] Waiting for healthy control plane components. This can take up to 4m0s\n[control-plane-check] Checking kube-apiserver at https://10.0.8.20:6443/livez\n[control-plane-check] Checking kube-controller-manager at https://127.0.0.1:10257/healthz\n[control-plane-check] Checking kube-scheduler at https://127.0.0.1:10259/livez\n[control-plane-check] kube-controller-manager is healthy after 2.009167026s\n[control-plane-check] kube-scheduler is healthy after 3.348718443s\n[control-plane-check] kube-apiserver is healthy after 5.002585561s\n[upload-config] Storing the configuration used in ConfigMap \"kubeadm-config\" in the \"kube-system\" Namespace\n[kubelet] Creating a ConfigMap \"kubelet-config\" in namespace kube-system with the configuration for the kubelets in the cluster\n[upload-certs] Skipping phase. Please see --upload-certs\n[mark-control-plane] Marking the node libre-wuji-cp-0 as control-plane by adding the labels: [node-role.kubernetes.io/control-plane node.kubernetes.io/exclude-from-external-load-balancers]\n[bootstrap-token] Using token: ej78z5.bdjagm3azfmht6wx\n[bootstrap-token] Configuring bootstrap tokens, cluster-info ConfigMap, RBAC Roles\n[bootstrap-token] Configured RBAC rules to allow Node Bootstrap tokens to get nodes\n[bootstrap-token] Configured RBAC rules to allow Node Bootstrap tokens to post CSRs in order for nodes to get long term certificate credentials\n[bootstrap-token] Configured RBAC rules to allow the csrapprover controller automatically approve CSRs from a Node Bootstrap Token\n[bootstrap-token] Configured RBAC rules to allow certificate rotation for all node client certificates in the cluster\n[bootstrap-token] Creating the \"cluster-info\" ConfigMap in the \"kube-public\" namespace\n[kubelet-finalize] Updating \"/etc/kubernetes/kubelet.conf\" to point to a rotatable kubelet client certificate and key\n[addons] Applied essential addon: CoreDNS\n\nYour Kubernetes control-plane has initialized successfully!\n\nTo start using your cluster, you need to run the following as a regular user:\n\n mkdir -p $HOME/.kube\n sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config\n sudo chown $(id -u):$(id -g) $HOME/.kube/config\n\nAlternatively, if you are the root user, you can run:\n\n export KUBECONFIG=/etc/kubernetes/admin.conf\n\nYou should now deploy a pod network to the cluster.\nRun \"kubectl apply -f [podnetwork].yaml\" with one of the options listed at:\n https://kubernetes.io/docs/concepts/cluster-administration/addons/\n\nYou can now join any number of control-plane nodes by copying certificate authorities\nand service account keys on each node and then running the following as root:\n\n kubeadm join 10.0.8.20:6443 --token ej78z5.bdjagm3azfmht6wx \\\n\t--discovery-token-ca-cert-hash sha256:853660b1bcad68847ca1d6c7f63a96d3df47b1fd7d6ba54395f4cdd07e05b3cb \\\n\t--control-plane \n\nThen you can join any number of worker nodes by running the following on each as root:\n\nkubeadm join 10.0.8.20:6443 --token ej78z5.bdjagm3azfmht6wx \\\n\t--discovery-token-ca-cert-hash sha256:853660b1bcad68847ca1d6c7f63a96d3df47b1fd7d6ba54395f4cdd07e05b3cb \nprobes patched\n2026_04_20_111110 | apiserver probes patched: startup=300s liveness=120s readiness=15s\n2026_04_20_111110 | etcd endpoints reordered: https://127.0.0.1:2379,https://127.0.0.1:2379\nUpdating certificates in /etc/ssl/certs...\n1 added, 0 removed; done.\nRunning hooks in /etc/ca-certificates/update.d...\ndone.\n[addons] Applied essential addon: CoreDNS\nruntimeclass.node.k8s.io/runc created\n2026_04_20_111120 | Waiting for RBAC bootstrap to complete...\n2026_04_20_111120 | RBAC bootstrap complete (attempt 1)\n", + "error": null, + "tags": { + "server": "libre-wuji-cp-0", + "operation": "install", + "type": "component", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "component": "kubernetes" + } +} \ No newline at end of file diff --git a/crates/data/tasks/21a7ed02-b4f0-4f09-98cc-8406a98cd0de.json b/crates/data/tasks/21a7ed02-b4f0-4f09-98cc-8406a98cd0de.json new file mode 100644 index 0000000..19f1bd2 --- /dev/null +++ b/crates/data/tasks/21a7ed02-b4f0-4f09-98cc-8406a98cd0de.json @@ -0,0 +1,23 @@ +{ + "id": "21a7ed02-b4f0-4f09-98cc-8406a98cd0de", + "name": "component_install_external_nfs", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_21a7ed02-b4f0-4f09-98cc-8406a98cd0de.tar.gz.b64' > /tmp/external_nfs.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/external_nfs.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/external_nfs && mkdir -p /tmp/external_nfs && tar xzf /tmp/external_nfs.tar.gz -C /tmp/external_nfs && cd /tmp/external_nfs && sudo bash install-external_nfs.sh install ; rc=$?; rm -f /tmp/external_nfs.tar.gz && rm -rf /tmp/external_nfs; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_21a7ed02-b4f0-4f09-98cc-8406a98cd0de.tar.gz.b64' /tmp/external_nfs.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Failed", + "created_at": "2026-04-21T02:43:42.534304Z", + "started_at": "2026-04-21T02:43:42.637603Z", + "completed_at": "2026-04-21T02:43:45.247118Z", + "output": null, + "error": "Command execution failed: bash: install-external_nfs.sh: No such file or directory\n", + "tags": { + "server": "libre-wuji-cp-0", + "type": "component", + "operation": "install", + "component": "external_nfs", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji" + } +} \ No newline at end of file diff --git a/crates/data/tasks/21d299bc-3605-467b-bc88-54694ce92eca.json b/crates/data/tasks/21d299bc-3605-467b-bc88-54694ce92eca.json new file mode 100644 index 0000000..36b063c --- /dev/null +++ b/crates/data/tasks/21d299bc-3605-467b-bc88-54694ce92eca.json @@ -0,0 +1,23 @@ +{ + "id": "21d299bc-3605-467b-bc88-54694ce92eca", + "name": "component_install_os", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_21d299bc-3605-467b-bc88-54694ce92eca.tar.gz.b64' > /tmp/os.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/os.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/os && mkdir -p /tmp/os && tar xzf /tmp/os.tar.gz -C /tmp/os && cd /tmp/os && sudo bash install-os.sh install ; rc=$?; rm -f /tmp/os.tar.gz && rm -rf /tmp/os; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_21d299bc-3605-467b-bc88-54694ce92eca.tar.gz.b64' /tmp/os.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T01:55:04.220548Z", + "started_at": "2026-04-21T01:55:04.481214Z", + "completed_at": "2026-04-21T01:55:11.473780Z", + "output": "APT::Get::Update::SourceListWarnings::NonFreeFirmware \"false\";\nHit:1 http://mirror.hetzner.com/debian/packages bookworm InRelease\nHit:2 http://mirror.hetzner.com/debian/packages bookworm-backports InRelease\nHit:3 http://mirror.hetzner.com/debian/packages bookworm-updates InRelease\nHit:4 http://mirror.hetzner.com/debian/security bookworm-security InRelease\nHit:5 http://deb.debian.org/debian bookworm InRelease\nHit:6 http://security.debian.org/debian-security bookworm-security InRelease\nHit:7 http://deb.debian.org/debian bookworm-updates InRelease\nReading package lists...\nReading package lists...\nBuilding dependency tree...\nReading state information...\nCalculating upgrade...\nThe following packages have been kept back:\n linux-image-arm64\n0 upgraded, 0 newly installed, 0 to remove and 1 not upgraded.\nReading package lists...\nBuilding dependency tree...\nReading state information...\n0 upgraded, 0 newly installed, 0 to remove and 1 not upgraded.\n", + "error": null, + "tags": { + "component": "os", + "server": "libre-wuji-wrk-0", + "type": "component", + "operation": "install", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji" + } +} \ No newline at end of file diff --git a/crates/data/tasks/24c4352d-ab7c-4756-95a4-8b9c23712d71.json b/crates/data/tasks/24c4352d-ab7c-4756-95a4-8b9c23712d71.json new file mode 100644 index 0000000..070814b --- /dev/null +++ b/crates/data/tasks/24c4352d-ab7c-4756-95a4-8b9c23712d71.json @@ -0,0 +1,17 @@ +{ + "id": "24c4352d-ab7c-4756-95a4-8b9c23712d71", + "name": "execute_servers_script_libre-wuji-wrk-0", + "command": "bash", + "args": [ + "-c", + "base64 -d < /tmp/orchestrator_script_24c4352d-ab7c-4756-95a4-8b9c23712d71.tar.gz.b64 | gunzip | tar -xOf - script.sh | bash +x" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T00:42:51.560222Z", + "started_at": "2026-04-21T00:42:51.712428Z", + "completed_at": "2026-04-21T00:43:34.296729Z", + "output": "✓ Prerequisites: HCLOUD_TOKEN set, hcloud CLI available\n\n=== Managing SSH Keys ===\n✓ SSH public key found: /Users/jesusperezlorenzo/.ssh/htz_ops.pub\nChecking if SSH key 'htz_ops' exists in Hetzner...\n✓ SSH key htz_ops already exists with ID: 106168627\n\n=== SSH Key Management Complete ===\nSSH_KEY_ID: 106168627\nSSH_KEY_NAME: htz_ops\nEnvironment exported to: /tmp/provisioning-wuji-20260421-014251/.env\n\n=== Managing Network ===\n✓ Network config validated: 10.0.8.0/22 with subnet 10.0.8.0/24 in zone eu-central\nChecking if network 'wuwei' exists...\n✓ Network 'wuwei' already exists with ID: 11875940\n ✓ Subnet 10.0.8.0/24 already present\n\n=== Network Management Complete ===\nNETWORK_ID: 11875940\nNETWORK_NAME: wuwei\nEnvironment exported to: /tmp/provisioning-wuji-20260421-014251/.env\n\u001b[0;34m[INFO]\u001b[0m Step 1/3: Checking server 'libre-wuji-wrk-0'...\n\u001b[0;34m[INFO]\u001b[0m Creating server 'libre-wuji-wrk-0'...\n\u001b[0;32m[✓]\u001b[0m 127584497 libre-wuji-wrk-0 createdIPv4: \n\u001b[0;32m[✓]\u001b[0m Server created: ID=127584497\n\u001b[0;34m[INFO]\u001b[0m Waiting for server 'libre-wuji-wrk-0' to reach running state...\n\u001b[0;32m[✓]\u001b[0m Server 'libre-wuji-wrk-0' is running\nServer 127584497 attached to Network 11875940\n\u001b[0;32m[✓]\u001b[0m Attached to network wuwei with IP 10.0.8.24\nResource protection enabled for Server 127584497\n\u001b[0;32m[✓]\u001b[0m Protection enabled: delete rebuild\n\u001b[0;34m[INFO]\u001b[0m Step 2/2: Attaching server to firewall 'librecloud-fw'...\nFirewall 10819279 applied to resource\n\u001b[0;32m[✓]\u001b[0m Server attached to firewall 'librecloud-fw'\n\u001b[0;34m[INFO]\u001b[0m Step 3/3: Assigning floating IP 'librecloud-fip-mail' to 'libre-wuji-wrk-0'...\nFloating IP 127388996 assigned to Server 127584497\n\u001b[0;32m[✓]\u001b[0m Assigned floating IP 'librecloud-fip-mail' (id=127388996) → libre-wuji-wrk-0 (id=127584497)\n\u001b[0;32m[✓]\u001b[0m ✓ Server provisioning complete\n\u001b[0;34m[INFO]\u001b[0m Summary:\n\u001b[0;34m[INFO]\u001b[0m • Server: libre-wuji-wrk-0 (ID: 127584497)\n\u001b[0;34m[INFO]\u001b[0m • Firewall: librecloud-fw\n\u001b[0;34m[INFO]\u001b[0m • Float IP: librecloud-fip-mail (id=127388996)\n\u001b[0;34m[INFO]\u001b[0m • Status: Ready for deployment\n", + "error": null, + "tags": {} +} \ No newline at end of file diff --git a/crates/data/tasks/29a4a214-c961-4b14-8256-afaf1541ca81.json b/crates/data/tasks/29a4a214-c961-4b14-8256-afaf1541ca81.json new file mode 100644 index 0000000..6ace729 --- /dev/null +++ b/crates/data/tasks/29a4a214-c961-4b14-8256-afaf1541ca81.json @@ -0,0 +1,23 @@ +{ + "id": "29a4a214-c961-4b14-8256-afaf1541ca81", + "name": "component_install_hetzner_csi", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_29a4a214-c961-4b14-8256-afaf1541ca81.tar.gz.b64' > /tmp/hetzner_csi.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/hetzner_csi.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/hetzner_csi && mkdir -p /tmp/hetzner_csi && tar xzf /tmp/hetzner_csi.tar.gz -C /tmp/hetzner_csi && cd /tmp/hetzner_csi && sudo bash install-hetzner_csi.sh install ; rc=$?; rm -f /tmp/hetzner_csi.tar.gz && rm -rf /tmp/hetzner_csi; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_29a4a214-c961-4b14-8256-afaf1541ca81.tar.gz.b64' /tmp/hetzner_csi.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Failed", + "created_at": "2026-04-21T02:16:01.781982Z", + "started_at": "2026-04-21T02:16:02.265091Z", + "completed_at": "2026-04-21T02:16:05.102310Z", + "output": null, + "error": "Command execution failed: ERROR: Secret 'hcloud' not found in kube-system.\n Create it first: kubectl -n kube-system create secret generic hcloud --from-literal=token=\n", + "tags": { + "operation": "install", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "type": "component", + "component": "hetzner_csi", + "server": "libre-wuji-cp-0" + } +} \ No newline at end of file diff --git a/crates/data/tasks/2af968fa-1f9e-44b7-85d6-ea913de2142e.json b/crates/data/tasks/2af968fa-1f9e-44b7-85d6-ea913de2142e.json new file mode 100644 index 0000000..14a9baf --- /dev/null +++ b/crates/data/tasks/2af968fa-1f9e-44b7-85d6-ea913de2142e.json @@ -0,0 +1,23 @@ +{ + "id": "2af968fa-1f9e-44b7-85d6-ea913de2142e", + "name": "component_install_crun", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_2af968fa-1f9e-44b7-85d6-ea913de2142e.tar.gz.b64' > /tmp/crun.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/crun.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/crun && mkdir -p /tmp/crun && tar xzf /tmp/crun.tar.gz -C /tmp/crun && cd /tmp/crun && sudo bash install-crun.sh install ; rc=$?; rm -f /tmp/crun.tar.gz && rm -rf /tmp/crun; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_2af968fa-1f9e-44b7-85d6-ea913de2142e.tar.gz.b64' /tmp/crun.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T01:44:12.211853Z", + "started_at": "2026-04-21T01:44:12.491759Z", + "completed_at": "2026-04-21T01:44:15.870915Z", + "output": "", + "error": null, + "tags": { + "operation": "install", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "server": "libre-wuji-cp-0", + "component": "crun", + "type": "component" + } +} \ No newline at end of file diff --git a/crates/data/tasks/3bc38fc5-9d51-4c44-a761-7c06948606f6.json b/crates/data/tasks/3bc38fc5-9d51-4c44-a761-7c06948606f6.json new file mode 100644 index 0000000..9b105bc --- /dev/null +++ b/crates/data/tasks/3bc38fc5-9d51-4c44-a761-7c06948606f6.json @@ -0,0 +1,23 @@ +{ + "id": "3bc38fc5-9d51-4c44-a761-7c06948606f6", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_3bc38fc5-9d51-4c44-a761-7c06948606f6.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp /tmp/kubernetes.tar.gz 'root@libre-wuji-wkr-0:/tmp/' && ssh 'root@libre-wuji-wkr-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_3bc38fc5-9d51-4c44-a761-7c06948606f6.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Failed", + "created_at": "2026-04-21T03:12:56.664977Z", + "started_at": "2026-04-21T03:12:56.887653Z", + "completed_at": "2026-04-21T03:12:56.927657Z", + "output": null, + "error": "Command execution failed: ssh: Could not resolve hostname libre-wuji-wkr-0: nodename nor servname provided, or not known\nscp: Connection closed\n", + "tags": { + "operation": "install", + "type": "component", + "server": "libre-wuji-wkr-0", + "component": "kubernetes", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji" + } +} \ No newline at end of file diff --git a/crates/data/tasks/3c4b73ce-4ec0-4c63-b8a0-f04408f82c67.json b/crates/data/tasks/3c4b73ce-4ec0-4c63-b8a0-f04408f82c67.json new file mode 100644 index 0000000..d90dedc --- /dev/null +++ b/crates/data/tasks/3c4b73ce-4ec0-4c63-b8a0-f04408f82c67.json @@ -0,0 +1,23 @@ +{ + "id": "3c4b73ce-4ec0-4c63-b8a0-f04408f82c67", + "name": "component_install_hetzner_csi", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_3c4b73ce-4ec0-4c63-b8a0-f04408f82c67.tar.gz.b64' > /tmp/hetzner_csi.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/hetzner_csi.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/hetzner_csi && mkdir -p /tmp/hetzner_csi && tar xzf /tmp/hetzner_csi.tar.gz -C /tmp/hetzner_csi && cd /tmp/hetzner_csi && sudo bash install-hetzner_csi.sh install ; rc=$?; rm -f /tmp/hetzner_csi.tar.gz && rm -rf /tmp/hetzner_csi; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_3c4b73ce-4ec0-4c63-b8a0-f04408f82c67.tar.gz.b64' /tmp/hetzner_csi.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Failed", + "created_at": "2026-04-21T02:23:24.029018Z", + "started_at": "2026-04-21T02:23:24.447791Z", + "completed_at": "2026-04-21T02:23:27.237129Z", + "output": null, + "error": "Command execution failed: ERROR: Secret 'hcloud' not found in kube-system.\n Create it first: kubectl -n kube-system create secret generic hcloud --from-literal=token=\n", + "tags": { + "component": "hetzner_csi", + "type": "component", + "server": "libre-wuji-cp-0", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/3c7369a3-b249-43e3-8559-f7de35dc7fe6.json b/crates/data/tasks/3c7369a3-b249-43e3-8559-f7de35dc7fe6.json new file mode 100644 index 0000000..bbb69ab --- /dev/null +++ b/crates/data/tasks/3c7369a3-b249-43e3-8559-f7de35dc7fe6.json @@ -0,0 +1,23 @@ +{ + "id": "3c7369a3-b249-43e3-8559-f7de35dc7fe6", + "name": "component_install_youki", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_3c7369a3-b249-43e3-8559-f7de35dc7fe6.tar.gz.b64' > /tmp/youki.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/youki.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/youki && mkdir -p /tmp/youki && tar xzf /tmp/youki.tar.gz -C /tmp/youki && cd /tmp/youki && sudo bash install-youki.sh install ; rc=$?; rm -f /tmp/youki.tar.gz && rm -rf /tmp/youki; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_3c7369a3-b249-43e3-8559-f7de35dc7fe6.tar.gz.b64' /tmp/youki.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-20T10:14:12.601474Z", + "started_at": "2026-04-20T10:14:12.933804Z", + "completed_at": "2026-04-20T10:14:16.513877Z", + "output": "", + "error": null, + "tags": { + "component": "youki", + "server": "libre-wuji-wrk-0", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install", + "type": "component" + } +} \ No newline at end of file diff --git a/crates/data/tasks/3cb42c41-866d-428a-887f-341fb816a1b2.json b/crates/data/tasks/3cb42c41-866d-428a-887f-341fb816a1b2.json new file mode 100644 index 0000000..f81f7d5 --- /dev/null +++ b/crates/data/tasks/3cb42c41-866d-428a-887f-341fb816a1b2.json @@ -0,0 +1,23 @@ +{ + "id": "3cb42c41-866d-428a-887f-341fb816a1b2", + "name": "component_install_crun", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_3cb42c41-866d-428a-887f-341fb816a1b2.tar.gz.b64' > /tmp/crun.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/crun.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/crun && mkdir -p /tmp/crun && tar xzf /tmp/crun.tar.gz -C /tmp/crun && cd /tmp/crun && sudo bash install-crun.sh install ; rc=$?; rm -f /tmp/crun.tar.gz && rm -rf /tmp/crun; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_3cb42c41-866d-428a-887f-341fb816a1b2.tar.gz.b64' /tmp/crun.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-20T10:13:45.058247Z", + "started_at": "2026-04-20T10:13:45.327751Z", + "completed_at": "2026-04-20T10:13:48.708291Z", + "output": "", + "error": null, + "tags": { + "component": "crun", + "type": "component", + "operation": "install", + "server": "libre-wuji-wrk-0", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji" + } +} \ No newline at end of file diff --git a/crates/data/tasks/3ed38c0f-9920-4dc8-b746-0b783cbaab0e.json b/crates/data/tasks/3ed38c0f-9920-4dc8-b746-0b783cbaab0e.json new file mode 100644 index 0000000..f86a065 --- /dev/null +++ b/crates/data/tasks/3ed38c0f-9920-4dc8-b746-0b783cbaab0e.json @@ -0,0 +1,23 @@ +{ + "id": "3ed38c0f-9920-4dc8-b746-0b783cbaab0e", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_3ed38c0f-9920-4dc8-b746-0b783cbaab0e.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_3ed38c0f-9920-4dc8-b746-0b783cbaab0e.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T03:28:27.448853Z", + "started_at": "2026-04-21T03:28:27.459746Z", + "completed_at": "2026-04-21T03:28:32.081653Z", + "output": "No k8s_join.sh found\n", + "error": null, + "tags": { + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "server": "libre-wuji-wrk-0", + "type": "component", + "component": "kubernetes", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/3f27a39a-303d-4ef3-a1fb-fdff3b69d530.json b/crates/data/tasks/3f27a39a-303d-4ef3-a1fb-fdff3b69d530.json new file mode 100644 index 0000000..e53def8 --- /dev/null +++ b/crates/data/tasks/3f27a39a-303d-4ef3-a1fb-fdff3b69d530.json @@ -0,0 +1,23 @@ +{ + "id": "3f27a39a-303d-4ef3-a1fb-fdff3b69d530", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_3f27a39a-303d-4ef3-a1fb-fdff3b69d530.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_3f27a39a-303d-4ef3-a1fb-fdff3b69d530.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T03:54:35.730627Z", + "started_at": "2026-04-21T03:54:35.923566Z", + "completed_at": "2026-04-21T03:54:42.449188Z", + "output": "", + "error": null, + "tags": { + "component": "kubernetes", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install", + "server": "libre-wuji-wrk-0", + "type": "component" + } +} \ No newline at end of file diff --git a/crates/data/tasks/43a62145-7402-4a4b-8aa4-76befe884ebd.json b/crates/data/tasks/43a62145-7402-4a4b-8aa4-76befe884ebd.json new file mode 100644 index 0000000..3e408bb --- /dev/null +++ b/crates/data/tasks/43a62145-7402-4a4b-8aa4-76befe884ebd.json @@ -0,0 +1,23 @@ +{ + "id": "43a62145-7402-4a4b-8aa4-76befe884ebd", + "name": "component_reinstall_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_43a62145-7402-4a4b-8aa4-76befe884ebd.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh reinstall ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_43a62145-7402-4a4b-8aa4-76befe884ebd.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T03:04:23.522971Z", + "started_at": "2026-04-21T03:04:23.925109Z", + "completed_at": "2026-04-21T03:04:27.724227Z", + "output": "Warning: probe patch failed\n2026_04_21_030427 | apiserver probes patched: startup=300s liveness=120s readiness=15s\n/etc/kubernetes/admin.conf not found\n", + "error": null, + "tags": { + "component": "kubernetes", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "type": "component", + "server": "libre-wuji-cp-0", + "operation": "reinstall" + } +} \ No newline at end of file diff --git a/crates/data/tasks/44a67063-a05a-4126-9aac-5bb52fc850f9.json b/crates/data/tasks/44a67063-a05a-4126-9aac-5bb52fc850f9.json new file mode 100644 index 0000000..26da24f --- /dev/null +++ b/crates/data/tasks/44a67063-a05a-4126-9aac-5bb52fc850f9.json @@ -0,0 +1,23 @@ +{ + "id": "44a67063-a05a-4126-9aac-5bb52fc850f9", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_44a67063-a05a-4126-9aac-5bb52fc850f9.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_44a67063-a05a-4126-9aac-5bb52fc850f9.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T03:17:07.185465Z", + "started_at": "2026-04-21T03:17:07.354693Z", + "completed_at": "2026-04-21T03:17:16.032753Z", + "output": "No k8s_join.sh found\n", + "error": null, + "tags": { + "server": "libre-wuji-wrk-0", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install", + "type": "component", + "component": "kubernetes" + } +} \ No newline at end of file diff --git a/crates/data/tasks/44cf7c11-770c-4f78-a810-06d4ae105a8e.json b/crates/data/tasks/44cf7c11-770c-4f78-a810-06d4ae105a8e.json new file mode 100644 index 0000000..e58a875 --- /dev/null +++ b/crates/data/tasks/44cf7c11-770c-4f78-a810-06d4ae105a8e.json @@ -0,0 +1,23 @@ +{ + "id": "44cf7c11-770c-4f78-a810-06d4ae105a8e", + "name": "component_install_democratic_csi", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_44cf7c11-770c-4f78-a810-06d4ae105a8e.tar.gz.b64' > /tmp/democratic_csi.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/democratic_csi.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/democratic_csi && mkdir -p /tmp/democratic_csi && tar xzf /tmp/democratic_csi.tar.gz -C /tmp/democratic_csi && cd /tmp/democratic_csi && sudo bash install-democratic_csi.sh install ; rc=$?; rm -f /tmp/democratic_csi.tar.gz && rm -rf /tmp/democratic_csi; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_44cf7c11-770c-4f78-a810-06d4ae105a8e.tar.gz.b64' /tmp/democratic_csi.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T03:13:05.041471Z", + "started_at": "2026-04-21T03:13:05.419506Z", + "completed_at": "2026-04-21T03:13:08.431367Z", + "output": "=== democratic-csi: v0.14.6 ===\n=== democratic-csi: nfs_server empty — deferred (namespace only) ===\nnamespace/democratic-csi unchanged\n Set nfs_server to the private IP of the external_nfs node and re-run.\n", + "error": null, + "tags": { + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "component": "democratic_csi", + "operation": "install", + "type": "component", + "server": "libre-wuji-cp-0" + } +} \ No newline at end of file diff --git a/crates/data/tasks/49b985ef-68a6-4f56-ab2d-20a523411fe8.json b/crates/data/tasks/49b985ef-68a6-4f56-ab2d-20a523411fe8.json new file mode 100644 index 0000000..98329c9 --- /dev/null +++ b/crates/data/tasks/49b985ef-68a6-4f56-ab2d-20a523411fe8.json @@ -0,0 +1,23 @@ +{ + "id": "49b985ef-68a6-4f56-ab2d-20a523411fe8", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_49b985ef-68a6-4f56-ab2d-20a523411fe8.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_49b985ef-68a6-4f56-ab2d-20a523411fe8.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T02:39:51.849617Z", + "started_at": "2026-04-21T02:39:52.264364Z", + "completed_at": "2026-04-21T02:39:55.544779Z", + "output": "No k8s_join.sh found\n", + "error": null, + "tags": { + "component": "kubernetes", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "type": "component", + "server": "libre-wuji-wrk-0", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/4a072d77-e98e-4776-a7cc-2d64c511162b.json b/crates/data/tasks/4a072d77-e98e-4776-a7cc-2d64c511162b.json new file mode 100644 index 0000000..ad199cc --- /dev/null +++ b/crates/data/tasks/4a072d77-e98e-4776-a7cc-2d64c511162b.json @@ -0,0 +1,23 @@ +{ + "id": "4a072d77-e98e-4776-a7cc-2d64c511162b", + "name": "component_install_fip", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_4a072d77-e98e-4776-a7cc-2d64c511162b.tar.gz.b64' > /tmp/fip.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/fip.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/fip && mkdir -p /tmp/fip && tar xzf /tmp/fip.tar.gz -C /tmp/fip && cd /tmp/fip && sudo bash install-fip.sh install ; rc=$?; rm -f /tmp/fip.tar.gz && rm -rf /tmp/fip; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_4a072d77-e98e-4776-a7cc-2d64c511162b.tar.gz.b64' /tmp/fip.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-20T10:10:19.412561Z", + "started_at": "2026-04-20T10:10:19.538669Z", + "completed_at": "2026-04-20T10:10:23.400232Z", + "output": "Written /etc/network/interfaces.d/60-floating-ip\nFloating IP 49.12.115.133 active on eth0\n", + "error": null, + "tags": { + "server": "libre-wuji-wrk-0", + "component": "fip", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install", + "type": "component" + } +} \ No newline at end of file diff --git a/crates/data/tasks/4df21b80-697c-45af-9d00-c13d8a8ae975.json b/crates/data/tasks/4df21b80-697c-45af-9d00-c13d8a8ae975.json new file mode 100644 index 0000000..27437bd --- /dev/null +++ b/crates/data/tasks/4df21b80-697c-45af-9d00-c13d8a8ae975.json @@ -0,0 +1,23 @@ +{ + "id": "4df21b80-697c-45af-9d00-c13d8a8ae975", + "name": "component_install_cilium", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_4df21b80-697c-45af-9d00-c13d8a8ae975.tar.gz.b64' > /tmp/cilium.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/cilium.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/cilium && mkdir -p /tmp/cilium && tar xzf /tmp/cilium.tar.gz -C /tmp/cilium && cd /tmp/cilium && sudo bash install-cilium.sh install ; rc=$?; rm -f /tmp/cilium.tar.gz && rm -rf /tmp/cilium; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_4df21b80-697c-45af-9d00-c13d8a8ae975.tar.gz.b64' /tmp/cilium.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T02:12:45.819421Z", + "started_at": "2026-04-21T02:12:46.025740Z", + "completed_at": "2026-04-21T02:13:19.913455Z", + "output": "=== cilium-cli: installing v0.19.0 ===\ncilium-linux-arm64.tar.gz: OK\n=== cilium: installing v1.19.3 ===\n kube-proxy replacement: enabled (eBPF datapath)\n envoy: disabled (L7 policies unavailable — saves ~200MB RAM on constrained nodes)\nℹ️ Using Cilium version 1.19.3\nℹ️ Using cluster name \"libre-wuji\"\n🔮 Auto-detected kube-proxy has not been installed\nℹ️ Cilium will fully replace all functionalities of kube-proxy\n=== cilium: waiting for status OK ===\n\u001b[33m /¯¯\\\n\u001b[36m /¯¯\u001b[33m\\__/\u001b[32m¯¯\\\u001b[0m Cilium: \u001b[31m1 errors\u001b[0m, \u001b[33m1 warnings\u001b[0m\n\u001b[36m \\__\u001b[31m/¯¯\\\u001b[32m__/\u001b[0m Operator: \u001b[31m1 errors\u001b[0m, \u001b[33m2 warnings\u001b[0m\n\u001b[32m /¯¯\u001b[31m\\__/\u001b[35m¯¯\\\u001b[0m Envoy DaemonSet: \u001b[36mdisabled (using embedded mode)\u001b[0m\n\u001b[32m \\__\u001b[34m/¯¯\\\u001b[35m__/\u001b[0m Hubble Relay: \u001b[36mdisabled\u001b[0m\n\u001b[34m\u001b[34m\u001b[34m \\__/\u001b[0m ClusterMesh: \u001b[36mdisabled\u001b[0m\n\nDaemonSet cilium Desired: 1, Unavailable: \u001b[31m1/1\u001b[0m\nDeployment cilium-operator Desired: 1, Unavailable: \u001b[31m1/1\u001b[0m\nContainers: cilium Pending: \u001b[32m1\u001b[0m\n cilium-operator Pending: \u001b[32m1\u001b[0m\n clustermesh-apiserver \n hubble-relay \nCluster Pods: 0/2 managed by Cilium\nHelm chart version: 1.19.3\nImage versions cilium quay.io/cilium/cilium:v1.19.3@sha256:2e61680593cddca8b6c055f6d4c849d87a26a1c91c7e3b8b56c7fb76ab7b7b10: 1\n cilium-operator quay.io/cilium/operator-generic:v1.19.3@sha256:205b09b0ed6accbf9fe688d312a9f0fcfc6a316fc081c23fbffb472af5dd62cd: 1\nErrors: cilium cilium 1 pods of DaemonSet cilium are not ready\n cilium-operator cilium-operator 1 pods of Deployment cilium-operator are not ready\nWarnings: cilium cilium-kxfd2 pod is pending\n cilium-operator cilium-operator-68ccfdd8f6-s2l77 pod is pending\n cilium-operator cilium-operator-68ccfdd8f6-s2l77 pod is pending\n\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[33m /¯¯\\\n\u001b[36m /¯¯\u001b[33m\\__/\u001b[32m¯¯\\\u001b[0m Cilium: \u001b[31m1 errors\u001b[0m, \u001b[33m1 warnings\u001b[0m\n\u001b[36m \\__\u001b[31m/¯¯\\\u001b[32m__/\u001b[0m Operator: \u001b[31m1 errors\u001b[0m, \u001b[33m2 warnings\u001b[0m\n\u001b[32m /¯¯\u001b[31m\\__/\u001b[35m¯¯\\\u001b[0m Envoy DaemonSet: \u001b[36mdisabled (using embedded mode)\u001b[0m\n\u001b[32m \\__\u001b[34m/¯¯\\\u001b[35m__/\u001b[0m Hubble Relay: \u001b[36mdisabled\u001b[0m\n\u001b[34m\u001b[34m\u001b[34m \\__/\u001b[0m ClusterMesh: \u001b[36mdisabled\u001b[0m\n\nDaemonSet cilium Desired: 1, Unavailable: \u001b[31m1/1\u001b[0m\nDeployment cilium-operator Desired: 1, Unavailable: \u001b[31m1/1\u001b[0m\nContainers: cilium Pending: \u001b[32m1\u001b[0m\n cilium-operator Pending: \u001b[32m1\u001b[0m\n clustermesh-apiserver \n hubble-relay \nCluster Pods: 0/2 managed by Cilium\nHelm chart version: 1.19.3\nImage versions cilium quay.io/cilium/cilium:v1.19.3@sha256:2e61680593cddca8b6c055f6d4c849d87a26a1c91c7e3b8b56c7fb76ab7b7b10: 1\n cilium-operator quay.io/cilium/operator-generic:v1.19.3@sha256:205b09b0ed6accbf9fe688d312a9f0fcfc6a316fc081c23fbffb472af5dd62cd: 1\nErrors: cilium cilium 1 pods of DaemonSet cilium are not ready\n cilium-operator cilium-operator 1 pods of Deployment cilium-operator are not ready\nWarnings: cilium cilium-kxfd2 pod is pending\n cilium-operator cilium-operator-68ccfdd8f6-s2l77 pod is pending\n cilium-operator cilium-operator-68ccfdd8f6-s2l77 pod is pending\n\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[33m /¯¯\\\n\u001b[36m /¯¯\u001b[33m\\__/\u001b[32m¯¯\\\u001b[0m Cilium: \u001b[31m1 errors\u001b[0m, \u001b[33m1 warnings\u001b[0m\n\u001b[36m \\__\u001b[31m/¯¯\\\u001b[32m__/\u001b[0m Operator: \u001b[31m1 errors\u001b[0m, \u001b[33m2 warnings\u001b[0m\n\u001b[32m /¯¯\u001b[31m\\__/\u001b[35m¯¯\\\u001b[0m Envoy DaemonSet: \u001b[36mdisabled (using embedded mode)\u001b[0m\n\u001b[32m \\__\u001b[34m/¯¯\\\u001b[35m__/\u001b[0m Hubble Relay: \u001b[36mdisabled\u001b[0m\n\u001b[34m\u001b[34m\u001b[34m \\__/\u001b[0m ClusterMesh: \u001b[36mdisabled\u001b[0m\n\nDaemonSet cilium Desired: 1, Unavailable: \u001b[31m1/1\u001b[0m\nDeployment cilium-operator Desired: 1, Unavailable: \u001b[31m1/1\u001b[0m\nContainers: cilium Pending: \u001b[32m1\u001b[0m\n cilium-operator Pending: \u001b[32m1\u001b[0m\n clustermesh-apiserver \n hubble-relay \nCluster Pods: 0/2 managed by Cilium\nHelm chart version: 1.19.3\nImage versions cilium quay.io/cilium/cilium:v1.19.3@sha256:2e61680593cddca8b6c055f6d4c849d87a26a1c91c7e3b8b56c7fb76ab7b7b10: 1\n cilium-operator quay.io/cilium/operator-generic:v1.19.3@sha256:205b09b0ed6accbf9fe688d312a9f0fcfc6a316fc081c23fbffb472af5dd62cd: 1\nErrors: cilium cilium 1 pods of DaemonSet cilium are not ready\n cilium-operator cilium-operator 1 pods of Deployment cilium-operator are not ready\nWarnings: cilium cilium-kxfd2 pod is pending\n cilium-operator cilium-operator-68ccfdd8f6-s2l77 pod is pending\n cilium-operator cilium-operator-68ccfdd8f6-s2l77 pod is pending\n\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[33m /¯¯\\\n\u001b[36m /¯¯\u001b[33m\\__/\u001b[32m¯¯\\\u001b[0m Cilium: \u001b[31m1 errors\u001b[0m, \u001b[33m1 warnings\u001b[0m\n\u001b[36m \\__\u001b[31m/¯¯\\\u001b[32m__/\u001b[0m Operator: \u001b[32mOK\u001b[0m\n\u001b[32m /¯¯\u001b[31m\\__/\u001b[35m¯¯\\\u001b[0m Envoy DaemonSet: \u001b[36mdisabled (using embedded mode)\u001b[0m\n\u001b[32m \\__\u001b[34m/¯¯\\\u001b[35m__/\u001b[0m Hubble Relay: \u001b[36mdisabled\u001b[0m\n\u001b[34m\u001b[34m\u001b[34m \\__/\u001b[0m ClusterMesh: \u001b[36mdisabled\u001b[0m\n\nDaemonSet cilium Desired: 1, Unavailable: \u001b[31m1/1\u001b[0m\nDeployment cilium-operator Desired: 1, Ready: \u001b[32m1/1\u001b[0m, Available: \u001b[32m1/1\u001b[0m\nContainers: cilium Pending: \u001b[32m1\u001b[0m\n cilium-operator Running: \u001b[32m1\u001b[0m\n clustermesh-apiserver \n hubble-relay \nCluster Pods: 0/2 managed by Cilium\nHelm chart version: 1.19.3\nImage versions cilium quay.io/cilium/cilium:v1.19.3@sha256:2e61680593cddca8b6c055f6d4c849d87a26a1c91c7e3b8b56c7fb76ab7b7b10: 1\n cilium-operator quay.io/cilium/operator-generic:v1.19.3@sha256:205b09b0ed6accbf9fe688d312a9f0fcfc6a316fc081c23fbffb472af5dd62cd: 1\nErrors: cilium cilium 1 pods of DaemonSet cilium are not ready\nWarnings: cilium cilium-kxfd2 pod is pending\n\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[33m /¯¯\\\n\u001b[36m /¯¯\u001b[33m\\__/\u001b[32m¯¯\\\u001b[0m Cilium: \u001b[31m1 errors\u001b[0m, \u001b[33m1 warnings\u001b[0m\n\u001b[36m \\__\u001b[31m/¯¯\\\u001b[32m__/\u001b[0m Operator: \u001b[32mOK\u001b[0m\n\u001b[32m /¯¯\u001b[31m\\__/\u001b[35m¯¯\\\u001b[0m Envoy DaemonSet: \u001b[36mdisabled (using embedded mode)\u001b[0m\n\u001b[32m \\__\u001b[34m/¯¯\\\u001b[35m__/\u001b[0m Hubble Relay: \u001b[36mdisabled\u001b[0m\n\u001b[34m\u001b[34m\u001b[34m \\__/\u001b[0m ClusterMesh: \u001b[36mdisabled\u001b[0m\n\nDaemonSet cilium Desired: 1, Unavailable: \u001b[31m1/1\u001b[0m\nDeployment cilium-operator Desired: 1, Ready: \u001b[32m1/1\u001b[0m, Available: \u001b[32m1/1\u001b[0m\nContainers: cilium Pending: \u001b[32m1\u001b[0m\n cilium-operator Running: \u001b[32m1\u001b[0m\n clustermesh-apiserver \n hubble-relay \nCluster Pods: 0/2 managed by Cilium\nHelm chart version: 1.19.3\nImage versions cilium quay.io/cilium/cilium:v1.19.3@sha256:2e61680593cddca8b6c055f6d4c849d87a26a1c91c7e3b8b56c7fb76ab7b7b10: 1\n cilium-operator quay.io/cilium/operator-generic:v1.19.3@sha256:205b09b0ed6accbf9fe688d312a9f0fcfc6a316fc081c23fbffb472af5dd62cd: 1\nErrors: cilium cilium 1 pods of DaemonSet cilium are not ready\nWarnings: cilium cilium-kxfd2 pod is pending\n\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[33m /¯¯\\\n\u001b[36m /¯¯\u001b[33m\\__/\u001b[32m¯¯\\\u001b[0m Cilium: \u001b[31m1 errors\u001b[0m, \u001b[33m1 warnings\u001b[0m\n\u001b[36m \\__\u001b[31m/¯¯\\\u001b[32m__/\u001b[0m Operator: \u001b[32mOK\u001b[0m\n\u001b[32m /¯¯\u001b[31m\\__/\u001b[35m¯¯\\\u001b[0m Envoy DaemonSet: \u001b[36mdisabled (using embedded mode)\u001b[0m\n\u001b[32m \\__\u001b[34m/¯¯\\\u001b[35m__/\u001b[0m Hubble Relay: \u001b[36mdisabled\u001b[0m\n\u001b[34m\u001b[34m\u001b[34m \\__/\u001b[0m ClusterMesh: \u001b[36mdisabled\u001b[0m\n\nDaemonSet cilium Desired: 1, Unavailable: \u001b[31m1/1\u001b[0m\nDeployment cilium-operator Desired: 1, Ready: \u001b[32m1/1\u001b[0m, Available: \u001b[32m1/1\u001b[0m\nContainers: cilium Pending: \u001b[32m1\u001b[0m\n cilium-operator Running: \u001b[32m1\u001b[0m\n clustermesh-apiserver \n hubble-relay \nCluster Pods: 0/2 managed by Cilium\nHelm chart version: 1.19.3\nImage versions cilium quay.io/cilium/cilium:v1.19.3@sha256:2e61680593cddca8b6c055f6d4c849d87a26a1c91c7e3b8b56c7fb76ab7b7b10: 1\n cilium-operator quay.io/cilium/operator-generic:v1.19.3@sha256:205b09b0ed6accbf9fe688d312a9f0fcfc6a316fc081c23fbffb472af5dd62cd: 1\nErrors: cilium cilium 1 pods of DaemonSet cilium are not ready\nWarnings: cilium cilium-kxfd2 pod is pending\n\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[33m /¯¯\\\n\u001b[36m /¯¯\u001b[33m\\__/\u001b[32m¯¯\\\u001b[0m Cilium: \u001b[31m1 errors\u001b[0m, \u001b[33m1 warnings\u001b[0m\n\u001b[36m \\__\u001b[31m/¯¯\\\u001b[32m__/\u001b[0m Operator: \u001b[32mOK\u001b[0m\n\u001b[32m /¯¯\u001b[31m\\__/\u001b[35m¯¯\\\u001b[0m Envoy DaemonSet: \u001b[36mdisabled (using embedded mode)\u001b[0m\n\u001b[32m \\__\u001b[34m/¯¯\\\u001b[35m__/\u001b[0m Hubble Relay: \u001b[36mdisabled\u001b[0m\n\u001b[34m\u001b[34m\u001b[34m \\__/\u001b[0m ClusterMesh: \u001b[36mdisabled\u001b[0m\n\nDaemonSet cilium Desired: 1, Unavailable: \u001b[31m1/1\u001b[0m\nDeployment cilium-operator Desired: 1, Ready: \u001b[32m1/1\u001b[0m, Available: \u001b[32m1/1\u001b[0m\nContainers: cilium Pending: \u001b[32m1\u001b[0m\n cilium-operator Running: \u001b[32m1\u001b[0m\n clustermesh-apiserver \n hubble-relay \nCluster Pods: 0/2 managed by Cilium\nHelm chart version: 1.19.3\nImage versions cilium quay.io/cilium/cilium:v1.19.3@sha256:2e61680593cddca8b6c055f6d4c849d87a26a1c91c7e3b8b56c7fb76ab7b7b10: 1\n cilium-operator quay.io/cilium/operator-generic:v1.19.3@sha256:205b09b0ed6accbf9fe688d312a9f0fcfc6a316fc081c23fbffb472af5dd62cd: 1\nErrors: cilium cilium 1 pods of DaemonSet cilium are not ready\nWarnings: cilium cilium-kxfd2 pod is pending\n\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[33m /¯¯\\\n\u001b[36m /¯¯\u001b[33m\\__/\u001b[32m¯¯\\\u001b[0m Cilium: \u001b[31m1 errors\u001b[0m, \u001b[33m1 warnings\u001b[0m\n\u001b[36m \\__\u001b[31m/¯¯\\\u001b[32m__/\u001b[0m Operator: \u001b[32mOK\u001b[0m\n\u001b[32m /¯¯\u001b[31m\\__/\u001b[35m¯¯\\\u001b[0m Envoy DaemonSet: \u001b[36mdisabled (using embedded mode)\u001b[0m\n\u001b[32m \\__\u001b[34m/¯¯\\\u001b[35m__/\u001b[0m Hubble Relay: \u001b[36mdisabled\u001b[0m\n\u001b[34m\u001b[34m\u001b[34m \\__/\u001b[0m ClusterMesh: \u001b[36mdisabled\u001b[0m\n\nDaemonSet cilium Desired: 1, Unavailable: \u001b[31m1/1\u001b[0m\nDeployment cilium-operator Desired: 1, Ready: \u001b[32m1/1\u001b[0m, Available: \u001b[32m1/1\u001b[0m\nContainers: cilium Pending: \u001b[32m1\u001b[0m\n cilium-operator Running: \u001b[32m1\u001b[0m\n clustermesh-apiserver \n hubble-relay \nCluster Pods: 0/2 managed by Cilium\nHelm chart version: 1.19.3\nImage versions cilium quay.io/cilium/cilium:v1.19.3@sha256:2e61680593cddca8b6c055f6d4c849d87a26a1c91c7e3b8b56c7fb76ab7b7b10: 1\n cilium-operator quay.io/cilium/operator-generic:v1.19.3@sha256:205b09b0ed6accbf9fe688d312a9f0fcfc6a316fc081c23fbffb472af5dd62cd: 1\nErrors: cilium cilium 1 pods of DaemonSet cilium are not ready\nWarnings: cilium cilium-kxfd2 pod is pending\n\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[33m /¯¯\\\n\u001b[36m /¯¯\u001b[33m\\__/\u001b[32m¯¯\\\u001b[0m Cilium: \u001b[31m1 errors\u001b[0m, \u001b[33m1 warnings\u001b[0m\n\u001b[36m \\__\u001b[31m/¯¯\\\u001b[32m__/\u001b[0m Operator: \u001b[32mOK\u001b[0m\n\u001b[32m /¯¯\u001b[31m\\__/\u001b[35m¯¯\\\u001b[0m Envoy DaemonSet: \u001b[36mdisabled (using embedded mode)\u001b[0m\n\u001b[32m \\__\u001b[34m/¯¯\\\u001b[35m__/\u001b[0m Hubble Relay: \u001b[36mdisabled\u001b[0m\n\u001b[34m\u001b[34m\u001b[34m \\__/\u001b[0m ClusterMesh: \u001b[36mdisabled\u001b[0m\n\nDaemonSet cilium Desired: 1, Unavailable: \u001b[31m1/1\u001b[0m\nDeployment cilium-operator Desired: 1, Ready: \u001b[32m1/1\u001b[0m, Available: \u001b[32m1/1\u001b[0m\nContainers: cilium Pending: \u001b[32m1\u001b[0m\n cilium-operator Running: \u001b[32m1\u001b[0m\n clustermesh-apiserver \n hubble-relay \nCluster Pods: 0/2 managed by Cilium\nHelm chart version: 1.19.3\nImage versions cilium quay.io/cilium/cilium:v1.19.3@sha256:2e61680593cddca8b6c055f6d4c849d87a26a1c91c7e3b8b56c7fb76ab7b7b10: 1\n cilium-operator quay.io/cilium/operator-generic:v1.19.3@sha256:205b09b0ed6accbf9fe688d312a9f0fcfc6a316fc081c23fbffb472af5dd62cd: 1\nErrors: cilium cilium 1 pods of DaemonSet cilium are not ready\nWarnings: cilium cilium-kxfd2 pod is pending\n\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[33m /¯¯\\\n\u001b[36m /¯¯\u001b[33m\\__/\u001b[32m¯¯\\\u001b[0m Cilium: \u001b[31m1 errors\u001b[0m, \u001b[33m1 warnings\u001b[0m\n\u001b[36m \\__\u001b[31m/¯¯\\\u001b[32m__/\u001b[0m Operator: \u001b[32mOK\u001b[0m\n\u001b[32m /¯¯\u001b[31m\\__/\u001b[35m¯¯\\\u001b[0m Envoy DaemonSet: \u001b[36mdisabled (using embedded mode)\u001b[0m\n\u001b[32m \\__\u001b[34m/¯¯\\\u001b[35m__/\u001b[0m Hubble Relay: \u001b[36mdisabled\u001b[0m\n\u001b[34m\u001b[34m\u001b[34m \\__/\u001b[0m ClusterMesh: \u001b[36mdisabled\u001b[0m\n\nDaemonSet cilium Desired: 1, Unavailable: \u001b[31m1/1\u001b[0m\nDeployment cilium-operator Desired: 1, Ready: \u001b[32m1/1\u001b[0m, Available: \u001b[32m1/1\u001b[0m\nContainers: cilium Running: \u001b[32m1\u001b[0m\n cilium-operator Running: \u001b[32m1\u001b[0m\n clustermesh-apiserver \n hubble-relay \nCluster Pods: 0/2 managed by Cilium\nHelm chart version: 1.19.3\nImage versions cilium quay.io/cilium/cilium:v1.19.3@sha256:2e61680593cddca8b6c055f6d4c849d87a26a1c91c7e3b8b56c7fb76ab7b7b10: 1\n cilium-operator quay.io/cilium/operator-generic:v1.19.3@sha256:205b09b0ed6accbf9fe688d312a9f0fcfc6a316fc081c23fbffb472af5dd62cd: 1\nErrors: cilium cilium 1 pods of DaemonSet cilium are not ready\nWarnings: cilium cilium-kxfd2 2 endpoints are not ready\n\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[33m /¯¯\\\n\u001b[36m /¯¯\u001b[33m\\__/\u001b[32m¯¯\\\u001b[0m Cilium: \u001b[31m1 errors\u001b[0m, \u001b[33m1 warnings\u001b[0m\n\u001b[36m \\__\u001b[31m/¯¯\\\u001b[32m__/\u001b[0m Operator: \u001b[32mOK\u001b[0m\n\u001b[32m /¯¯\u001b[31m\\__/\u001b[35m¯¯\\\u001b[0m Envoy DaemonSet: \u001b[36mdisabled (using embedded mode)\u001b[0m\n\u001b[32m \\__\u001b[34m/¯¯\\\u001b[35m__/\u001b[0m Hubble Relay: \u001b[36mdisabled\u001b[0m\n\u001b[34m\u001b[34m\u001b[34m \\__/\u001b[0m ClusterMesh: \u001b[36mdisabled\u001b[0m\n\nDaemonSet cilium Desired: 1, Unavailable: \u001b[31m1/1\u001b[0m\nDeployment cilium-operator Desired: 1, Ready: \u001b[32m1/1\u001b[0m, Available: \u001b[32m1/1\u001b[0m\nContainers: cilium Running: \u001b[32m1\u001b[0m\n cilium-operator Running: \u001b[32m1\u001b[0m\n clustermesh-apiserver \n hubble-relay \nCluster Pods: 0/2 managed by Cilium\nHelm chart version: 1.19.3\nImage versions cilium quay.io/cilium/cilium:v1.19.3@sha256:2e61680593cddca8b6c055f6d4c849d87a26a1c91c7e3b8b56c7fb76ab7b7b10: 1\n cilium-operator quay.io/cilium/operator-generic:v1.19.3@sha256:205b09b0ed6accbf9fe688d312a9f0fcfc6a316fc081c23fbffb472af5dd62cd: 1\nErrors: cilium cilium 1 pods of DaemonSet cilium are not ready\nWarnings: cilium cilium-kxfd2 2 endpoints are not ready\n\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[33m /¯¯\\\n\u001b[36m /¯¯\u001b[33m\\__/\u001b[32m¯¯\\\u001b[0m Cilium: \u001b[33m1 warnings\u001b[0m\n\u001b[36m \\__\u001b[31m/¯¯\\\u001b[32m__/\u001b[0m Operator: \u001b[32mOK\u001b[0m\n\u001b[32m /¯¯\u001b[31m\\__/\u001b[35m¯¯\\\u001b[0m Envoy DaemonSet: \u001b[36mdisabled (using embedded mode)\u001b[0m\n\u001b[32m \\__\u001b[34m/¯¯\\\u001b[35m__/\u001b[0m Hubble Relay: \u001b[36mdisabled\u001b[0m\n\u001b[34m\u001b[34m\u001b[34m \\__/\u001b[0m ClusterMesh: \u001b[36mdisabled\u001b[0m\n\nDaemonSet cilium Desired: 1, Ready: \u001b[32m1/1\u001b[0m, Available: \u001b[32m1/1\u001b[0m\nDeployment cilium-operator Desired: 1, Ready: \u001b[32m1/1\u001b[0m, Available: \u001b[32m1/1\u001b[0m\nContainers: cilium Running: \u001b[32m1\u001b[0m\n cilium-operator Running: \u001b[32m1\u001b[0m\n clustermesh-apiserver \n hubble-relay \nCluster Pods: 2/2 managed by Cilium\nHelm chart version: 1.19.3\nImage versions cilium quay.io/cilium/cilium:v1.19.3@sha256:2e61680593cddca8b6c055f6d4c849d87a26a1c91c7e3b8b56c7fb76ab7b7b10: 1\n cilium-operator quay.io/cilium/operator-generic:v1.19.3@sha256:205b09b0ed6accbf9fe688d312a9f0fcfc6a316fc081c23fbffb472af5dd62cd: 1\nWarnings: cilium cilium-kxfd2 4 endpoints are not ready\n\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[A\u001b[2K\u001b[33m /¯¯\\\n\u001b[36m /¯¯\u001b[33m\\__/\u001b[32m¯¯\\\u001b[0m Cilium: \u001b[32mOK\u001b[0m\n\u001b[36m \\__\u001b[31m/¯¯\\\u001b[32m__/\u001b[0m Operator: \u001b[32mOK\u001b[0m\n\u001b[32m /¯¯\u001b[31m\\__/\u001b[35m¯¯\\\u001b[0m Envoy DaemonSet: \u001b[36mdisabled (using embedded mode)\u001b[0m\n\u001b[32m \\__\u001b[34m/¯¯\\\u001b[35m__/\u001b[0m Hubble Relay: \u001b[36mdisabled\u001b[0m\n\u001b[34m\u001b[34m\u001b[34m \\__/\u001b[0m ClusterMesh: \u001b[36mdisabled\u001b[0m\n\nDaemonSet cilium Desired: 1, Ready: \u001b[32m1/1\u001b[0m, Available: \u001b[32m1/1\u001b[0m\nDeployment cilium-operator Desired: 1, Ready: \u001b[32m1/1\u001b[0m, Available: \u001b[32m1/1\u001b[0m\nContainers: cilium Running: \u001b[32m1\u001b[0m\n cilium-operator Running: \u001b[32m1\u001b[0m\n clustermesh-apiserver \n hubble-relay \nCluster Pods: 2/2 managed by Cilium\nHelm chart version: 1.19.3\nImage versions cilium quay.io/cilium/cilium:v1.19.3@sha256:2e61680593cddca8b6c055f6d4c849d87a26a1c91c7e3b8b56c7fb76ab7b7b10: 1\n cilium-operator quay.io/cilium/operator-generic:v1.19.3@sha256:205b09b0ed6accbf9fe688d312a9f0fcfc6a316fc081c23fbffb472af5dd62cd: 1\n=== cilium: ready ===\n", + "error": null, + "tags": { + "operation": "install", + "component": "cilium", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "type": "component", + "server": "libre-wuji-cp-0" + } +} \ No newline at end of file diff --git a/crates/data/tasks/5006edf3-c986-443f-9610-5b5cb9224269.json b/crates/data/tasks/5006edf3-c986-443f-9610-5b5cb9224269.json new file mode 100644 index 0000000..1d50b78 --- /dev/null +++ b/crates/data/tasks/5006edf3-c986-443f-9610-5b5cb9224269.json @@ -0,0 +1,17 @@ +{ + "id": "5006edf3-c986-443f-9610-5b5cb9224269", + "name": "execute_servers_script_libre-wuji-cp-0", + "command": "bash", + "args": [ + "-c", + "base64 -d < /tmp/orchestrator_script_5006edf3-c986-443f-9610-5b5cb9224269.tar.gz.b64 | gunzip | tar -xOf - script.sh | bash +x" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T00:42:51.560218Z", + "started_at": "2026-04-21T00:42:51.712434Z", + "completed_at": "2026-04-21T00:43:20.037945Z", + "output": "✓ Prerequisites: HCLOUD_TOKEN set, hcloud CLI available\n\n=== Managing SSH Keys ===\n✓ SSH public key found: /Users/jesusperezlorenzo/.ssh/htz_ops.pub\nChecking if SSH key 'htz_ops' exists in Hetzner...\n✓ SSH key htz_ops already exists with ID: 106168627\n\n=== SSH Key Management Complete ===\nSSH_KEY_ID: 106168627\nSSH_KEY_NAME: htz_ops\nEnvironment exported to: /tmp/provisioning-wuji-20260421-014251/.env\n\n=== Managing Network ===\n✓ Network config validated: 10.0.8.0/22 with subnet 10.0.8.0/24 in zone eu-central\nChecking if network 'wuwei' exists...\n✓ Network 'wuwei' already exists with ID: 11875940\n ✓ Subnet 10.0.8.0/24 already present\n\n=== Network Management Complete ===\nNETWORK_ID: 11875940\nNETWORK_NAME: wuwei\nEnvironment exported to: /tmp/provisioning-wuji-20260421-014251/.env\n\u001b[0;34m[INFO]\u001b[0m Step 1/3: Checking server 'libre-wuji-cp-0'...\n\u001b[0;34m[INFO]\u001b[0m Creating server 'libre-wuji-cp-0'...\n\u001b[0;32m[✓]\u001b[0m 127584498 libre-wuji-cp-0 createdIPv4: \n\u001b[0;32m[✓]\u001b[0m Server created: ID=127584498\n\u001b[0;34m[INFO]\u001b[0m Waiting for server 'libre-wuji-cp-0' to reach running state...\n\u001b[0;32m[✓]\u001b[0m Server 'libre-wuji-cp-0' is running\nServer 127584498 attached to Network 11875940\n\u001b[0;32m[✓]\u001b[0m Attached to network wuwei with IP 10.0.8.20\nResource protection enabled for Server 127584498\n\u001b[0;32m[✓]\u001b[0m Protection enabled: delete rebuild\n\u001b[0;34m[INFO]\u001b[0m Step 2/2: Attaching server to firewall 'librecloud-fw'...\nFirewall 10819279 applied to resource\n\u001b[0;32m[✓]\u001b[0m Server attached to firewall 'librecloud-fw'\n\u001b[0;32m[✓]\u001b[0m ✓ Server provisioning complete\n\u001b[0;34m[INFO]\u001b[0m Summary:\n\u001b[0;34m[INFO]\u001b[0m • Server: libre-wuji-cp-0 (ID: 127584498)\n\u001b[0;34m[INFO]\u001b[0m • Firewall: librecloud-fw\n\u001b[0;34m[INFO]\u001b[0m • Status: Ready for deployment\n", + "error": null, + "tags": {} +} \ No newline at end of file diff --git a/crates/data/tasks/50f27d6d-65a0-4400-88e6-06ffa9bf5ecc.json b/crates/data/tasks/50f27d6d-65a0-4400-88e6-06ffa9bf5ecc.json new file mode 100644 index 0000000..9050a68 --- /dev/null +++ b/crates/data/tasks/50f27d6d-65a0-4400-88e6-06ffa9bf5ecc.json @@ -0,0 +1,23 @@ +{ + "id": "50f27d6d-65a0-4400-88e6-06ffa9bf5ecc", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_50f27d6d-65a0-4400-88e6-06ffa9bf5ecc.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_50f27d6d-65a0-4400-88e6-06ffa9bf5ecc.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T03:52:48.460149Z", + "started_at": "2026-04-21T03:52:48.513324Z", + "completed_at": "2026-04-21T03:52:59.984673Z", + "output": "", + "error": null, + "tags": { + "type": "component", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install", + "component": "kubernetes", + "server": "libre-wuji-wrk-0" + } +} \ No newline at end of file diff --git a/crates/data/tasks/52c80f88-1fa5-4c3f-8e15-b645d0b7abdd.json b/crates/data/tasks/52c80f88-1fa5-4c3f-8e15-b645d0b7abdd.json new file mode 100644 index 0000000..617b2a9 --- /dev/null +++ b/crates/data/tasks/52c80f88-1fa5-4c3f-8e15-b645d0b7abdd.json @@ -0,0 +1,17 @@ +{ + "id": "52c80f88-1fa5-4c3f-8e15-b645d0b7abdd", + "name": "execute_servers_script_libre-wuji-cp-0", + "command": "bash", + "args": [ + "-c", + "base64 -d < /tmp/orchestrator_script_52c80f88-1fa5-4c3f-8e15-b645d0b7abdd.tar.gz.b64 | gunzip | tar -xOf - script.sh | bash +x" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T01:07:42.324101Z", + "started_at": "2026-04-21T01:07:42.517244Z", + "completed_at": "2026-04-21T01:08:10.615549Z", + "output": "✓ Prerequisites: HCLOUD_TOKEN set, hcloud CLI available\n\n=== Managing SSH Keys ===\n✓ SSH public key found: /Users/jesusperezlorenzo/.ssh/htz_ops.pub\nChecking if SSH key 'htz_ops' exists in Hetzner...\n✓ SSH key htz_ops already exists with ID: 106168627\n\n=== SSH Key Management Complete ===\nSSH_KEY_ID: 106168627\nSSH_KEY_NAME: htz_ops\nEnvironment exported to: /tmp/provisioning-wuji-20260421-020742/.env\n\n=== Managing Network ===\n✓ Network config validated: 10.0.8.0/22 with subnet 10.0.8.0/24 in zone eu-central\nChecking if network 'wuwei' exists...\n✓ Network 'wuwei' already exists with ID: 11875940\n ✓ Subnet 10.0.8.0/24 already present\n\n=== Network Management Complete ===\nNETWORK_ID: 11875940\nNETWORK_NAME: wuwei\nEnvironment exported to: /tmp/provisioning-wuji-20260421-020742/.env\n\u001b[0;34m[INFO]\u001b[0m Step 1/3: Checking server 'libre-wuji-cp-0'...\n\u001b[0;34m[INFO]\u001b[0m Creating server 'libre-wuji-cp-0'...\n\u001b[0;32m[✓]\u001b[0m 127586862 libre-wuji-cp-0 createdIPv4: \n\u001b[0;32m[✓]\u001b[0m Server created: ID=127586862\n\u001b[0;34m[INFO]\u001b[0m Waiting for server 'libre-wuji-cp-0' to reach running state...\n\u001b[0;32m[✓]\u001b[0m Server 'libre-wuji-cp-0' is running\nServer 127586862 attached to Network 11875940\n\u001b[0;32m[✓]\u001b[0m Attached to network wuwei with IP 10.0.8.20\nResource protection enabled for Server 127586862\n\u001b[0;32m[✓]\u001b[0m Protection enabled: delete rebuild\n\u001b[0;34m[INFO]\u001b[0m Step 2/2: Attaching server to firewall 'librecloud-fw'...\nFirewall 10819279 applied to resource\n\u001b[0;32m[✓]\u001b[0m Server attached to firewall 'librecloud-fw'\n\u001b[0;32m[✓]\u001b[0m ✓ Server provisioning complete\n\u001b[0;34m[INFO]\u001b[0m Summary:\n\u001b[0;34m[INFO]\u001b[0m • Server: libre-wuji-cp-0 (ID: 127586862)\n\u001b[0;34m[INFO]\u001b[0m • Firewall: librecloud-fw\n\u001b[0;34m[INFO]\u001b[0m • Status: Ready for deployment\n", + "error": null, + "tags": {} +} \ No newline at end of file diff --git a/crates/data/tasks/5406f205-24f1-467c-90cc-7e917b7e3293.json b/crates/data/tasks/5406f205-24f1-467c-90cc-7e917b7e3293.json new file mode 100644 index 0000000..0176ab5 --- /dev/null +++ b/crates/data/tasks/5406f205-24f1-467c-90cc-7e917b7e3293.json @@ -0,0 +1,23 @@ +{ + "id": "5406f205-24f1-467c-90cc-7e917b7e3293", + "name": "component_install_containerd", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_5406f205-24f1-467c-90cc-7e917b7e3293.tar.gz.b64' > /tmp/containerd.tar.gz && scp /tmp/containerd.tar.gz 'root@libre-wuji-wk-0:/tmp/' && ssh 'root@libre-wuji-wk-0' 'rm -rf /tmp/containerd && mkdir -p /tmp/containerd && tar xzf /tmp/containerd.tar.gz -C /tmp/containerd && cd /tmp/containerd && sudo bash install-containerd.sh install ; rc=$?; rm -f /tmp/containerd.tar.gz && rm -rf /tmp/containerd; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_5406f205-24f1-467c-90cc-7e917b7e3293.tar.gz.b64' /tmp/containerd.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Failed", + "created_at": "2026-04-20T10:29:18.910354Z", + "started_at": "2026-04-20T10:29:19.413672Z", + "completed_at": "2026-04-20T10:29:19.627169Z", + "output": null, + "error": "Command execution failed: ssh: Could not resolve hostname libre-wuji-wk-0: nodename nor servname provided, or not known\nscp: Connection closed\n", + "tags": { + "component": "containerd", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "type": "component", + "server": "libre-wuji-wk-0", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/56477558-463a-43d3-97dd-2460f6063301.json b/crates/data/tasks/56477558-463a-43d3-97dd-2460f6063301.json new file mode 100644 index 0000000..4bec32f --- /dev/null +++ b/crates/data/tasks/56477558-463a-43d3-97dd-2460f6063301.json @@ -0,0 +1,23 @@ +{ + "id": "56477558-463a-43d3-97dd-2460f6063301", + "name": "component_install_containerd", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_56477558-463a-43d3-97dd-2460f6063301.tar.gz.b64' > /tmp/containerd.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/containerd.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/containerd && mkdir -p /tmp/containerd && tar xzf /tmp/containerd.tar.gz -C /tmp/containerd && cd /tmp/containerd && sudo bash install-containerd.sh install ; rc=$?; rm -f /tmp/containerd.tar.gz && rm -rf /tmp/containerd; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_56477558-463a-43d3-97dd-2460f6063301.tar.gz.b64' /tmp/containerd.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Failed", + "created_at": "2026-04-20T10:29:34.730752Z", + "started_at": "2026-04-20T10:29:34.975291Z", + "completed_at": "2026-04-20T10:29:41.121589Z", + "output": null, + "error": "Command execution failed: tar: Ignoring unknown extended header keyword 'LIBARCHIVE.xattr.com.apple.provenance'\ntar: Ignoring unknown extended header keyword 'LIBARCHIVE.xattr.com.apple.provenance'\ntar: Ignoring unknown extended header keyword 'LIBARCHIVE.xattr.com.apple.provenance'\ntar: Ignoring unknown extended header keyword 'LIBARCHIVE.xattr.com.apple.provenance'\ntar: Ignoring unknown extended header keyword 'LIBARCHIVE.xattr.com.apple.provenance'\ntar: Ignoring unknown extended header keyword 'LIBARCHIVE.xattr.com.apple.provenance'\ntar: Ignoring unknown extended header keyword 'LIBARCHIVE.xattr.com.apple.provenance'\ninstall-containerd.sh: line 42: _common_service_stop: command not found\ninstall-containerd.sh: line 93: _common_service_start: command not found\n", + "tags": { + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "component": "containerd", + "server": "libre-wuji-wrk-0", + "operation": "install", + "type": "component" + } +} \ No newline at end of file diff --git a/crates/data/tasks/5733514f-2fa5-4ddb-8933-b431e84b15fd.json b/crates/data/tasks/5733514f-2fa5-4ddb-8933-b431e84b15fd.json new file mode 100644 index 0000000..8df159d --- /dev/null +++ b/crates/data/tasks/5733514f-2fa5-4ddb-8933-b431e84b15fd.json @@ -0,0 +1,23 @@ +{ + "id": "5733514f-2fa5-4ddb-8933-b431e84b15fd", + "name": "component_install_containerd", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_5733514f-2fa5-4ddb-8933-b431e84b15fd.tar.gz.b64' > /tmp/containerd.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/containerd.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/containerd && mkdir -p /tmp/containerd && tar xzf /tmp/containerd.tar.gz -C /tmp/containerd && cd /tmp/containerd && sudo bash install-containerd.sh install ; rc=$?; rm -f /tmp/containerd.tar.gz && rm -rf /tmp/containerd; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_5733514f-2fa5-4ddb-8933-b431e84b15fd.tar.gz.b64' /tmp/containerd.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-20T10:33:42.311972Z", + "started_at": "2026-04-20T10:33:42.422679Z", + "completed_at": "2026-04-20T10:33:45.858732Z", + "output": "", + "error": null, + "tags": { + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "type": "component", + "server": "libre-wuji-cp-0", + "operation": "install", + "component": "containerd" + } +} \ No newline at end of file diff --git a/crates/data/tasks/575c3a64-51f0-4d7c-8cb8-404dd68f4f59.json b/crates/data/tasks/575c3a64-51f0-4d7c-8cb8-404dd68f4f59.json new file mode 100644 index 0000000..455ea0b --- /dev/null +++ b/crates/data/tasks/575c3a64-51f0-4d7c-8cb8-404dd68f4f59.json @@ -0,0 +1,23 @@ +{ + "id": "575c3a64-51f0-4d7c-8cb8-404dd68f4f59", + "name": "component_install_kubernetes_worker", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_575c3a64-51f0-4d7c-8cb8-404dd68f4f59.tar.gz.b64' > /tmp/kubernetes_worker.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes_worker.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes_worker && mkdir -p /tmp/kubernetes_worker && tar xzf /tmp/kubernetes_worker.tar.gz -C /tmp/kubernetes_worker && cd /tmp/kubernetes_worker && sudo bash install-kubernetes_worker.sh install ; rc=$?; rm -f /tmp/kubernetes_worker.tar.gz && rm -rf /tmp/kubernetes_worker; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_575c3a64-51f0-4d7c-8cb8-404dd68f4f59.tar.gz.b64' /tmp/kubernetes_worker.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Failed", + "created_at": "2026-04-21T04:25:37.300429Z", + "started_at": "2026-04-21T04:25:37.549871Z", + "completed_at": "2026-04-21T04:25:40.241492Z", + "output": null, + "error": "Command execution failed: bash: install-kubernetes_worker.sh: No such file or directory\n", + "tags": { + "server": "libre-wuji-wrk-0", + "component": "kubernetes_worker", + "operation": "install", + "type": "component", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji" + } +} \ No newline at end of file diff --git a/crates/data/tasks/579136e4-87a2-412c-9657-89ed101d6601.json b/crates/data/tasks/579136e4-87a2-412c-9657-89ed101d6601.json new file mode 100644 index 0000000..f87970d --- /dev/null +++ b/crates/data/tasks/579136e4-87a2-412c-9657-89ed101d6601.json @@ -0,0 +1,23 @@ +{ + "id": "579136e4-87a2-412c-9657-89ed101d6601", + "name": "component_install_resolv", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_579136e4-87a2-412c-9657-89ed101d6601.tar.gz.b64' > /tmp/resolv.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/resolv.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/resolv && mkdir -p /tmp/resolv && tar xzf /tmp/resolv.tar.gz -C /tmp/resolv && cd /tmp/resolv && sudo bash install-resolv.sh install ; rc=$?; rm -f /tmp/resolv.tar.gz && rm -rf /tmp/resolv; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_579136e4-87a2-412c-9657-89ed101d6601.tar.gz.b64' /tmp/resolv.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T02:14:19.993230Z", + "started_at": "2026-04-21T02:14:20.382448Z", + "completed_at": "2026-04-21T02:14:23.040966Z", + "output": "", + "error": null, + "tags": { + "component": "resolv", + "server": "libre-wuji-cp-0", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "type": "component", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/58b7d322-8ab2-4af1-8548-ae432a070114.json b/crates/data/tasks/58b7d322-8ab2-4af1-8548-ae432a070114.json new file mode 100644 index 0000000..9f844c5 --- /dev/null +++ b/crates/data/tasks/58b7d322-8ab2-4af1-8548-ae432a070114.json @@ -0,0 +1,23 @@ +{ + "id": "58b7d322-8ab2-4af1-8548-ae432a070114", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_58b7d322-8ab2-4af1-8548-ae432a070114.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_58b7d322-8ab2-4af1-8548-ae432a070114.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T04:54:02.171636Z", + "started_at": "2026-04-21T04:54:02.519031Z", + "completed_at": "2026-04-21T04:54:14.807908Z", + "output": "", + "error": null, + "tags": { + "server": "libre-wuji-wrk-0", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "type": "component", + "component": "kubernetes", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/5c2d51d1-d10d-4032-b81c-1e6cdf5a4d64.json b/crates/data/tasks/5c2d51d1-d10d-4032-b81c-1e6cdf5a4d64.json new file mode 100644 index 0000000..a707952 --- /dev/null +++ b/crates/data/tasks/5c2d51d1-d10d-4032-b81c-1e6cdf5a4d64.json @@ -0,0 +1,23 @@ +{ + "id": "5c2d51d1-d10d-4032-b81c-1e6cdf5a4d64", + "name": "component_install_external_nfs", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_5c2d51d1-d10d-4032-b81c-1e6cdf5a4d64.tar.gz.b64' > /tmp/external_nfs.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/external_nfs.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/external_nfs && mkdir -p /tmp/external_nfs && tar xzf /tmp/external_nfs.tar.gz -C /tmp/external_nfs && cd /tmp/external_nfs && sudo bash install-external_nfs.sh install ; rc=$?; rm -f /tmp/external_nfs.tar.gz && rm -rf /tmp/external_nfs; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_5c2d51d1-d10d-4032-b81c-1e6cdf5a4d64.tar.gz.b64' /tmp/external_nfs.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Failed", + "created_at": "2026-04-21T03:09:03.176840Z", + "started_at": "2026-04-21T03:09:03.509139Z", + "completed_at": "2026-04-21T03:09:07.174742Z", + "output": null, + "error": "Command execution failed: Error: IP NET SHARE_PATH not all set for NFS\n", + "tags": { + "type": "component", + "component": "external_nfs", + "operation": "install", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "server": "libre-wuji-cp-0" + } +} \ No newline at end of file diff --git a/crates/data/tasks/5f4b0130-68a1-4849-92d3-b22079d3664d.json b/crates/data/tasks/5f4b0130-68a1-4849-92d3-b22079d3664d.json new file mode 100644 index 0000000..9305933 --- /dev/null +++ b/crates/data/tasks/5f4b0130-68a1-4849-92d3-b22079d3664d.json @@ -0,0 +1,23 @@ +{ + "id": "5f4b0130-68a1-4849-92d3-b22079d3664d", + "name": "component_install_os", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_5f4b0130-68a1-4849-92d3-b22079d3664d.tar.gz.b64' > /tmp/os.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/os.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/os && mkdir -p /tmp/os && tar xzf /tmp/os.tar.gz -C /tmp/os && cd /tmp/os && sudo bash install-os.sh install ; rc=$?; rm -f /tmp/os.tar.gz && rm -rf /tmp/os; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_5f4b0130-68a1-4849-92d3-b22079d3664d.tar.gz.b64' /tmp/os.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T01:40:00.404679Z", + "started_at": "2026-04-21T01:40:00.520784Z", + "completed_at": "2026-04-21T01:40:52.862031Z", + "output": "APT::Get::Update::SourceListWarnings::NonFreeFirmware \"false\";\nGet:1 http://mirror.hetzner.com/debian/packages bookworm InRelease [151 kB]\nGet:2 http://deb.debian.org/debian bookworm InRelease [151 kB]\nGet:3 http://security.debian.org/debian-security bookworm-security InRelease [48.0 kB]\nGet:4 http://mirror.hetzner.com/debian/packages bookworm-backports InRelease [59.4 kB]\nGet:5 http://mirror.hetzner.com/debian/packages bookworm-updates InRelease [55.4 kB]\nGet:6 http://mirror.hetzner.com/debian/security bookworm-security InRelease [48.0 kB]\nGet:7 http://deb.debian.org/debian bookworm-updates InRelease [55.4 kB]\nGet:8 http://mirror.hetzner.com/debian/packages bookworm/main arm64 Packages [8,691 kB]\nGet:9 http://mirror.hetzner.com/debian/packages bookworm/main Translation-en [6,108 kB]\nGet:10 http://mirror.hetzner.com/debian/packages bookworm/contrib arm64 Packages [45.7 kB]\nGet:11 http://mirror.hetzner.com/debian/packages bookworm/contrib Translation-en [48.4 kB]\nGet:12 http://mirror.hetzner.com/debian/packages bookworm/non-free arm64 Packages [75.8 kB]\nGet:13 http://mirror.hetzner.com/debian/packages bookworm/non-free Translation-en [68.1 kB]\nGet:14 http://mirror.hetzner.com/debian/packages bookworm/non-free-firmware arm64 Packages [5,832 B]\nGet:15 http://mirror.hetzner.com/debian/packages bookworm/non-free-firmware Translation-en [20.9 kB]\nGet:16 http://mirror.hetzner.com/debian/packages bookworm-backports/main arm64 Packages [304 kB]\nGet:17 http://mirror.hetzner.com/debian/packages bookworm-backports/main Translation-en [257 kB]\nGet:18 http://mirror.hetzner.com/debian/packages bookworm-backports/contrib arm64 Packages [5,180 B]\nGet:19 http://mirror.hetzner.com/debian/packages bookworm-backports/contrib Translation-en [5,864 B]\nGet:20 http://mirror.hetzner.com/debian/packages bookworm-backports/non-free arm64 Packages [12.6 kB]\nGet:21 http://mirror.hetzner.com/debian/packages bookworm-backports/non-free Translation-en [8,460 B]\nGet:22 http://mirror.hetzner.com/debian/packages bookworm-backports/non-free-firmware arm64 Packages [3,832 B]\nGet:23 http://mirror.hetzner.com/debian/packages bookworm-backports/non-free-firmware Translation-en [2,860 B]\nGet:24 http://mirror.hetzner.com/debian/packages bookworm-updates/main arm64 Packages [6,936 B]\nGet:25 http://mirror.hetzner.com/debian/packages bookworm-updates/main Translation-en [5,448 B]\nGet:26 http://mirror.hetzner.com/debian/security bookworm-security/main arm64 Packages [291 kB]\nGet:27 http://mirror.hetzner.com/debian/security bookworm-security/main Translation-en [180 kB]\nGet:28 http://mirror.hetzner.com/debian/security bookworm-security/contrib arm64 Packages [508 B]\nGet:29 http://mirror.hetzner.com/debian/security bookworm-security/contrib Translation-en [652 B]\nGet:30 http://security.debian.org/debian-security bookworm-security/main arm64 Packages [291 kB]\nGet:31 http://security.debian.org/debian-security bookworm-security/main Translation-en [180 kB]\nGet:32 http://security.debian.org/debian-security bookworm-security/contrib arm64 Packages [508 B]\nGet:33 http://security.debian.org/debian-security bookworm-security/contrib Translation-en [652 B]\nGet:34 http://security.debian.org/debian-security bookworm-security/non-free-firmware Translation-en [472 B]\nGet:35 http://deb.debian.org/debian bookworm/main arm64 Packages [8,691 kB]\nGet:36 http://deb.debian.org/debian bookworm/main Translation-en [6,108 kB]\nGet:37 http://deb.debian.org/debian bookworm/contrib arm64 Packages [45.7 kB]\nGet:38 http://deb.debian.org/debian bookworm/contrib Translation-en [48.4 kB]\nGet:39 http://deb.debian.org/debian bookworm/non-free arm64 Packages [75.8 kB]\nGet:40 http://deb.debian.org/debian bookworm/non-free Translation-en [68.1 kB]\nGet:41 http://deb.debian.org/debian bookworm/non-free-firmware arm64 Packages [5,832 B]\nGet:42 http://deb.debian.org/debian bookworm/non-free-firmware Translation-en [20.9 kB]\nGet:43 http://deb.debian.org/debian bookworm-updates/main arm64 Packages [6,936 B]\nGet:44 http://deb.debian.org/debian bookworm-updates/main Translation-en [5,448 B]\nFetched 32.3 MB in 3s (10.8 MB/s)\nReading package lists...\nReading package lists...\nBuilding dependency tree...\nReading state information...\nCalculating upgrade...\nThe following packages have been kept back:\n linux-image-arm64\nThe following packages will be upgraded:\n bind9-dnsutils bind9-host bind9-libs inetutils-telnet libgnutls30\n libpng16-16 libssl3 openssh-client openssh-server openssh-sftp-server\n openssl\n11 upgraded, 0 newly installed, 0 to remove and 1 not upgraded.\nNeed to get 7,582 kB of archives.\nAfter this operation, 8,192 B of additional disk space will be used.\nGet:1 http://security.debian.org/debian-security bookworm-security/main arm64 libssl3 arm64 3.0.19-1~deb12u2 [1,820 kB]\nGet:2 http://security.debian.org/debian-security bookworm-security/main arm64 openssh-sftp-server arm64 1:9.2p1-2+deb12u9 [60.3 kB]\nGet:3 http://security.debian.org/debian-security bookworm-security/main arm64 openssh-server arm64 1:9.2p1-2+deb12u9 [411 kB]\nGet:4 http://security.debian.org/debian-security bookworm-security/main arm64 openssh-client arm64 1:9.2p1-2+deb12u9 [935 kB]\nGet:5 http://security.debian.org/debian-security bookworm-security/main arm64 libgnutls30 arm64 3.7.9-2+deb12u6 [1,316 kB]\nGet:6 http://security.debian.org/debian-security bookworm-security/main arm64 bind9-dnsutils arm64 1:9.18.47-1~deb12u1 [148 kB]\nGet:7 http://security.debian.org/debian-security bookworm-security/main arm64 bind9-host arm64 1:9.18.47-1~deb12u1 [52.4 kB]\nGet:8 http://security.debian.org/debian-security bookworm-security/main arm64 bind9-libs arm64 1:9.18.47-1~deb12u1 [1,052 kB]\nGet:9 http://security.debian.org/debian-security bookworm-security/main arm64 inetutils-telnet arm64 2:2.4-2+deb12u3 [116 kB]\nGet:10 http://security.debian.org/debian-security bookworm-security/main arm64 libpng16-16 arm64 1.6.39-2+deb12u4 [270 kB]\nGet:11 http://security.debian.org/debian-security bookworm-security/main arm64 openssl arm64 3.0.19-1~deb12u2 [1,402 kB]\nPreconfiguring packages ...\nFetched 7,582 kB in 0s (88.3 MB/s)\n(Reading database ... \r(Reading database ... 5%\r(Reading database ... 10%\r(Reading database ... 15%\r(Reading database ... 20%\r(Reading database ... 25%\r(Reading database ... 30%\r(Reading database ... 35%\r(Reading database ... 40%\r(Reading database ... 45%\r(Reading database ... 50%\r(Reading database ... 55%\r(Reading database ... 60%\r(Reading database ... 65%\r(Reading database ... 70%\r(Reading database ... 75%\r(Reading database ... 80%\r(Reading database ... 85%\r(Reading database ... 90%\r(Reading database ... 95%\r(Reading database ... 100%\r(Reading database ... 36165 files and directories currently installed.)\r\nPreparing to unpack .../libssl3_3.0.19-1~deb12u2_arm64.deb ...\r\nUnpacking libssl3:arm64 (3.0.19-1~deb12u2) over (3.0.18-1~deb12u1) ...\r\nPreparing to unpack .../openssh-sftp-server_1%3a9.2p1-2+deb12u9_arm64.deb ...\r\nUnpacking openssh-sftp-server (1:9.2p1-2+deb12u9) over (1:9.2p1-2+deb12u7) ...\r\nPreparing to unpack .../openssh-server_1%3a9.2p1-2+deb12u9_arm64.deb ...\r\nUnpacking openssh-server (1:9.2p1-2+deb12u9) over (1:9.2p1-2+deb12u7) ...\r\nPreparing to unpack .../openssh-client_1%3a9.2p1-2+deb12u9_arm64.deb ...\r\nUnpacking openssh-client (1:9.2p1-2+deb12u9) over (1:9.2p1-2+deb12u7) ...\r\nPreparing to unpack .../libgnutls30_3.7.9-2+deb12u6_arm64.deb ...\r\nUnpacking libgnutls30:arm64 (3.7.9-2+deb12u6) over (3.7.9-2+deb12u5) ...\r\nSetting up libgnutls30:arm64 (3.7.9-2+deb12u6) ...\r\n(Reading database ... \r(Reading database ... 5%\r(Reading database ... 10%\r(Reading database ... 15%\r(Reading database ... 20%\r(Reading database ... 25%\r(Reading database ... 30%\r(Reading database ... 35%\r(Reading database ... 40%\r(Reading database ... 45%\r(Reading database ... 50%\r(Reading database ... 55%\r(Reading database ... 60%\r(Reading database ... 65%\r(Reading database ... 70%\r(Reading database ... 75%\r(Reading database ... 80%\r(Reading database ... 85%\r(Reading database ... 90%\r(Reading database ... 95%\r(Reading database ... 100%\r(Reading database ... 36165 files and directories currently installed.)\r\nPreparing to unpack .../0-bind9-dnsutils_1%3a9.18.47-1~deb12u1_arm64.deb ...\r\nUnpacking bind9-dnsutils (1:9.18.47-1~deb12u1) over (1:9.18.44-1~deb12u1) ...\r\nPreparing to unpack .../1-bind9-host_1%3a9.18.47-1~deb12u1_arm64.deb ...\r\nUnpacking bind9-host (1:9.18.47-1~deb12u1) over (1:9.18.44-1~deb12u1) ...\r\nPreparing to unpack .../2-bind9-libs_1%3a9.18.47-1~deb12u1_arm64.deb ...\r\nUnpacking bind9-libs:arm64 (1:9.18.47-1~deb12u1) over (1:9.18.44-1~deb12u1) ...\r\nPreparing to unpack .../3-inetutils-telnet_2%3a2.4-2+deb12u3_arm64.deb ...\r\nUnpacking inetutils-telnet (2:2.4-2+deb12u3) over (2:2.4-2+deb12u2) ...\r\nPreparing to unpack .../4-libpng16-16_1.6.39-2+deb12u4_arm64.deb ...\r\nUnpacking libpng16-16:arm64 (1.6.39-2+deb12u4) over (1.6.39-2+deb12u1) ...\r\nPreparing to unpack .../5-openssl_3.0.19-1~deb12u2_arm64.deb ...\r\nUnpacking openssl (3.0.19-1~deb12u2) over (3.0.18-1~deb12u1) ...\r\nSetting up libssl3:arm64 (3.0.19-1~deb12u2) ...\r\nSetting up inetutils-telnet (2:2.4-2+deb12u3) ...\r\nSetting up libpng16-16:arm64 (1.6.39-2+deb12u4) ...\r\nSetting up openssl (3.0.19-1~deb12u2) ...\r\nSetting up bind9-libs:arm64 (1:9.18.47-1~deb12u1) ...\r\nSetting up openssh-client (1:9.2p1-2+deb12u9) ...\r\nSetting up bind9-host (1:9.18.47-1~deb12u1) ...\r\nSetting up openssh-sftp-server (1:9.2p1-2+deb12u9) ...\r\nSetting up openssh-server (1:9.2p1-2+deb12u9) ...\r\nrescue-ssh.target is a disabled or a static unit not running, not starting it.\r\nssh.socket is a disabled or a static unit not running, not starting it.\r\nSetting up bind9-dnsutils (1:9.18.47-1~deb12u1) ...\r\nProcessing triggers for man-db (2.11.2-2) ...\r\nProcessing triggers for libc-bin (2.36-9+deb12u13) ...\r\nPreconfiguring packages ...\nSelecting previously unselected package perl-modules-5.36.\r\n(Reading database ... \r(Reading database ... 5%\r(Reading database ... 10%\r(Reading database ... 15%\r(Reading database ... 20%\r(Reading database ... 25%\r(Reading database ... 30%\r(Reading database ... 35%\r(Reading database ... 40%\r(Reading database ... 45%\r(Reading database ... 50%\r(Reading database ... 55%\r(Reading database ... 60%\r(Reading database ... 65%\r(Reading database ... 70%\r(Reading database ... 75%\r(Reading database ... 80%\r(Reading database ... 85%\r(Reading database ... 90%\r(Reading database ... 95%\r(Reading database ... 100%\r(Reading database ... 36165 files and directories currently installed.)\r\nPreparing to unpack .../00-perl-modules-5.36_5.36.0-7+deb12u3_all.deb ...\r\nUnpacking perl-modules-5.36 (5.36.0-7+deb12u3) ...\r\nSelecting previously unselected package libgdbm-compat4:arm64.\r\nPreparing to unpack .../01-libgdbm-compat4_1.23-3_arm64.deb ...\r\nUnpacking libgdbm-compat4:arm64 (1.23-3) ...\r\nSelecting previously unselected package libperl5.36:arm64.\r\nPreparing to unpack .../02-libperl5.36_5.36.0-7+deb12u3_arm64.deb ...\r\nUnpacking libperl5.36:arm64 (5.36.0-7+deb12u3) ...\r\nSelecting previously unselected package perl.\r\nPreparing to unpack .../03-perl_5.36.0-7+deb12u3_arm64.deb ...\r\nUnpacking perl (5.36.0-7+deb12u3) ...\r\nSelecting previously unselected package libevent-core-2.1-7:arm64.\r\nPreparing to unpack .../04-libevent-core-2.1-7_2.1.12-stable-8_arm64.deb ...\r\nUnpacking libevent-core-2.1-7:arm64 (2.1.12-stable-8) ...\r\nSelecting previously unselected package libnfsidmap1:arm64.\r\nPreparing to unpack .../05-libnfsidmap1_1%3a2.6.2-4+deb12u1_arm64.deb ...\r\nUnpacking libnfsidmap1:arm64 (1:2.6.2-4+deb12u1) ...\r\nSelecting previously unselected package rpcbind.\r\nPreparing to unpack .../06-rpcbind_1.2.6-6+b1_arm64.deb ...\r\nUnpacking rpcbind (1.2.6-6+b1) ...\r\nSelecting previously unselected package keyutils.\r\nPreparing to unpack .../07-keyutils_1.6.3-2_arm64.deb ...\r\nUnpacking keyutils (1.6.3-2) ...\r\nSelecting previously unselected package nfs-common.\r\nPreparing to unpack .../08-nfs-common_1%3a2.6.2-4+deb12u1_arm64.deb ...\r\nUnpacking nfs-common (1:2.6.2-4+deb12u1) ...\r\nSelecting previously unselected package sgml-base.\r\nPreparing to unpack .../09-sgml-base_1.31_all.deb ...\r\nUnpacking sgml-base (1.31) ...\r\nSelecting previously unselected package apt-transport-https.\r\nPreparing to unpack .../10-apt-transport-https_2.6.1_all.deb ...\r\nUnpacking apt-transport-https (2.6.1) ...\r\nSelecting previously unselected package dialog.\r\nPreparing to unpack .../11-dialog_1.3-20230209-1_arm64.deb ...\r\nUnpacking dialog (1.3-20230209-1) ...\r\nSelecting previously unselected package fuse3.\r\nPreparing to unpack .../12-fuse3_3.14.0-4_arm64.deb ...\r\nUnpacking fuse3 (3.14.0-4) ...\r\nSelecting previously unselected package libgirepository-1.0-1:arm64.\r\nPreparing to unpack .../13-libgirepository-1.0-1_1.74.0-3_arm64.deb ...\r\nUnpacking libgirepository-1.0-1:arm64 (1.74.0-3) ...\r\nSelecting previously unselected package gir1.2-glib-2.0:arm64.\r\nPreparing to unpack .../14-gir1.2-glib-2.0_1.74.0-3_arm64.deb ...\r\nUnpacking gir1.2-glib-2.0:arm64 (1.74.0-3) ...\r\nSelecting previously unselected package libpackagekit-glib2-18:arm64.\r\nPreparing to unpack .../15-libpackagekit-glib2-18_1.2.6-5_arm64.deb ...\r\nUnpacking libpackagekit-glib2-18:arm64 (1.2.6-5) ...\r\nSelecting previously unselected package gir1.2-packagekitglib-1.0.\r\nPreparing to unpack .../16-gir1.2-packagekitglib-1.0_1.2.6-5_arm64.deb ...\r\nUnpacking gir1.2-packagekitglib-1.0 (1.2.6-5) ...\r\nSelecting previously unselected package liberror-perl.\r\nPreparing to unpack .../17-liberror-perl_0.17029-2_all.deb ...\r\nUnpacking liberror-perl (0.17029-2) ...\r\nSelecting previously unselected package git-man.\r\nPreparing to unpack .../18-git-man_1%3a2.39.5-0+deb12u3_all.deb ...\r\nUnpacking git-man (1:2.39.5-0+deb12u3) ...\r\nSelecting previously unselected package git.\r\nPreparing to unpack .../19-git_1%3a2.39.5-0+deb12u3_arm64.deb ...\r\nUnpacking git (1:2.39.5-0+deb12u3) ...\r\nSelecting previously unselected package iso-codes.\r\nPreparing to unpack .../20-iso-codes_4.15.0-1_all.deb ...\r\nUnpacking iso-codes (4.15.0-1) ...\r\nSelecting previously unselected package libonig5:arm64.\r\nPreparing to unpack .../21-libonig5_6.9.8-1_arm64.deb ...\r\nUnpacking libonig5:arm64 (6.9.8-1) ...\r\nSelecting previously unselected package libjq1:arm64.\r\nPreparing to unpack .../22-libjq1_1.6-2.1+deb12u1_arm64.deb ...\r\nUnpacking libjq1:arm64 (1.6-2.1+deb12u1) ...\r\nSelecting previously unselected package jq.\r\nPreparing to unpack .../23-jq_1.6-2.1+deb12u1_arm64.deb ...\r\nUnpacking jq (1.6-2.1+deb12u1) ...\r\nSelecting previously unselected package libstemmer0d:arm64.\r\nPreparing to unpack .../24-libstemmer0d_2.2.0-2_arm64.deb ...\r\nUnpacking libstemmer0d:arm64 (2.2.0-2) ...\r\nSelecting previously unselected package libxmlb2:arm64.\r\nPreparing to unpack .../25-libxmlb2_0.3.10-2_arm64.deb ...\r\nUnpacking libxmlb2:arm64 (0.3.10-2) ...\r\nSelecting previously unselected package libappstream4:arm64.\r\nPreparing to unpack .../26-libappstream4_0.16.1-2_arm64.deb ...\r\nUnpacking libappstream4:arm64 (0.16.1-2) ...\r\nSelecting previously unselected package libbluetooth3:arm64.\r\nPreparing to unpack .../27-libbluetooth3_5.66-1+deb12u2_arm64.deb ...\r\nUnpacking libbluetooth3:arm64 (5.66-1+deb12u2) ...\r\nSelecting previously unselected package libduktape207:arm64.\r\nPreparing to unpack .../28-libduktape207_2.7.0-2_arm64.deb ...\r\nUnpacking libduktape207:arm64 (2.7.0-2) ...\r\nSelecting previously unselected package libdw1:arm64.\r\nPreparing to unpack .../29-libdw1_0.188-2.1_arm64.deb ...\r\nUnpacking libdw1:arm64 (0.188-2.1) ...\r\nSelecting previously unselected package libglib2.0-bin.\r\nPreparing to unpack .../30-libglib2.0-bin_2.74.6-2+deb12u8_arm64.deb ...\r\nUnpacking libglib2.0-bin (2.74.6-2+deb12u8) ...\r\nSelecting previously unselected package libunwind8:arm64.\r\nPreparing to unpack .../31-libunwind8_1.6.2-3_arm64.deb ...\r\nUnpacking libunwind8:arm64 (1.6.2-3) ...\r\nSelecting previously unselected package libgstreamer1.0-0:arm64.\r\nPreparing to unpack .../32-libgstreamer1.0-0_1.22.0-2+deb12u1_arm64.deb ...\r\nUnpacking libgstreamer1.0-0:arm64 (1.22.0-2+deb12u1) ...\r\nSelecting previously unselected package libmm-glib0:arm64.\r\nPreparing to unpack .../33-libmm-glib0_1.20.4-1_arm64.deb ...\r\nUnpacking libmm-glib0:arm64 (1.20.4-1) ...\r\nSelecting previously unselected package libndp0:arm64.\r\nPreparing to unpack .../34-libndp0_1.8-1+deb12u1_arm64.deb ...\r\nUnpacking libndp0:arm64 (1.8-1+deb12u1) ...\r\nSelecting previously unselected package libnm0:arm64.\r\nPreparing to unpack .../35-libnm0_1.42.4-1+deb12u1_arm64.deb ...\r\nUnpacking libnm0:arm64 (1.42.4-1+deb12u1) ...\r\nSelecting previously unselected package libpolkit-gobject-1-0:arm64.\r\nPreparing to unpack .../36-libpolkit-gobject-1-0_122-3_arm64.deb ...\r\nUnpacking libpolkit-gobject-1-0:arm64 (122-3) ...\r\nSelecting previously unselected package libpolkit-agent-1-0:arm64.\r\nPreparing to unpack .../37-libpolkit-agent-1-0_122-3_arm64.deb ...\r\nUnpacking libpolkit-agent-1-0:arm64 (122-3) ...\r\nSelecting previously unselected package libsensors-config.\r\nPreparing to unpack .../38-libsensors-config_1%3a3.6.0-7.1_all.deb ...\r\nUnpacking libsensors-config (1:3.6.0-7.1) ...\r\nSelecting previously unselected package libsensors5:arm64.\r\nPreparing to unpack .../39-libsensors5_1%3a3.6.0-7.1_arm64.deb ...\r\nUnpacking libsensors5:arm64 (1:3.6.0-7.1) ...\r\nSelecting previously unselected package libteamdctl0:arm64.\r\nPreparing to unpack .../40-libteamdctl0_1.31-1_arm64.deb ...\r\nUnpacking libteamdctl0:arm64 (1.31-1) ...\r\nSelecting previously unselected package xml-core.\r\nPreparing to unpack .../41-xml-core_0.18+nmu1_all.deb ...\r\nUnpacking xml-core (0.18+nmu1) ...\r\nSelecting previously unselected package polkitd.\r\nPreparing to unpack .../42-polkitd_122-3_arm64.deb ...\r\nUnpacking polkitd (122-3) ...\r\nSelecting previously unselected package network-manager.\r\nPreparing to unpack .../43-network-manager_1.42.4-1+deb12u1_arm64.deb ...\r\nUnpacking network-manager (1.42.4-1+deb12u1) ...\r\nSelecting previously unselected package packagekit.\r\nPreparing to unpack .../44-packagekit_1.2.6-5_arm64.deb ...\r\nUnpacking packagekit (1.2.6-5) ...\r\nSelecting previously unselected package python3-gi.\r\nPreparing to unpack .../45-python3-gi_3.42.2-3+b1_arm64.deb ...\r\nUnpacking python3-gi (3.42.2-3+b1) ...\r\nSelecting previously unselected package python3-lazr.uri.\r\nPreparing to unpack .../46-python3-lazr.uri_1.0.6-3_all.deb ...\r\nUnpacking python3-lazr.uri (1.0.6-3) ...\r\nSelecting previously unselected package python3-wadllib.\r\nPreparing to unpack .../47-python3-wadllib_1.3.6-4_all.deb ...\r\nUnpacking python3-wadllib (1.3.6-4) ...\r\nSelecting previously unselected package python3-lazr.restfulclient.\r\nPreparing to unpack .../48-python3-lazr.restfulclient_0.14.5-1_all.deb ...\r\nUnpacking python3-lazr.restfulclient (0.14.5-1) ...\r\nSelecting previously unselected package python3-software-properties.\r\nPreparing to unpack .../49-python3-software-properties_0.99.30-4.1~deb12u1_all.deb ...\r\nUnpacking python3-software-properties (0.99.30-4.1~deb12u1) ...\r\nSelecting previously unselected package software-properties-common.\r\nPreparing to unpack .../50-software-properties-common_0.99.30-4.1~deb12u1_all.deb ...\r\nUnpacking software-properties-common (0.99.30-4.1~deb12u1) ...\r\nSelecting previously unselected package sshfs.\r\nPreparing to unpack .../51-sshfs_3.7.3-1.1_arm64.deb ...\r\nUnpacking sshfs (3.7.3-1.1) ...\r\nSelecting previously unselected package sysstat.\r\nPreparing to unpack .../52-sysstat_12.6.1-1_arm64.deb ...\r\nUnpacking sysstat (12.6.1-1) ...\r\nSetting up libdw1:arm64 (0.188-2.1) ...\r\nSetting up libnfsidmap1:arm64 (1:2.6.2-4+deb12u1) ...\r\nSetting up apt-transport-https (2.6.1) ...\r\nSetting up libxmlb2:arm64 (0.3.10-2) ...\r\nSetting up libsensors-config (1:3.6.0-7.1) ...\r\nSetting up libglib2.0-bin (2.74.6-2+deb12u8) ...\r\nSetting up libpackagekit-glib2-18:arm64 (1.2.6-5) ...\r\nSetting up rpcbind (1.2.6-6+b1) ...\r\nCreated symlink /etc/systemd/system/multi-user.target.wants/rpcbind.service → /lib/systemd/system/rpcbind.service.\r\r\nCreated symlink /etc/systemd/system/sockets.target.wants/rpcbind.socket → /lib/systemd/system/rpcbind.socket.\r\r\nSetting up python3-lazr.uri (1.0.6-3) ...\r\nSetting up libunwind8:arm64 (1.6.2-3) ...\r\nSetting up fuse3 (3.14.0-4) ...\r\nupdate-initramfs: deferring update (trigger activated)\r\nSetting up perl-modules-5.36 (5.36.0-7+deb12u3) ...\r\nSetting up dialog (1.3-20230209-1) ...\r\nSetting up libteamdctl0:arm64 (1.31-1) ...\r\nSetting up python3-wadllib (1.3.6-4) ...\r\nSetting up libevent-core-2.1-7:arm64 (2.1.12-stable-8) ...\r\nSetting up libgdbm-compat4:arm64 (1.23-3) ...\r\nSetting up libsensors5:arm64 (1:3.6.0-7.1) ...\r\nSetting up libnm0:arm64 (1.42.4-1+deb12u1) ...\r\nSetting up keyutils (1.6.3-2) ...\r\nSetting up libduktape207:arm64 (2.7.0-2) ...\r\nSetting up libmm-glib0:arm64 (1.20.4-1) ...\r\nSetting up libbluetooth3:arm64 (5.66-1+deb12u2) ...\r\nSetting up git-man (1:2.39.5-0+deb12u3) ...\r\nSetting up libgirepository-1.0-1:arm64 (1.74.0-3) ...\r\nSetting up sgml-base (1.31) ...\r\nSetting up libstemmer0d:arm64 (2.2.0-2) ...\r\nSetting up libndp0:arm64 (1.8-1+deb12u1) ...\r\nSetting up python3-lazr.restfulclient (0.14.5-1) ...\r\nSetting up sysstat (12.6.1-1) ...\r\n\r\nCreating config file /etc/default/sysstat with new version\r\nupdate-alternatives: using /usr/bin/sar.sysstat to provide /usr/bin/sar (sar) in auto mode\r\nCreated symlink /etc/systemd/system/sysstat.service.wants/sysstat-collect.timer → /lib/systemd/system/sysstat-collect.timer.\r\r\nCreated symlink /etc/systemd/system/sysstat.service.wants/sysstat-summary.timer → /lib/systemd/system/sysstat-summary.timer.\r\r\nCreated symlink /etc/systemd/system/multi-user.target.wants/sysstat.service → /lib/systemd/system/sysstat.service.\r\r\nSetting up libperl5.36:arm64 (5.36.0-7+deb12u3) ...\r\nSetting up iso-codes (4.15.0-1) ...\r\nSetting up libonig5:arm64 (6.9.8-1) ...\r\nSetting up libpolkit-gobject-1-0:arm64 (122-3) ...\r\nSetting up libgstreamer1.0-0:arm64 (1.22.0-2+deb12u1) ...\r\nSetcap worked! gst-ptp-helper is not suid!\r\nSetting up libjq1:arm64 (1.6-2.1+deb12u1) ...\r\nSetting up sshfs (3.7.3-1.1) ...\r\nSetting up libappstream4:arm64 (0.16.1-2) ...\r\nSetting up nfs-common (1:2.6.2-4+deb12u1) ...\r\n\r\nCreating config file /etc/idmapd.conf with new version\r\n\r\nCreating config file /etc/nfs.conf with new version\r\nAdding system user `statd' (UID 103) ...\r\nAdding new user `statd' (UID 103) with group `nogroup' ...\r\nNot creating home directory `/var/lib/nfs'.\r\nCreated symlink /etc/systemd/system/multi-user.target.wants/nfs-client.target → /lib/systemd/system/nfs-client.target.\r\r\nCreated symlink /etc/systemd/system/remote-fs.target.wants/nfs-client.target → /lib/systemd/system/nfs-client.target.\r\r\nauth-rpcgss-module.service is a disabled or a static unit, not starting it.\r\nnfs-idmapd.service is a disabled or a static unit, not starting it.\r\nnfs-utils.service is a disabled or a static unit, not starting it.\r\nproc-fs-nfsd.mount is a disabled or a static unit, not starting it.\r\nrpc-gssd.service is a disabled or a static unit, not starting it.\r\nrpc-statd-notify.service is a disabled or a static unit, not starting it.\r\nrpc-statd.service is a disabled or a static unit, not starting it.\r\nrpc-svcgssd.service is a disabled or a static unit, not starting it.\r\nrpc_pipefs.target is a disabled or a static unit, not starting it.\r\nvar-lib-nfs-rpc_pipefs.mount is a disabled or a static unit, not starting it.\r\nSetting up perl (5.36.0-7+deb12u3) ...\r\nSetting up gir1.2-glib-2.0:arm64 (1.74.0-3) ...\r\nSetting up xml-core (0.18+nmu1) ...\r\nSetting up jq (1.6-2.1+deb12u1) ...\r\nSetting up libpolkit-agent-1-0:arm64 (122-3) ...\r\nSetting up liberror-perl (0.17029-2) ...\r\nSetting up gir1.2-packagekitglib-1.0 (1.2.6-5) ...\r\nSetting up python3-gi (3.42.2-3+b1) ...\r\nSetting up git (1:2.39.5-0+deb12u3) ...\r\nSetting up python3-software-properties (0.99.30-4.1~deb12u1) ...\r\nProcessing triggers for initramfs-tools (0.142+deb12u3) ...\r\nupdate-initramfs: Generating /boot/initrd.img-6.1.0-42-arm64\r\nProcessing triggers for libc-bin (2.36-9+deb12u13) ...\r\nProcessing triggers for man-db (2.11.2-2) ...\r\nProcessing triggers for dbus (1.14.10-1~deb12u1) ...\r\nProcessing triggers for sgml-base (1.31) ...\r\nSetting up polkitd (122-3) ...\r\nCreating group 'polkitd' with GID 996.\r\r\nCreating user 'polkitd' (polkit) with UID 996 and GID 996.\r\r\nSetting up network-manager (1.42.4-1+deb12u1) ...\r\nCreated symlink /etc/systemd/system/dbus-org.freedesktop.nm-dispatcher.service → /lib/systemd/system/NetworkManager-dispatcher.service.\r\r\nCreated symlink /etc/systemd/system/network-online.target.wants/NetworkManager-wait-online.service → /lib/systemd/system/NetworkManager-wait-online.service.\r\r\nCreated symlink /etc/systemd/system/multi-user.target.wants/NetworkManager.service → /lib/systemd/system/NetworkManager.service.\r\r\nSetting up packagekit (1.2.6-5) ...\r\nCreated symlink /etc/systemd/user/sockets.target.wants/pk-debconf-helper.socket → /usr/lib/systemd/user/pk-debconf-helper.socket.\r\r\nSetting up software-properties-common (0.99.30-4.1~deb12u1) ...\r\nProcessing triggers for dbus (1.14.10-1~deb12u1) ...\r\nReading package lists...\nBuilding dependency tree...\nReading state information...\n0 upgraded, 0 newly installed, 0 to remove and 1 not upgraded.\n", + "error": null, + "tags": { + "operation": "install", + "server": "libre-wuji-wrk-0", + "component": "os", + "type": "component", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji" + } +} \ No newline at end of file diff --git a/crates/data/tasks/6224355f-5bd3-4975-abfa-76904ccf5608.json b/crates/data/tasks/6224355f-5bd3-4975-abfa-76904ccf5608.json new file mode 100644 index 0000000..3b9c091 --- /dev/null +++ b/crates/data/tasks/6224355f-5bd3-4975-abfa-76904ccf5608.json @@ -0,0 +1,23 @@ +{ + "id": "6224355f-5bd3-4975-abfa-76904ccf5608", + "name": "component_install_external_nfs", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_6224355f-5bd3-4975-abfa-76904ccf5608.tar.gz.b64' > /tmp/external_nfs.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/external_nfs.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/external_nfs && mkdir -p /tmp/external_nfs && tar xzf /tmp/external_nfs.tar.gz -C /tmp/external_nfs && cd /tmp/external_nfs && sudo bash install-external_nfs.sh install ; rc=$?; rm -f /tmp/external_nfs.tar.gz && rm -rf /tmp/external_nfs; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_6224355f-5bd3-4975-abfa-76904ccf5608.tar.gz.b64' /tmp/external_nfs.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T03:12:06.369821Z", + "started_at": "2026-04-21T03:12:06.700003Z", + "completed_at": "2026-04-21T03:12:10.904163Z", + "output": "\n/var/lib/data/shared 10.0.8.0/24(rw,sync,no_subtree_check,no_root_squash)\nnamespace/nfs-provisioner created\nstorageclass.storage.k8s.io/nfs-client created\nserviceaccount/nfs-client-provisioner created\nrole.rbac.authorization.k8s.io/leader-locking-nfs-client-provisioner created\nclusterrole.rbac.authorization.k8s.io/nfs-client-provisioner-runner created\nrolebinding.rbac.authorization.k8s.io/leader-locking-nfs-client-provisioner created\nclusterrolebinding.rbac.authorization.k8s.io/run-nfs-client-provisioner created\nstorageclass.storage.k8s.io/nfs-client unchanged\ndeployment.apps/nfs-client-provisioner created\n=== external-nfs: k8s resources applied ===\n", + "error": null, + "tags": { + "server": "libre-wuji-cp-0", + "component": "external_nfs", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install", + "type": "component" + } +} \ No newline at end of file diff --git a/crates/data/tasks/6612d1d5-7874-4ae5-8344-b63fc1f7a732.json b/crates/data/tasks/6612d1d5-7874-4ae5-8344-b63fc1f7a732.json new file mode 100644 index 0000000..2d94fa8 --- /dev/null +++ b/crates/data/tasks/6612d1d5-7874-4ae5-8344-b63fc1f7a732.json @@ -0,0 +1,23 @@ +{ + "id": "6612d1d5-7874-4ae5-8344-b63fc1f7a732", + "name": "component_install_runc", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_6612d1d5-7874-4ae5-8344-b63fc1f7a732.tar.gz.b64' > /tmp/runc.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/runc.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/runc && mkdir -p /tmp/runc && tar xzf /tmp/runc.tar.gz -C /tmp/runc && cd /tmp/runc && sudo bash install-runc.sh install ; rc=$?; rm -f /tmp/runc.tar.gz && rm -rf /tmp/runc; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_6612d1d5-7874-4ae5-8344-b63fc1f7a732.tar.gz.b64' /tmp/runc.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T01:44:52.940739Z", + "started_at": "2026-04-21T01:44:53.135909Z", + "completed_at": "2026-04-21T01:44:56.714270Z", + "output": "", + "error": null, + "tags": { + "operation": "install", + "type": "component", + "component": "runc", + "server": "libre-wuji-wrk-0", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji" + } +} \ No newline at end of file diff --git a/crates/data/tasks/6de0d750-9a37-4359-aba3-bc1ec1cb69d7.json b/crates/data/tasks/6de0d750-9a37-4359-aba3-bc1ec1cb69d7.json new file mode 100644 index 0000000..d4ea9ff --- /dev/null +++ b/crates/data/tasks/6de0d750-9a37-4359-aba3-bc1ec1cb69d7.json @@ -0,0 +1,23 @@ +{ + "id": "6de0d750-9a37-4359-aba3-bc1ec1cb69d7", + "name": "component_install_runc", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_6de0d750-9a37-4359-aba3-bc1ec1cb69d7.tar.gz.b64' > /tmp/runc.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/runc.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/runc && mkdir -p /tmp/runc && tar xzf /tmp/runc.tar.gz -C /tmp/runc && cd /tmp/runc && sudo bash install-runc.sh install ; rc=$?; rm -f /tmp/runc.tar.gz && rm -rf /tmp/runc; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_6de0d750-9a37-4359-aba3-bc1ec1cb69d7.tar.gz.b64' /tmp/runc.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T02:03:38.242599Z", + "started_at": "2026-04-21T02:03:38.442151Z", + "completed_at": "2026-04-21T02:03:41.909283Z", + "output": "", + "error": null, + "tags": { + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "server": "libre-wuji-cp-0", + "component": "runc", + "type": "component", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/73ec8084-028f-46bf-8689-8a929ded3424.json b/crates/data/tasks/73ec8084-028f-46bf-8689-8a929ded3424.json new file mode 100644 index 0000000..ee00235 --- /dev/null +++ b/crates/data/tasks/73ec8084-028f-46bf-8689-8a929ded3424.json @@ -0,0 +1,23 @@ +{ + "id": "73ec8084-028f-46bf-8689-8a929ded3424", + "name": "component_install_cilium", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_73ec8084-028f-46bf-8689-8a929ded3424.tar.gz.b64' > /tmp/cilium.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/cilium.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/cilium && mkdir -p /tmp/cilium && tar xzf /tmp/cilium.tar.gz -C /tmp/cilium && cd /tmp/cilium && sudo bash install-cilium.sh install ; rc=$?; rm -f /tmp/cilium.tar.gz && rm -rf /tmp/cilium; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_73ec8084-028f-46bf-8689-8a929ded3424.tar.gz.b64' /tmp/cilium.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Failed", + "created_at": "2026-04-20T11:29:35.426696Z", + "started_at": "2026-04-20T11:29:35.673655Z", + "completed_at": "2026-04-20T11:32:10.952690Z", + "output": null, + "error": "Command execution failed: E0420 11:32:08.760812 33196 request.go:1196] \"Unexpected error when reading response body\" err=\"context canceled\"\n\u001b[33m /¯¯\\\n\u001b[36m /¯¯\u001b[33m\\__/\u001b[32m¯¯\\\u001b[0m Cilium: \u001b[31m3 errors\u001b[0m\n\u001b[36m \\__\u001b[31m/¯¯\\\u001b[32m__/\u001b[0m Operator: \u001b[31m1 errors\u001b[0m\n\u001b[32m /¯¯\u001b[31m\\__/\u001b[35m¯¯\\\u001b[0m Envoy DaemonSet: \u001b[36mdisabled (using embedded mode)\u001b[0m\n\u001b[32m \\__\u001b[34m/¯¯\\\u001b[35m__/\u001b[0m Hubble Relay: \u001b[36mdisabled\u001b[0m\n\u001b[34m\u001b[34m\u001b[34m \\__/\u001b[0m ClusterMesh: \u001b[36mdisabled\u001b[0m\n\nDaemonSet cilium Desired: 1, Unavailable: \u001b[31m1/1\u001b[0m\nDeployment cilium-operator Desired: 1, Ready: \u001b[32m1/1\u001b[0m, Available: \u001b[32m1/1\u001b[0m\nContainers: cilium Running: \u001b[32m1\u001b[0m\n cilium-operator Running: \u001b[32m1\u001b[0m\n clustermesh-apiserver \n hubble-relay \nCluster Pods: 0/2 managed by Cilium\nHelm chart version: 1.19.1\nImage versions cilium quay.io/cilium/cilium:v1.19.1@sha256:41f1f74a0000de8656f1de4088ea00c8f2d49d6edea579034c73c5fd5fe01792: 1\n cilium-operator quay.io/cilium/operator-generic:v1.19.1@sha256:e7278d763e448bf6c184b0682cf98cdca078d58a27e1b2f3c906792670aa211a: 1\nErrors: cilium cilium 1 pods of DaemonSet cilium are not ready\n cilium cilium-hprzl unable to retrieve cilium status: container cilium-agent is in CrashLoopBackOff, exited with code 2: 026-04-20T11:31:56.668596484Z level=info msg=\"The Hubble static exporter is disabled\" module=agent.controlplane.hubble.hubble-exporters\ntime=2026-04-20T11:31:56.668648404Z level=info msg=\"The Hubble dynamic exporter is disabled\" module=agent.controlplane.hubble.hubble-exporters\ntime=2026-04-20T11:31:56.668753525Z level=info msg=\"The Hubble packet drop events emitter is disabled\" module=agent.controlplane.hubble.hubble-dropeventemitter\ntime=2026-04-20T11:31:56.669135167Z level=info msg=\"Certloader TLS watcher disabled\" module=agent.controlplane.hubble.hubble-metrics config=certloader-server-tls\ntime=2026-04-20T11:31:56.669429408Z level=info msg=\"The Hubble metrics server is disabled\" module=agent.controlplane.hubble.hubble-metrics\ntime=2026-04-20T11:31:56.684433654Z level=info msg=Invoked duration=110.122747ms function=\"cmd.configureAPIServer (cmd/cells.go:372)\"\ntime=2026-04-20T11:31:56.739056485Z level=info msg=\"Determined final XDP mode\" module=agent.datapath.datapath-xdp-config accelarationMode=disabled mode=disabled\ntime=2026-04-20T11:31:56.744031713Z level=info msg=\"Starting hive\"\ntime=2026-04-20T11:31:56.744087753Z level=info msg=\"Started gops server\" module=agent.infra.gops address=127.0.0.1:9890\ntime=2026-04-20T11:31:56.744609676Z level=info msg=\"Establishing connection to apiserver\" module=agent.infra.k8s-client ipAddr=https://127.0.0.1:6443\ntime=2026-04-20T11:31:56.753887569Z level=info msg=\"Connected to apiserver\" module=agent.infra.k8s-client\ntime=2026-04-20T11:31:56.755154536Z level=info msg=\"Waiting until all Cilium CRDs are available\" module=agent.infra.k8s-synced-crdsync\ntime=2026-04-20T11:31:57.756207516Z level=info msg=\"Still waiting for Cilium Operator to register CRDs\" module=agent.infra.k8s-synced-crdsync\n cilium cilium-hprzl container cilium-agent is in CrashLoopBackOff, pulling previous Pod logs for further investigation:\n2026-04-20T11:31:56.447359544Z time=2026-04-20T11:31:56.447107703Z level=info msg=\"Memory available for map entries (0.250% of 8126111744B): 20315279B\"\n2026-04-20T11:31:56.447422144Z time=2026-04-20T11:31:56.447175223Z level=info msg=\"option bpf-ct-global-tcp-max set by dynamic sizing to 131072\"\n2026-04-20T11:31:56.447432624Z time=2026-04-20T11:31:56.447183583Z level=info msg=\"option bpf-ct-global-any-max set by dynamic sizing to 65536\"\n2026-04-20T11:31:56.447438545Z time=2026-04-20T11:31:56.447189423Z level=info msg=\"option bpf-nat-global-max set by dynamic sizing to 131072\"\n2026-04-20T11:31:56.447443825Z time=2026-04-20T11:31:56.447194583Z level=info msg=\"option bpf-neigh-global-max set by dynamic sizing to 131072\"\n<...>\n2026-04-20T11:31:56.744344195Z time=2026-04-20T11:31:56.744087753Z level=info msg=\"Started gops server\" module=agent.infra.gops address=127.0.0.1:9890\n2026-04-20T11:31:56.744710917Z time=2026-04-20T11:31:56.744609676Z level=info msg=\"Establishing connection to apiserver\" module=agent.infra.k8s-client ipAddr=https://127.0.0.1:6443\n2026-04-20T11:31:56.754038090Z time=2026-04-20T11:31:56.753887569Z level=info msg=\"Connected to apiserver\" module=agent.infra.k8s-client\n2026-04-20T11:31:56.755255177Z time=2026-04-20T11:31:56.755154536Z level=info msg=\"Waiting until all Cilium CRDs are available\" module=agent.infra.k8s-synced-crdsync\n2026-04-20T11:31:57.756781079Z time=2026-04-20T11:31:57.756207516Z level=info msg=\"Still waiting for Cilium Operator to register CRDs\" module=agent.infra.k8s-synced-crdsync CRDs=\"[crd:ciliumendpoints.cilium.io crd:ciliumnodes.cilium.io crd:ciliumclusterwidenetworkpolicies.cilium.io crd:ciliumloadbalancerippools.cilium.io crd:ciliuml2announcementpolicies.cilium.io crd:ciliumpodippools.cilium.io crd:ciliumnetworkpolicies.cilium.io crd:ciliumcidrgroups.cilium.io]\"\n cilium-operator cilium-operator-98c8cb769-99b65 container cilium-operator is in CrashLoopBackOff, pulling previous Pod logs for further investigation:\n2026-04-20T11:31:44.471616199Z time=2026-04-20T11:31:44.471090716Z level=info msg=\" --agent-not-ready-taint-key='node.cilium.io/agent-not-ready'\" subsys=cilium-operator-generic\n2026-04-20T11:31:44.471686799Z time=2026-04-20T11:31:44.471166476Z level=info msg=\" --auto-create-cilium-pod-ip-pools=''\" subsys=cilium-operator-generic\n2026-04-20T11:31:44.471694839Z time=2026-04-20T11:31:44.471173516Z level=info msg=\" --auto-direct-node-routes='false'\" subsys=cilium-operator-generic\n2026-04-20T11:31:44.471700719Z time=2026-04-20T11:31:44.471178036Z level=info msg=\" --bpf-distributed-lru='false'\" subsys=cilium-operator-generic\n2026-04-20T11:31:44.471705879Z time=2026-04-20T11:31:44.471181957Z level=info msg=\" --bpf-events-drop-enabled='true'\" subsys=cilium-operator-generic\n<...>\n2026-04-20T11:31:49.922879517Z time=2026-04-20T11:31:49.922816157Z level=info msg=\"\\\"Attempting to acquire leader lease...\\\" lock=\\\"kube-system/cilium-operator-resource-lock\\\"\" subsys=klog\n2026-04-20T11:31:49.933275336Z time=2026-04-20T11:31:49.933157656Z level=info msg=\"Leader re-election complete\" module=operator newLeader=libre-wuji-cp-0-gmc9zkgbjt operatorID=libre-wuji-cp-0-gmc9zkgbjt\n2026-04-20T11:31:51.031729550Z time=2026-04-20T11:31:51.03158899Z level=info msg=\"Signal received\" signal=terminated\n2026-04-20T11:31:51.032300634Z time=2026-04-20T11:31:51.032235393Z level=info msg=\"Stopping hive\"\n2026-04-20T11:31:51.032569195Z time=2026-04-20T11:31:51.032513715Z level=fatal msg=\"Leader election lost, shutting down.\" module=operator operatorID=libre-wuji-cp-0-gmc9zkgbjt\n\nError: Unable to determine status: wait canceled, cilium agent container has crashed or was terminated: context canceled\n", + "tags": { + "operation": "install", + "component": "cilium", + "server": "libre-wuji-cp-0", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "type": "component" + } +} \ No newline at end of file diff --git a/crates/data/tasks/74d9a061-46f7-4b6b-8c47-dea468945159.json b/crates/data/tasks/74d9a061-46f7-4b6b-8c47-dea468945159.json new file mode 100644 index 0000000..568190a --- /dev/null +++ b/crates/data/tasks/74d9a061-46f7-4b6b-8c47-dea468945159.json @@ -0,0 +1,23 @@ +{ + "id": "74d9a061-46f7-4b6b-8c47-dea468945159", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_74d9a061-46f7-4b6b-8c47-dea468945159.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_74d9a061-46f7-4b6b-8c47-dea468945159.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T04:40:22.617905Z", + "started_at": "2026-04-21T04:40:22.946649Z", + "completed_at": "2026-04-21T04:40:26.600184Z", + "output": "", + "error": null, + "tags": { + "type": "component", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install", + "server": "libre-wuji-wrk-0", + "component": "kubernetes" + } +} \ No newline at end of file diff --git a/crates/data/tasks/7b483ced-4c7b-4808-b45b-31b445493819.json b/crates/data/tasks/7b483ced-4c7b-4808-b45b-31b445493819.json new file mode 100644 index 0000000..88e76fc --- /dev/null +++ b/crates/data/tasks/7b483ced-4c7b-4808-b45b-31b445493819.json @@ -0,0 +1,23 @@ +{ + "id": "7b483ced-4c7b-4808-b45b-31b445493819", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_7b483ced-4c7b-4808-b45b-31b445493819.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_7b483ced-4c7b-4808-b45b-31b445493819.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T02:30:44.216076Z", + "started_at": "2026-04-21T02:30:44.639991Z", + "completed_at": "2026-04-21T02:30:59.296254Z", + "output": "Hit:1 http://mirror.hetzner.com/debian/packages bookworm InRelease\nHit:2 http://mirror.hetzner.com/debian/packages bookworm-backports InRelease\nHit:3 http://mirror.hetzner.com/debian/packages bookworm-updates InRelease\nHit:4 http://mirror.hetzner.com/debian/security bookworm-security InRelease\nHit:5 http://deb.debian.org/debian bookworm InRelease\nHit:6 http://security.debian.org/debian-security bookworm-security InRelease\nHit:7 http://deb.debian.org/debian bookworm-updates InRelease\nReading package lists...\nReading package lists...\nBuilding dependency tree...\nReading state information...\napt-transport-https is already the newest version (2.6.1).\ncurl is already the newest version (7.88.1-10+deb12u14).\nThe following NEW packages will be installed:\n gnupg2\n0 upgraded, 1 newly installed, 0 to remove and 1 not upgraded.\nNeed to get 446 kB of archives.\nAfter this operation, 465 kB of additional disk space will be used.\nGet:1 http://deb.debian.org/debian bookworm/main arm64 gnupg2 all 2.2.40-1.1+deb12u2 [446 kB]\nFetched 446 kB in 0s (11.1 MB/s)\nSelecting previously unselected package gnupg2.\r\n(Reading database ... \r(Reading database ... 5%\r(Reading database ... 10%\r(Reading database ... 15%\r(Reading database ... 20%\r(Reading database ... 25%\r(Reading database ... 30%\r(Reading database ... 35%\r(Reading database ... 40%\r(Reading database ... 45%\r(Reading database ... 50%\r(Reading database ... 55%\r(Reading database ... 60%\r(Reading database ... 65%\r(Reading database ... 70%\r(Reading database ... 75%\r(Reading database ... 80%\r(Reading database ... 85%\r(Reading database ... 90%\r(Reading database ... 95%\r(Reading database ... 100%\r(Reading database ... 41877 files and directories currently installed.)\r\nPreparing to unpack .../gnupg2_2.2.40-1.1+deb12u2_all.deb ...\r\nUnpacking gnupg2 (2.2.40-1.1+deb12u2) ...\r\nSetting up gnupg2 (2.2.40-1.1+deb12u2) ...\r\nProcessing triggers for man-db (2.11.2-2) ...\r\nHit:1 http://mirror.hetzner.com/debian/packages bookworm InRelease\nHit:2 http://deb.debian.org/debian bookworm InRelease\nHit:3 http://security.debian.org/debian-security bookworm-security InRelease\nHit:4 http://mirror.hetzner.com/debian/packages bookworm-backports InRelease\nHit:5 http://mirror.hetzner.com/debian/packages bookworm-updates InRelease\nHit:6 http://mirror.hetzner.com/debian/security bookworm-security InRelease\nHit:7 http://deb.debian.org/debian bookworm-updates InRelease\nGet:8 https://prod-cdn.packages.k8s.io/repositories/isv:/kubernetes:/core:/stable:/v1.35/deb InRelease [1227 B]\nGet:9 https://prod-cdn.packages.k8s.io/repositories/isv:/kubernetes:/core:/stable:/v1.35/deb Packages [7603 B]\nFetched 8830 B in 1s (8411 B/s)\nReading package lists...\nkubelet was already not on hold.\nkubectl was already not on hold.\nkubeadm was already not on hold.\nReading package lists...\nBuilding dependency tree...\nReading state information...\nThe following additional packages will be installed:\n cri-tools kubernetes-cni\nThe following NEW packages will be installed:\n cri-tools kubeadm kubectl kubelet kubernetes-cni\n0 upgraded, 5 newly installed, 0 to remove and 1 not upgraded.\nNeed to get 81.7 MB of archives.\nAfter this operation, 311 MB of additional disk space will be used.\nGet:1 https://prod-cdn.packages.k8s.io/repositories/isv:/kubernetes:/core:/stable:/v1.35/deb cri-tools 1.35.0-1.1 [14.5 MB]\nGet:2 https://prod-cdn.packages.k8s.io/repositories/isv:/kubernetes:/core:/stable:/v1.35/deb kubeadm 1.35.4-1.1 [10.6 MB]\nGet:3 https://prod-cdn.packages.k8s.io/repositories/isv:/kubernetes:/core:/stable:/v1.35/deb kubectl 1.35.4-1.1 [9768 kB]\nGet:4 https://prod-cdn.packages.k8s.io/repositories/isv:/kubernetes:/core:/stable:/v1.35/deb kubernetes-cni 1.8.0-1.1 [36.0 MB]\nGet:5 https://prod-cdn.packages.k8s.io/repositories/isv:/kubernetes:/core:/stable:/v1.35/deb kubelet 1.35.4-1.1 [10.8 MB]\nFetched 81.7 MB in 1s (96.5 MB/s)\nSelecting previously unselected package cri-tools.\r\n(Reading database ... \r(Reading database ... 5%\r(Reading database ... 10%\r(Reading database ... 15%\r(Reading database ... 20%\r(Reading database ... 25%\r(Reading database ... 30%\r(Reading database ... 35%\r(Reading database ... 40%\r(Reading database ... 45%\r(Reading database ... 50%\r(Reading database ... 55%\r(Reading database ... 60%\r(Reading database ... 65%\r(Reading database ... 70%\r(Reading database ... 75%\r(Reading database ... 80%\r(Reading database ... 85%\r(Reading database ... 90%\r(Reading database ... 95%\r(Reading database ... 100%\r(Reading database ... 41884 files and directories currently installed.)\r\nPreparing to unpack .../cri-tools_1.35.0-1.1_arm64.deb ...\r\nUnpacking cri-tools (1.35.0-1.1) ...\r\nSelecting previously unselected package kubeadm.\r\nPreparing to unpack .../kubeadm_1.35.4-1.1_arm64.deb ...\r\nUnpacking kubeadm (1.35.4-1.1) ...\r\nSelecting previously unselected package kubectl.\r\nPreparing to unpack .../kubectl_1.35.4-1.1_arm64.deb ...\r\nUnpacking kubectl (1.35.4-1.1) ...\r\nSelecting previously unselected package kubernetes-cni.\r\nPreparing to unpack .../kubernetes-cni_1.8.0-1.1_arm64.deb ...\r\nUnpacking kubernetes-cni (1.8.0-1.1) ...\r\nSelecting previously unselected package kubelet.\r\nPreparing to unpack .../kubelet_1.35.4-1.1_arm64.deb ...\r\nUnpacking kubelet (1.35.4-1.1) ...\r\nSetting up kubectl (1.35.4-1.1) ...\r\nSetting up cri-tools (1.35.0-1.1) ...\r\nSetting up kubernetes-cni (1.8.0-1.1) ...\r\nSetting up kubeadm (1.35.4-1.1) ...\r\nSetting up kubelet (1.35.4-1.1) ...\r\nkubelet set on hold.\nkubectl set on hold.\nkubeadm set on hold.\nNo k8s_join.sh found\n", + "error": null, + "tags": { + "operation": "install", + "component": "kubernetes", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "type": "component", + "server": "libre-wuji-wrk-0" + } +} \ No newline at end of file diff --git a/crates/data/tasks/7c3133c6-72da-44fe-bc76-a460a7fb49b6.json b/crates/data/tasks/7c3133c6-72da-44fe-bc76-a460a7fb49b6.json new file mode 100644 index 0000000..273e3c7 --- /dev/null +++ b/crates/data/tasks/7c3133c6-72da-44fe-bc76-a460a7fb49b6.json @@ -0,0 +1,23 @@ +{ + "id": "7c3133c6-72da-44fe-bc76-a460a7fb49b6", + "name": "component_install_democratic_csi", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_7c3133c6-72da-44fe-bc76-a460a7fb49b6.tar.gz.b64' > /tmp/democratic_csi.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/democratic_csi.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/democratic_csi && mkdir -p /tmp/democratic_csi && tar xzf /tmp/democratic_csi.tar.gz -C /tmp/democratic_csi && cd /tmp/democratic_csi && sudo bash install-democratic_csi.sh install ; rc=$?; rm -f /tmp/democratic_csi.tar.gz && rm -rf /tmp/democratic_csi; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_7c3133c6-72da-44fe-bc76-a460a7fb49b6.tar.gz.b64' /tmp/democratic_csi.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T03:24:07.148882Z", + "started_at": "2026-04-21T03:24:07.457517Z", + "completed_at": "2026-04-21T03:24:14.875458Z", + "output": "=== democratic-csi: v0.14.6 ===\n=== democratic-csi: deploying nfs-client driver (server=10.0.8.20) ===\nnamespace/democratic-csi unchanged\n\"democratic-csi\" has been added to your repositories\nHang tight while we grab the latest from your chart repositories...\n...Successfully got an update from the \"democratic-csi\" chart repository\nUpdate Complete. ⎈Happy Helming!⎈\nserviceaccount/democratic-csi-controller-sa unchanged\nserviceaccount/democratic-csi-node-sa unchanged\nsecret/democratic-csi-driver-config configured\nconfigmap/democratic-csi unchanged\nstorageclass.storage.k8s.io/nfs-shared configured\nclusterrole.rbac.authorization.k8s.io/democratic-csi-controller-cr unchanged\nclusterrole.rbac.authorization.k8s.io/democratic-csi-node-cr unchanged\nclusterrolebinding.rbac.authorization.k8s.io/democratic-csi-controller-rb unchanged\nclusterrolebinding.rbac.authorization.k8s.io/democratic-csi-node-rb unchanged\ndaemonset.apps/democratic-csi-node configured\ndeployment.apps/democratic-csi-controller configured\ncsidriver.storage.k8s.io/org.democratic-csi.nfs-client unchanged\n=== democratic-csi: waiting for controller ===\nWaiting for deployment \"democratic-csi-controller\" rollout to finish: 1 old replicas are pending termination...\nWaiting for deployment \"democratic-csi-controller\" rollout to finish: 1 old replicas are pending termination...\nWaiting for deployment \"democratic-csi-controller\" rollout to finish: 1 old replicas are pending termination...\ndeployment \"democratic-csi-controller\" successfully rolled out\n=== democratic-csi: ready (nfs=10.0.8.20/var/lib/data/shared, class=nfs-shared) ===\n", + "error": null, + "tags": { + "operation": "install", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "type": "component", + "component": "democratic_csi", + "server": "libre-wuji-cp-0" + } +} \ No newline at end of file diff --git a/crates/data/tasks/7eb7eac3-2d13-4aba-806e-a5b7be0a69f9.json b/crates/data/tasks/7eb7eac3-2d13-4aba-806e-a5b7be0a69f9.json new file mode 100644 index 0000000..26f77b4 --- /dev/null +++ b/crates/data/tasks/7eb7eac3-2d13-4aba-806e-a5b7be0a69f9.json @@ -0,0 +1,23 @@ +{ + "id": "7eb7eac3-2d13-4aba-806e-a5b7be0a69f9", + "name": "component_install_resolv", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_7eb7eac3-2d13-4aba-806e-a5b7be0a69f9.tar.gz.b64' > /tmp/resolv.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/resolv.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/resolv && mkdir -p /tmp/resolv && tar xzf /tmp/resolv.tar.gz -C /tmp/resolv && cd /tmp/resolv && sudo bash install-resolv.sh install ; rc=$?; rm -f /tmp/resolv.tar.gz && rm -rf /tmp/resolv; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_7eb7eac3-2d13-4aba-806e-a5b7be0a69f9.tar.gz.b64' /tmp/resolv.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-20T10:41:09.455086Z", + "started_at": "2026-04-20T10:41:09.633091Z", + "completed_at": "2026-04-20T10:41:12.430204Z", + "output": "", + "error": null, + "tags": { + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install", + "type": "component", + "server": "libre-wuji-wrk-0", + "component": "resolv" + } +} \ No newline at end of file diff --git a/crates/data/tasks/833bcce1-a2f7-4f46-8481-f6d61c014bea.json b/crates/data/tasks/833bcce1-a2f7-4f46-8481-f6d61c014bea.json new file mode 100644 index 0000000..bb10f19 --- /dev/null +++ b/crates/data/tasks/833bcce1-a2f7-4f46-8481-f6d61c014bea.json @@ -0,0 +1,23 @@ +{ + "id": "833bcce1-a2f7-4f46-8481-f6d61c014bea", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_833bcce1-a2f7-4f46-8481-f6d61c014bea.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_833bcce1-a2f7-4f46-8481-f6d61c014bea.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T02:50:16.166410Z", + "started_at": "2026-04-21T02:50:16.640263Z", + "completed_at": "2026-04-21T02:50:20.009752Z", + "output": "No k8s_join.sh found\n", + "error": null, + "tags": { + "component": "kubernetes", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install", + "type": "component", + "server": "libre-wuji-wrk-0" + } +} \ No newline at end of file diff --git a/crates/data/tasks/8757d8c5-a0c6-4eaf-9d21-e4eedc5a1a7c.json b/crates/data/tasks/8757d8c5-a0c6-4eaf-9d21-e4eedc5a1a7c.json new file mode 100644 index 0000000..e6984b0 --- /dev/null +++ b/crates/data/tasks/8757d8c5-a0c6-4eaf-9d21-e4eedc5a1a7c.json @@ -0,0 +1,23 @@ +{ + "id": "8757d8c5-a0c6-4eaf-9d21-e4eedc5a1a7c", + "name": "component_delete_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_8757d8c5-a0c6-4eaf-9d21-e4eedc5a1a7c.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh delete ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_8757d8c5-a0c6-4eaf-9d21-e4eedc5a1a7c.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T03:03:46.520878Z", + "started_at": "2026-04-21T03:03:46.786009Z", + "completed_at": "2026-04-21T03:03:50.552931Z", + "output": "Warning: probe patch failed\n2026_04_21_030350 | apiserver probes patched: startup=300s liveness=120s readiness=15s\n/etc/kubernetes/admin.conf not found\n", + "error": null, + "tags": { + "component": "kubernetes", + "server": "libre-wuji-cp-0", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "delete", + "type": "component" + } +} \ No newline at end of file diff --git a/crates/data/tasks/88faabb6-6a84-4ff7-a536-4fc0d0571fe9.json b/crates/data/tasks/88faabb6-6a84-4ff7-a536-4fc0d0571fe9.json new file mode 100644 index 0000000..0297d17 --- /dev/null +++ b/crates/data/tasks/88faabb6-6a84-4ff7-a536-4fc0d0571fe9.json @@ -0,0 +1,23 @@ +{ + "id": "88faabb6-6a84-4ff7-a536-4fc0d0571fe9", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_88faabb6-6a84-4ff7-a536-4fc0d0571fe9.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_88faabb6-6a84-4ff7-a536-4fc0d0571fe9.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T04:44:48.812368Z", + "started_at": "2026-04-21T04:44:48.926695Z", + "completed_at": "2026-04-21T04:45:02.046779Z", + "output": "", + "error": null, + "tags": { + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "server": "libre-wuji-wrk-0", + "operation": "install", + "type": "component", + "component": "kubernetes" + } +} \ No newline at end of file diff --git a/crates/data/tasks/8a46efcd-1408-4e9b-afa1-e888cfb20763.json b/crates/data/tasks/8a46efcd-1408-4e9b-afa1-e888cfb20763.json new file mode 100644 index 0000000..33856df --- /dev/null +++ b/crates/data/tasks/8a46efcd-1408-4e9b-afa1-e888cfb20763.json @@ -0,0 +1,23 @@ +{ + "id": "8a46efcd-1408-4e9b-afa1-e888cfb20763", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_8a46efcd-1408-4e9b-afa1-e888cfb20763.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_8a46efcd-1408-4e9b-afa1-e888cfb20763.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T03:14:07.751758Z", + "started_at": "2026-04-21T03:14:08.170015Z", + "completed_at": "2026-04-21T03:14:11.606952Z", + "output": "No k8s_join.sh found\n", + "error": null, + "tags": { + "type": "component", + "server": "libre-wuji-wrk-0", + "operation": "install", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "component": "kubernetes" + } +} \ No newline at end of file diff --git a/crates/data/tasks/8a55c188-4b95-493c-8587-c45693be27bd.json b/crates/data/tasks/8a55c188-4b95-493c-8587-c45693be27bd.json new file mode 100644 index 0000000..45cf4eb --- /dev/null +++ b/crates/data/tasks/8a55c188-4b95-493c-8587-c45693be27bd.json @@ -0,0 +1,23 @@ +{ + "id": "8a55c188-4b95-493c-8587-c45693be27bd", + "name": "component_install_hetzner_csi", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_8a55c188-4b95-493c-8587-c45693be27bd.tar.gz.b64' > /tmp/hetzner_csi.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/hetzner_csi.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/hetzner_csi && mkdir -p /tmp/hetzner_csi && tar xzf /tmp/hetzner_csi.tar.gz -C /tmp/hetzner_csi && cd /tmp/hetzner_csi && sudo bash install-hetzner_csi.sh install ; rc=$?; rm -f /tmp/hetzner_csi.tar.gz && rm -rf /tmp/hetzner_csi; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_8a55c188-4b95-493c-8587-c45693be27bd.tar.gz.b64' /tmp/hetzner_csi.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Failed", + "created_at": "2026-04-20T11:38:28.585912Z", + "started_at": "2026-04-20T11:38:28.642963Z", + "completed_at": "2026-04-20T11:38:31.510882Z", + "output": null, + "error": "Command execution failed: ERROR: Secret 'hcloud' not found in kube-system.\n Create it first: kubectl -n kube-system create secret generic hcloud --from-literal=token=\n", + "tags": { + "type": "component", + "server": "libre-wuji-cp-0", + "component": "hetzner_csi", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/8c3476c2-c9d7-4960-a5a1-d1179dd3f341.json b/crates/data/tasks/8c3476c2-c9d7-4960-a5a1-d1179dd3f341.json new file mode 100644 index 0000000..64be14f --- /dev/null +++ b/crates/data/tasks/8c3476c2-c9d7-4960-a5a1-d1179dd3f341.json @@ -0,0 +1,23 @@ +{ + "id": "8c3476c2-c9d7-4960-a5a1-d1179dd3f341", + "name": "component_install_containerd", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_8c3476c2-c9d7-4960-a5a1-d1179dd3f341.tar.gz.b64' > /tmp/containerd.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/containerd.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/containerd && mkdir -p /tmp/containerd && tar xzf /tmp/containerd.tar.gz -C /tmp/containerd && cd /tmp/containerd && sudo bash install-containerd.sh install ; rc=$?; rm -f /tmp/containerd.tar.gz && rm -rf /tmp/containerd; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_8c3476c2-c9d7-4960-a5a1-d1179dd3f341.tar.gz.b64' /tmp/containerd.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-20T10:33:08.502624Z", + "started_at": "2026-04-20T10:33:08.791708Z", + "completed_at": "2026-04-20T10:33:12.476836Z", + "output": "", + "error": null, + "tags": { + "type": "component", + "server": "libre-wuji-wrk-0", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install", + "component": "containerd" + } +} \ No newline at end of file diff --git a/crates/data/tasks/916c81ff-3a37-4c59-848e-91e9b66e9033.json b/crates/data/tasks/916c81ff-3a37-4c59-848e-91e9b66e9033.json new file mode 100644 index 0000000..69e5fb3 --- /dev/null +++ b/crates/data/tasks/916c81ff-3a37-4c59-848e-91e9b66e9033.json @@ -0,0 +1,23 @@ +{ + "id": "916c81ff-3a37-4c59-848e-91e9b66e9033", + "name": "component_install_kubernetes_worker", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_916c81ff-3a37-4c59-848e-91e9b66e9033.tar.gz.b64' > /tmp/kubernetes_worker.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes_worker.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes_worker && mkdir -p /tmp/kubernetes_worker && tar xzf /tmp/kubernetes_worker.tar.gz -C /tmp/kubernetes_worker && cd /tmp/kubernetes_worker && sudo bash install-kubernetes_worker.sh install ; rc=$?; rm -f /tmp/kubernetes_worker.tar.gz && rm -rf /tmp/kubernetes_worker; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_916c81ff-3a37-4c59-848e-91e9b66e9033.tar.gz.b64' /tmp/kubernetes_worker.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T05:29:02.071408Z", + "started_at": "2026-04-21T05:29:02.559421Z", + "completed_at": "2026-04-21T05:29:07.412920Z", + "output": "", + "error": null, + "tags": { + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "type": "component", + "component": "kubernetes_worker", + "operation": "install", + "server": "libre-wuji-wrk-0" + } +} \ No newline at end of file diff --git a/crates/data/tasks/93d88f69-cf9d-4024-a77f-667013207df1.json b/crates/data/tasks/93d88f69-cf9d-4024-a77f-667013207df1.json new file mode 100644 index 0000000..bb94caf --- /dev/null +++ b/crates/data/tasks/93d88f69-cf9d-4024-a77f-667013207df1.json @@ -0,0 +1,23 @@ +{ + "id": "93d88f69-cf9d-4024-a77f-667013207df1", + "name": "component_install_youki", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_93d88f69-cf9d-4024-a77f-667013207df1.tar.gz.b64' > /tmp/youki.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/youki.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/youki && mkdir -p /tmp/youki && tar xzf /tmp/youki.tar.gz -C /tmp/youki && cd /tmp/youki && sudo bash install-youki.sh install ; rc=$?; rm -f /tmp/youki.tar.gz && rm -rf /tmp/youki; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_93d88f69-cf9d-4024-a77f-667013207df1.tar.gz.b64' /tmp/youki.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-20T10:14:24.695667Z", + "started_at": "2026-04-20T10:14:24.980518Z", + "completed_at": "2026-04-20T10:14:28.544664Z", + "output": "", + "error": null, + "tags": { + "component": "youki", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install", + "type": "component", + "server": "libre-wuji-cp-0" + } +} \ No newline at end of file diff --git a/crates/data/tasks/9b4cdb19-3e88-40bb-8775-18dac0306027.json b/crates/data/tasks/9b4cdb19-3e88-40bb-8775-18dac0306027.json new file mode 100644 index 0000000..1ce3447 --- /dev/null +++ b/crates/data/tasks/9b4cdb19-3e88-40bb-8775-18dac0306027.json @@ -0,0 +1,23 @@ +{ + "id": "9b4cdb19-3e88-40bb-8775-18dac0306027", + "name": "component_install_hetzner_csi", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_9b4cdb19-3e88-40bb-8775-18dac0306027.tar.gz.b64' > /tmp/hetzner_csi.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/hetzner_csi.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/hetzner_csi && mkdir -p /tmp/hetzner_csi && tar xzf /tmp/hetzner_csi.tar.gz -C /tmp/hetzner_csi && cd /tmp/hetzner_csi && sudo bash install-hetzner_csi.sh install ; rc=$?; rm -f /tmp/hetzner_csi.tar.gz && rm -rf /tmp/hetzner_csi; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_9b4cdb19-3e88-40bb-8775-18dac0306027.tar.gz.b64' /tmp/hetzner_csi.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Failed", + "created_at": "2026-04-21T02:26:18.119402Z", + "started_at": "2026-04-21T02:26:18.619608Z", + "completed_at": "2026-04-21T02:26:21.474526Z", + "output": null, + "error": "Command execution failed: ERROR: Secret 'hcloud' not found in kube-system.\n Create it first: kubectl -n kube-system create secret generic hcloud --from-literal=token=\n", + "tags": { + "type": "component", + "component": "hetzner_csi", + "server": "libre-wuji-cp-0", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/9c5a29da-d3b5-48a6-a5d0-e39f78295c5d.json b/crates/data/tasks/9c5a29da-d3b5-48a6-a5d0-e39f78295c5d.json new file mode 100644 index 0000000..3004362 --- /dev/null +++ b/crates/data/tasks/9c5a29da-d3b5-48a6-a5d0-e39f78295c5d.json @@ -0,0 +1,23 @@ +{ + "id": "9c5a29da-d3b5-48a6-a5d0-e39f78295c5d", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_9c5a29da-d3b5-48a6-a5d0-e39f78295c5d.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_9c5a29da-d3b5-48a6-a5d0-e39f78295c5d.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T04:51:14.673926Z", + "started_at": "2026-04-21T04:51:14.890485Z", + "completed_at": "2026-04-21T04:51:20.742925Z", + "output": "", + "error": null, + "tags": { + "server": "libre-wuji-wrk-0", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install", + "type": "component", + "component": "kubernetes" + } +} \ No newline at end of file diff --git a/crates/data/tasks/9d4168e5-09e5-4213-acb6-9d355178500a.json b/crates/data/tasks/9d4168e5-09e5-4213-acb6-9d355178500a.json new file mode 100644 index 0000000..068b144 --- /dev/null +++ b/crates/data/tasks/9d4168e5-09e5-4213-acb6-9d355178500a.json @@ -0,0 +1,23 @@ +{ + "id": "9d4168e5-09e5-4213-acb6-9d355178500a", + "name": "component_install_kubernetes_worker", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_9d4168e5-09e5-4213-acb6-9d355178500a.tar.gz.b64' > /tmp/kubernetes_worker.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes_worker.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes_worker && mkdir -p /tmp/kubernetes_worker && tar xzf /tmp/kubernetes_worker.tar.gz -C /tmp/kubernetes_worker && cd /tmp/kubernetes_worker && sudo bash install-kubernetes_worker.sh install ; rc=$?; rm -f /tmp/kubernetes_worker.tar.gz && rm -rf /tmp/kubernetes_worker; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_9d4168e5-09e5-4213-acb6-9d355178500a.tar.gz.b64' /tmp/kubernetes_worker.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T04:27:58.228462Z", + "started_at": "2026-04-21T04:27:58.614926Z", + "completed_at": "2026-04-21T04:28:08.794078Z", + "output": "", + "error": null, + "tags": { + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install", + "component": "kubernetes_worker", + "type": "component", + "server": "libre-wuji-wrk-0" + } +} \ No newline at end of file diff --git a/crates/data/tasks/9f4bde77-1b97-4113-a824-1ddede99763e.json b/crates/data/tasks/9f4bde77-1b97-4113-a824-1ddede99763e.json new file mode 100644 index 0000000..cfc008e --- /dev/null +++ b/crates/data/tasks/9f4bde77-1b97-4113-a824-1ddede99763e.json @@ -0,0 +1,23 @@ +{ + "id": "9f4bde77-1b97-4113-a824-1ddede99763e", + "name": "component_install_fip", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_9f4bde77-1b97-4113-a824-1ddede99763e.tar.gz.b64' > /tmp/fip.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/fip.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/fip && mkdir -p /tmp/fip && tar xzf /tmp/fip.tar.gz -C /tmp/fip && cd /tmp/fip && sudo bash install-fip.sh install ; rc=$?; rm -f /tmp/fip.tar.gz && rm -rf /tmp/fip; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_9f4bde77-1b97-4113-a824-1ddede99763e.tar.gz.b64' /tmp/fip.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T01:43:55.801859Z", + "started_at": "2026-04-21T01:43:55.932210Z", + "completed_at": "2026-04-21T01:43:59.790169Z", + "output": "Written /etc/network/interfaces.d/60-floating-ip\nFloating IP 49.12.115.133 active on eth0\n", + "error": null, + "tags": { + "type": "component", + "component": "fip", + "server": "libre-wuji-wrk-0", + "operation": "install", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji" + } +} \ No newline at end of file diff --git a/crates/data/tasks/9f8d7eca-89cd-43b4-832c-2af85c6b454b.json b/crates/data/tasks/9f8d7eca-89cd-43b4-832c-2af85c6b454b.json new file mode 100644 index 0000000..c4f6ac3 --- /dev/null +++ b/crates/data/tasks/9f8d7eca-89cd-43b4-832c-2af85c6b454b.json @@ -0,0 +1,23 @@ +{ + "id": "9f8d7eca-89cd-43b4-832c-2af85c6b454b", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_9f8d7eca-89cd-43b4-832c-2af85c6b454b.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_9f8d7eca-89cd-43b4-832c-2af85c6b454b.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T04:47:10.571187Z", + "started_at": "2026-04-21T04:47:10.964185Z", + "completed_at": "2026-04-21T04:47:14.990191Z", + "output": "", + "error": null, + "tags": { + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "component": "kubernetes", + "server": "libre-wuji-wrk-0", + "operation": "install", + "type": "component" + } +} \ No newline at end of file diff --git a/crates/data/tasks/a05c36ec-888e-421d-abe8-5152115eb30e.json b/crates/data/tasks/a05c36ec-888e-421d-abe8-5152115eb30e.json new file mode 100644 index 0000000..4b1cdda --- /dev/null +++ b/crates/data/tasks/a05c36ec-888e-421d-abe8-5152115eb30e.json @@ -0,0 +1,23 @@ +{ + "id": "a05c36ec-888e-421d-abe8-5152115eb30e", + "name": "component_install_crun", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_a05c36ec-888e-421d-abe8-5152115eb30e.tar.gz.b64' > /tmp/crun.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/crun.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/crun && mkdir -p /tmp/crun && tar xzf /tmp/crun.tar.gz -C /tmp/crun && cd /tmp/crun && sudo bash install-crun.sh install ; rc=$?; rm -f /tmp/crun.tar.gz && rm -rf /tmp/crun; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_a05c36ec-888e-421d-abe8-5152115eb30e.tar.gz.b64' /tmp/crun.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-20T10:13:29.067454Z", + "started_at": "2026-04-20T10:13:29.264193Z", + "completed_at": "2026-04-20T10:13:32.938581Z", + "output": "", + "error": null, + "tags": { + "operation": "install", + "type": "component", + "server": "libre-wuji-cp-0", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "component": "crun" + } +} \ No newline at end of file diff --git a/crates/data/tasks/a2e69222-4595-44c1-a335-73a06d2e7e84.json b/crates/data/tasks/a2e69222-4595-44c1-a335-73a06d2e7e84.json new file mode 100644 index 0000000..76b135c --- /dev/null +++ b/crates/data/tasks/a2e69222-4595-44c1-a335-73a06d2e7e84.json @@ -0,0 +1,23 @@ +{ + "id": "a2e69222-4595-44c1-a335-73a06d2e7e84", + "name": "component_install_democratic_csi", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_a2e69222-4595-44c1-a335-73a06d2e7e84.tar.gz.b64' > /tmp/democratic_csi.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/democratic_csi.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/democratic_csi && mkdir -p /tmp/democratic_csi && tar xzf /tmp/democratic_csi.tar.gz -C /tmp/democratic_csi && cd /tmp/democratic_csi && sudo bash install-democratic_csi.sh install ; rc=$?; rm -f /tmp/democratic_csi.tar.gz && rm -rf /tmp/democratic_csi; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_a2e69222-4595-44c1-a335-73a06d2e7e84.tar.gz.b64' /tmp/democratic_csi.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T03:09:20.781875Z", + "started_at": "2026-04-21T03:09:21.076688Z", + "completed_at": "2026-04-21T03:09:24.131426Z", + "output": "=== democratic-csi: v0.14.6 ===\n=== democratic-csi: nfs_server empty — deferred (namespace only) ===\nnamespace/democratic-csi created\n Set nfs_server to the private IP of the external_nfs node and re-run.\n", + "error": null, + "tags": { + "type": "component", + "component": "democratic_csi", + "server": "libre-wuji-cp-0", + "operation": "install", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji" + } +} \ No newline at end of file diff --git a/crates/data/tasks/a3db0632-51aa-4285-93be-aa38b849a9e3.json b/crates/data/tasks/a3db0632-51aa-4285-93be-aa38b849a9e3.json new file mode 100644 index 0000000..85b037b --- /dev/null +++ b/crates/data/tasks/a3db0632-51aa-4285-93be-aa38b849a9e3.json @@ -0,0 +1,23 @@ +{ + "id": "a3db0632-51aa-4285-93be-aa38b849a9e3", + "name": "component_install_crio", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_a3db0632-51aa-4285-93be-aa38b849a9e3.tar.gz.b64' > /tmp/crio.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/crio.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/crio && mkdir -p /tmp/crio && tar xzf /tmp/crio.tar.gz -C /tmp/crio && cd /tmp/crio && sudo bash install-crio.sh install ; rc=$?; rm -f /tmp/crio.tar.gz && rm -rf /tmp/crio; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_a3db0632-51aa-4285-93be-aa38b849a9e3.tar.gz.b64' /tmp/crio.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T02:10:46.195430Z", + "started_at": "2026-04-21T02:10:46.576188Z", + "completed_at": "2026-04-21T02:10:55.682150Z", + "output": "/opt/cni/bin/*\n/etc/cni/net.d/10-crio-bridge.conflist.disabled\n/usr/libexec/crio/conmon\n/usr/libexec/crio/conmonrs\n/usr/libexec/crio/crun\n/usr/libexec/crio/runc\n/usr/libexec/crio/crio-credential-provider\n/usr/local/bin/crio\n/usr/local/bin/pinns\n/usr/local/bin/crictl\n/etc/crictl.yaml\n/usr/local/share/oci-umount/oci-umount.d/crio-umount.conf\n/etc/default/crio\n/etc/crio/policy.json\n/etc/crio/crio.conf.d/10-crio.conf\n/usr/local/share/man/man5/crio.conf.5\n/usr/local/share/man/man5/crio.conf.d.5\n/usr/local/share/man/man8/crio.8\n/usr/local/share/bash-completion/completions/crio\n/usr/local/share/fish/completions/crio.fish\n/usr/local/share/zsh/site-functions/_crio\n/usr/local/lib/systemd/system/crio.service\n/etc/containers/registries.conf.d/registries.conf\noverlay\nbr_netfilter\n", + "error": null, + "tags": { + "component": "crio", + "server": "libre-wuji-wrk-0", + "type": "component", + "operation": "install", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji" + } +} \ No newline at end of file diff --git a/crates/data/tasks/a8fa76b2-9283-4fe0-bb4d-0e6e43b1a3e3.json b/crates/data/tasks/a8fa76b2-9283-4fe0-bb4d-0e6e43b1a3e3.json new file mode 100644 index 0000000..c873da6 --- /dev/null +++ b/crates/data/tasks/a8fa76b2-9283-4fe0-bb4d-0e6e43b1a3e3.json @@ -0,0 +1,17 @@ +{ + "id": "a8fa76b2-9283-4fe0-bb4d-0e6e43b1a3e3", + "name": "execute_servers_script_libre-wuji-wrk-0", + "command": "bash", + "args": [ + "-c", + "base64 -d < /tmp/orchestrator_script_a8fa76b2-9283-4fe0-bb4d-0e6e43b1a3e3.tar.gz.b64 | gunzip | tar -xOf - script.sh | bash +x" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T01:07:42.324101Z", + "started_at": "2026-04-21T01:07:42.517329Z", + "completed_at": "2026-04-21T01:08:23.843343Z", + "output": "✓ Prerequisites: HCLOUD_TOKEN set, hcloud CLI available\n\n=== Managing SSH Keys ===\n✓ SSH public key found: /Users/jesusperezlorenzo/.ssh/htz_ops.pub\nChecking if SSH key 'htz_ops' exists in Hetzner...\n✓ SSH key htz_ops already exists with ID: 106168627\n\n=== SSH Key Management Complete ===\nSSH_KEY_ID: 106168627\nSSH_KEY_NAME: htz_ops\nEnvironment exported to: /tmp/provisioning-wuji-20260421-020742/.env\n\n=== Managing Network ===\n✓ Network config validated: 10.0.8.0/22 with subnet 10.0.8.0/24 in zone eu-central\nChecking if network 'wuwei' exists...\n✓ Network 'wuwei' already exists with ID: 11875940\n ✓ Subnet 10.0.8.0/24 already present\n\n=== Network Management Complete ===\nNETWORK_ID: 11875940\nNETWORK_NAME: wuwei\nEnvironment exported to: /tmp/provisioning-wuji-20260421-020742/.env\n\u001b[0;34m[INFO]\u001b[0m Step 1/3: Checking server 'libre-wuji-wrk-0'...\n\u001b[0;34m[INFO]\u001b[0m Creating server 'libre-wuji-wrk-0'...\n\u001b[0;32m[✓]\u001b[0m 127586861 libre-wuji-wrk-0 createdIPv4: \n\u001b[0;32m[✓]\u001b[0m Server created: ID=127586861\n\u001b[0;34m[INFO]\u001b[0m Waiting for server 'libre-wuji-wrk-0' to reach running state...\n\u001b[0;32m[✓]\u001b[0m Server 'libre-wuji-wrk-0' is running\nServer 127586861 attached to Network 11875940\n\u001b[0;32m[✓]\u001b[0m Attached to network wuwei with IP 10.0.8.24\nResource protection enabled for Server 127586861\n\u001b[0;32m[✓]\u001b[0m Protection enabled: delete rebuild\n\u001b[0;34m[INFO]\u001b[0m Step 2/2: Attaching server to firewall 'librecloud-fw'...\nFirewall 10819279 applied to resource\n\u001b[0;32m[✓]\u001b[0m Server attached to firewall 'librecloud-fw'\n\u001b[0;34m[INFO]\u001b[0m Step 3/3: Assigning floating IP 'librecloud-fip-mail' to 'libre-wuji-wrk-0'...\nFloating IP 127388996 assigned to Server 127586861\n\u001b[0;32m[✓]\u001b[0m Assigned floating IP 'librecloud-fip-mail' (id=127388996) → libre-wuji-wrk-0 (id=127586861)\n\u001b[0;32m[✓]\u001b[0m ✓ Server provisioning complete\n\u001b[0;34m[INFO]\u001b[0m Summary:\n\u001b[0;34m[INFO]\u001b[0m • Server: libre-wuji-wrk-0 (ID: 127586861)\n\u001b[0;34m[INFO]\u001b[0m • Firewall: librecloud-fw\n\u001b[0;34m[INFO]\u001b[0m • Float IP: librecloud-fip-mail (id=127388996)\n\u001b[0;34m[INFO]\u001b[0m • Status: Ready for deployment\n", + "error": null, + "tags": {} +} \ No newline at end of file diff --git a/crates/data/tasks/a97a984e-cb6e-4adb-be90-37e921394214.json b/crates/data/tasks/a97a984e-cb6e-4adb-be90-37e921394214.json new file mode 100644 index 0000000..0f026eb --- /dev/null +++ b/crates/data/tasks/a97a984e-cb6e-4adb-be90-37e921394214.json @@ -0,0 +1,23 @@ +{ + "id": "a97a984e-cb6e-4adb-be90-37e921394214", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_a97a984e-cb6e-4adb-be90-37e921394214.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_a97a984e-cb6e-4adb-be90-37e921394214.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Failed", + "created_at": "2026-04-20T10:57:42.200965Z", + "started_at": "2026-04-20T10:57:42.692109Z", + "completed_at": "2026-04-20T10:57:57.208332Z", + "output": null, + "error": "Command execution failed: Hit:1 http://mirror.hetzner.com/debian/packages bookworm InRelease\nHit:2 http://security.debian.org/debian-security bookworm-security InRelease\nHit:3 http://mirror.hetzner.com/debian/packages bookworm-backports InRelease\nHit:4 http://mirror.hetzner.com/debian/packages bookworm-updates InRelease\nHit:5 http://mirror.hetzner.com/debian/security bookworm-security InRelease\nHit:6 http://deb.debian.org/debian bookworm InRelease\nHit:7 http://deb.debian.org/debian bookworm-updates InRelease\nReading package lists...\nReading package lists...\nBuilding dependency tree...\nReading state information...\napt-transport-https is already the newest version (2.6.1).\ncurl is already the newest version (7.88.1-10+deb12u14).\nThe following NEW packages will be installed:\n gnupg2\n0 upgraded, 1 newly installed, 0 to remove and 0 not upgraded.\nNeed to get 446 kB of archives.\nAfter this operation, 465 kB of additional disk space will be used.\nGet:1 http://deb.debian.org/debian bookworm/main arm64 gnupg2 all 2.2.40-1.1+deb12u2 [446 kB]\nFetched 446 kB in 0s (9716 kB/s)\nSelecting previously unselected package gnupg2.\n(Reading database ... \r(Reading database ... 5%\r(Reading database ... 10%\r(Reading database ... 15%\r(Reading database ... 20%\r(Reading database ... 25%\r(Reading database ... 30%\r(Reading database ... 35%\r(Reading database ... 40%\r(Reading database ... 45%\r(Reading database ... 50%\r(Reading database ... 55%\r(Reading database ... 60%\r(Reading database ... 65%\r(Reading database ... 70%\r(Reading database ... 75%\r(Reading database ... 80%\r(Reading database ... 85%\r(Reading database ... 90%\r(Reading database ... 95%\r(Reading database ... 100%\r(Reading database ... 41877 files and directories currently installed.)\nPreparing to unpack .../gnupg2_2.2.40-1.1+deb12u2_all.deb ...\nUnpacking gnupg2 (2.2.40-1.1+deb12u2) ...\nSetting up gnupg2 (2.2.40-1.1+deb12u2) ...\nProcessing triggers for man-db (2.11.2-2) ...\nHit:1 http://mirror.hetzner.com/debian/packages bookworm InRelease\nHit:2 http://deb.debian.org/debian bookworm InRelease\nHit:3 http://mirror.hetzner.com/debian/packages bookworm-backports InRelease\nHit:4 http://mirror.hetzner.com/debian/packages bookworm-updates InRelease\nHit:5 http://mirror.hetzner.com/debian/security bookworm-security InRelease\nHit:6 http://security.debian.org/debian-security bookworm-security InRelease\nHit:7 http://deb.debian.org/debian bookworm-updates InRelease\nGet:8 https://prod-cdn.packages.k8s.io/repositories/isv:/kubernetes:/core:/stable:/v1.35/deb InRelease [1227 B]\nGet:9 https://prod-cdn.packages.k8s.io/repositories/isv:/kubernetes:/core:/stable:/v1.35/deb Packages [7603 B]\nFetched 8830 B in 1s (7188 B/s)\nReading package lists...\nkubelet was already not on hold.\nkubectl was already not on hold.\nkubeadm was already not on hold.\nReading package lists...\nBuilding dependency tree...\nReading state information...\nThe following additional packages will be installed:\n cri-tools kubernetes-cni\nThe following NEW packages will be installed:\n cri-tools kubeadm kubectl kubelet kubernetes-cni\n0 upgraded, 5 newly installed, 0 to remove and 0 not upgraded.\nNeed to get 81.7 MB of archives.\nAfter this operation, 311 MB of additional disk space will be used.\nGet:1 https://prod-cdn.packages.k8s.io/repositories/isv:/kubernetes:/core:/stable:/v1.35/deb cri-tools 1.35.0-1.1 [14.5 MB]\nGet:2 https://prod-cdn.packages.k8s.io/repositories/isv:/kubernetes:/core:/stable:/v1.35/deb kubeadm 1.35.3-1.1 [10.6 MB]\nGet:3 https://prod-cdn.packages.k8s.io/repositories/isv:/kubernetes:/core:/stable:/v1.35/deb kubectl 1.35.3-1.1 [9766 kB]\nGet:4 https://prod-cdn.packages.k8s.io/repositories/isv:/kubernetes:/core:/stable:/v1.35/deb kubernetes-cni 1.8.0-1.1 [36.0 MB]\nGet:5 https://prod-cdn.packages.k8s.io/repositories/isv:/kubernetes:/core:/stable:/v1.35/deb kubelet 1.35.3-1.1 [10.9 MB]\nFetched 81.7 MB in 1s (94.2 MB/s)\nSelecting previously unselected package cri-tools.\n(Reading database ... \r(Reading database ... 5%\r(Reading database ... 10%\r(Reading database ... 15%\r(Reading database ... 20%\r(Reading database ... 25%\r(Reading database ... 30%\r(Reading database ... 35%\r(Reading database ... 40%\r(Reading database ... 45%\r(Reading database ... 50%\r(Reading database ... 55%\r(Reading database ... 60%\r(Reading database ... 65%\r(Reading database ... 70%\r(Reading database ... 75%\r(Reading database ... 80%\r(Reading database ... 85%\r(Reading database ... 90%\r(Reading database ... 95%\r(Reading database ... 100%\r(Reading database ... 41884 files and directories currently installed.)\nPreparing to unpack .../cri-tools_1.35.0-1.1_arm64.deb ...\nUnpacking cri-tools (1.35.0-1.1) ...\nSelecting previously unselected package kubeadm.\nPreparing to unpack .../kubeadm_1.35.3-1.1_arm64.deb ...\nUnpacking kubeadm (1.35.3-1.1) ...\nSelecting previously unselected package kubectl.\nPreparing to unpack .../kubectl_1.35.3-1.1_arm64.deb ...\nUnpacking kubectl (1.35.3-1.1) ...\nSelecting previously unselected package kubernetes-cni.\nPreparing to unpack .../kubernetes-cni_1.8.0-1.1_arm64.deb ...\nUnpacking kubernetes-cni (1.8.0-1.1) ...\nSelecting previously unselected package kubelet.\nPreparing to unpack .../kubelet_1.35.3-1.1_arm64.deb ...\nUnpacking kubelet (1.35.3-1.1) ...\nSetting up kubectl (1.35.3-1.1) ...\nSetting up cri-tools (1.35.0-1.1) ...\nSetting up kubernetes-cni (1.8.0-1.1) ...\nSetting up kubeadm (1.35.3-1.1) ...\nSetting up kubelet (1.35.3-1.1) ...\nkubelet set on hold.\nkubectl set on hold.\nkubeadm set on hold.\nresources/kubeadm-config.yaml not found\n", + "tags": { + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "component": "kubernetes", + "server": "libre-wuji-cp-0", + "type": "component", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/ad73f5b0-6b04-4a28-ae32-b6e6d82fa7f5.json b/crates/data/tasks/ad73f5b0-6b04-4a28-ae32-b6e6d82fa7f5.json new file mode 100644 index 0000000..338bc05 --- /dev/null +++ b/crates/data/tasks/ad73f5b0-6b04-4a28-ae32-b6e6d82fa7f5.json @@ -0,0 +1,23 @@ +{ + "id": "ad73f5b0-6b04-4a28-ae32-b6e6d82fa7f5", + "name": "component_install_youki", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_ad73f5b0-6b04-4a28-ae32-b6e6d82fa7f5.tar.gz.b64' > /tmp/youki.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/youki.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/youki && mkdir -p /tmp/youki && tar xzf /tmp/youki.tar.gz -C /tmp/youki && cd /tmp/youki && sudo bash install-youki.sh install ; rc=$?; rm -f /tmp/youki.tar.gz && rm -rf /tmp/youki; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_ad73f5b0-6b04-4a28-ae32-b6e6d82fa7f5.tar.gz.b64' /tmp/youki.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T01:45:16.262500Z", + "started_at": "2026-04-21T01:45:16.726802Z", + "completed_at": "2026-04-21T01:45:20.151791Z", + "output": "", + "error": null, + "tags": { + "component": "youki", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install", + "server": "libre-wuji-wrk-0", + "type": "component" + } +} \ No newline at end of file diff --git a/crates/data/tasks/aee2c751-ec99-4a25-94ab-bc133e8b6bb5.json b/crates/data/tasks/aee2c751-ec99-4a25-94ab-bc133e8b6bb5.json new file mode 100644 index 0000000..021e9a7 --- /dev/null +++ b/crates/data/tasks/aee2c751-ec99-4a25-94ab-bc133e8b6bb5.json @@ -0,0 +1,23 @@ +{ + "id": "aee2c751-ec99-4a25-94ab-bc133e8b6bb5", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_aee2c751-ec99-4a25-94ab-bc133e8b6bb5.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_aee2c751-ec99-4a25-94ab-bc133e8b6bb5.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T04:49:33.401338Z", + "started_at": "2026-04-21T04:49:33.502497Z", + "completed_at": "2026-04-21T04:49:38.526537Z", + "output": "", + "error": null, + "tags": { + "type": "component", + "component": "kubernetes", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "server": "libre-wuji-wrk-0", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/b3fbd5db-0f3e-458f-82bb-8dcf7d72ea0b.json b/crates/data/tasks/b3fbd5db-0f3e-458f-82bb-8dcf7d72ea0b.json new file mode 100644 index 0000000..1d0a48a --- /dev/null +++ b/crates/data/tasks/b3fbd5db-0f3e-458f-82bb-8dcf7d72ea0b.json @@ -0,0 +1,23 @@ +{ + "id": "b3fbd5db-0f3e-458f-82bb-8dcf7d72ea0b", + "name": "component_install_hetzner_csi", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_b3fbd5db-0f3e-458f-82bb-8dcf7d72ea0b.tar.gz.b64' > /tmp/hetzner_csi.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/hetzner_csi.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/hetzner_csi && mkdir -p /tmp/hetzner_csi && tar xzf /tmp/hetzner_csi.tar.gz -C /tmp/hetzner_csi && cd /tmp/hetzner_csi && sudo bash install-hetzner_csi.sh install ; rc=$?; rm -f /tmp/hetzner_csi.tar.gz && rm -rf /tmp/hetzner_csi; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_b3fbd5db-0f3e-458f-82bb-8dcf7d72ea0b.tar.gz.b64' /tmp/hetzner_csi.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T03:08:41.401375Z", + "started_at": "2026-04-21T03:08:41.421457Z", + "completed_at": "2026-04-21T03:08:46.232698Z", + "output": "=== hetzner-csi: checking prerequisites ===\n=== hetzner-csi: deploying v2.9.0 ===\nserviceaccount/hcloud-csi-controller created\nclusterrole.rbac.authorization.k8s.io/hcloud-csi-controller created\nclusterrolebinding.rbac.authorization.k8s.io/hcloud-csi-controller created\nservice/hcloud-csi-controller-metrics created\nservice/hcloud-csi-node-metrics created\ndaemonset.apps/hcloud-csi-node created\ndeployment.apps/hcloud-csi-controller created\ncsidriver.storage.k8s.io/csi.hetzner.cloud created\n=== hetzner-csi: creating StorageClass hcloud-volumes (reclaimPolicy=Retain) ===\nstorageclass.storage.k8s.io/hcloud-volumes created\nstorageclass.storage.k8s.io/hcloud-volumes annotated\n=== hetzner-csi: waiting for controller pod ===\nWaiting for deployment \"hcloud-csi-controller\" rollout to finish: 0 of 1 updated replicas are available...\ndeployment \"hcloud-csi-controller\" successfully rolled out\n=== hetzner-csi: ready (StorageClass=hcloud-volumes, policy=Retain, fs=ext4) ===\n", + "error": null, + "tags": { + "server": "libre-wuji-cp-0", + "type": "component", + "component": "hetzner_csi", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/b4a492c9-bc77-4129-af80-4385512a9562.json b/crates/data/tasks/b4a492c9-bc77-4129-af80-4385512a9562.json new file mode 100644 index 0000000..8afe9b8 --- /dev/null +++ b/crates/data/tasks/b4a492c9-bc77-4129-af80-4385512a9562.json @@ -0,0 +1,23 @@ +{ + "id": "b4a492c9-bc77-4129-af80-4385512a9562", + "name": "component_install_cilium", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_b4a492c9-bc77-4129-af80-4385512a9562.tar.gz.b64' > /tmp/cilium.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/cilium.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/cilium && mkdir -p /tmp/cilium && tar xzf /tmp/cilium.tar.gz -C /tmp/cilium && cd /tmp/cilium && sudo bash install-cilium.sh install ; rc=$?; rm -f /tmp/cilium.tar.gz && rm -rf /tmp/cilium; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_b4a492c9-bc77-4129-af80-4385512a9562.tar.gz.b64' /tmp/cilium.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Failed", + "created_at": "2026-04-20T11:12:28.361332Z", + "started_at": "2026-04-20T11:12:28.702122Z", + "completed_at": "2026-04-20T11:12:31.376696Z", + "output": null, + "error": "Command execution failed: install-cilium.sh: line 12: k0s: command not found\n", + "tags": { + "operation": "install", + "type": "component", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "component": "cilium", + "server": "libre-wuji-cp-0" + } +} \ No newline at end of file diff --git a/crates/data/tasks/bd4be9ca-3e0b-40c7-9674-77a7a2751891.json b/crates/data/tasks/bd4be9ca-3e0b-40c7-9674-77a7a2751891.json new file mode 100644 index 0000000..19a1def --- /dev/null +++ b/crates/data/tasks/bd4be9ca-3e0b-40c7-9674-77a7a2751891.json @@ -0,0 +1,23 @@ +{ + "id": "bd4be9ca-3e0b-40c7-9674-77a7a2751891", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_bd4be9ca-3e0b-40c7-9674-77a7a2751891.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_bd4be9ca-3e0b-40c7-9674-77a7a2751891.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T02:33:20.270550Z", + "started_at": "2026-04-21T02:33:20.747882Z", + "completed_at": "2026-04-21T02:33:24.175403Z", + "output": "No k8s_join.sh found\n", + "error": null, + "tags": { + "component": "kubernetes", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "server": "libre-wuji-wrk-0", + "type": "component", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/bdf0beba-b398-4a49-86fb-c05fcd1aaf93.json b/crates/data/tasks/bdf0beba-b398-4a49-86fb-c05fcd1aaf93.json new file mode 100644 index 0000000..6b2cc6b --- /dev/null +++ b/crates/data/tasks/bdf0beba-b398-4a49-86fb-c05fcd1aaf93.json @@ -0,0 +1,23 @@ +{ + "id": "bdf0beba-b398-4a49-86fb-c05fcd1aaf93", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_bdf0beba-b398-4a49-86fb-c05fcd1aaf93.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp /tmp/kubernetes.tar.gz 'root@libre-wuji-wkr-0:/tmp/' && ssh 'root@libre-wuji-wkr-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_bdf0beba-b398-4a49-86fb-c05fcd1aaf93.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Failed", + "created_at": "2026-04-21T02:48:42.318738Z", + "started_at": "2026-04-21T02:48:42.781930Z", + "completed_at": "2026-04-21T02:48:43.017706Z", + "output": null, + "error": "Command execution failed: ssh: Could not resolve hostname libre-wuji-wkr-0: nodename nor servname provided, or not known\nscp: Connection closed\n", + "tags": { + "operation": "install", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "server": "libre-wuji-wkr-0", + "type": "component", + "component": "kubernetes" + } +} \ No newline at end of file diff --git a/crates/data/tasks/c49d311c-6c7a-4b65-84f8-bdeb7ab52216.json b/crates/data/tasks/c49d311c-6c7a-4b65-84f8-bdeb7ab52216.json new file mode 100644 index 0000000..ea2da79 --- /dev/null +++ b/crates/data/tasks/c49d311c-6c7a-4b65-84f8-bdeb7ab52216.json @@ -0,0 +1,23 @@ +{ + "id": "c49d311c-6c7a-4b65-84f8-bdeb7ab52216", + "name": "component_install_runc", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_c49d311c-6c7a-4b65-84f8-bdeb7ab52216.tar.gz.b64' > /tmp/runc.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/runc.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/runc && mkdir -p /tmp/runc && tar xzf /tmp/runc.tar.gz -C /tmp/runc && cd /tmp/runc && sudo bash install-runc.sh install ; rc=$?; rm -f /tmp/runc.tar.gz && rm -rf /tmp/runc; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_c49d311c-6c7a-4b65-84f8-bdeb7ab52216.tar.gz.b64' /tmp/runc.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-20T10:28:12.549535Z", + "started_at": "2026-04-20T10:28:12.653941Z", + "completed_at": "2026-04-20T10:28:16.018893Z", + "output": "", + "error": null, + "tags": { + "operation": "install", + "component": "runc", + "server": "libre-wuji-cp-0", + "type": "component", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji" + } +} \ No newline at end of file diff --git a/crates/data/tasks/c6b56bfb-a0d8-4920-9227-69fc3925d4cb.json b/crates/data/tasks/c6b56bfb-a0d8-4920-9227-69fc3925d4cb.json new file mode 100644 index 0000000..b1d532f --- /dev/null +++ b/crates/data/tasks/c6b56bfb-a0d8-4920-9227-69fc3925d4cb.json @@ -0,0 +1,17 @@ +{ + "id": "c6b56bfb-a0d8-4920-9227-69fc3925d4cb", + "name": "execute_servers_script_libre-wuji-wrk-0", + "command": "bash", + "args": [ + "-c", + "base64 -d < /tmp/orchestrator_script_c6b56bfb-a0d8-4920-9227-69fc3925d4cb.tar.gz.b64 | gunzip | tar -xOf - script.sh | bash +x" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T00:47:28.933301Z", + "started_at": "2026-04-21T00:47:29.292253Z", + "completed_at": "2026-04-21T00:48:08.841685Z", + "output": "✓ Prerequisites: HCLOUD_TOKEN set, hcloud CLI available\n\n=== Managing SSH Keys ===\n✓ SSH public key found: /Users/jesusperezlorenzo/.ssh/htz_ops.pub\nChecking if SSH key 'htz_ops' exists in Hetzner...\n✓ SSH key htz_ops already exists with ID: 106168627\n\n=== SSH Key Management Complete ===\nSSH_KEY_ID: 106168627\nSSH_KEY_NAME: htz_ops\nEnvironment exported to: /tmp/provisioning-wuji-20260421-014729/.env\n\n=== Managing Network ===\n✓ Network config validated: 10.0.8.0/22 with subnet 10.0.8.0/24 in zone eu-central\nChecking if network 'wuwei' exists...\n✓ Network 'wuwei' already exists with ID: 11875940\n ✓ Subnet 10.0.8.0/24 already present\n\n=== Network Management Complete ===\nNETWORK_ID: 11875940\nNETWORK_NAME: wuwei\nEnvironment exported to: /tmp/provisioning-wuji-20260421-014729/.env\n\u001b[0;34m[INFO]\u001b[0m Step 1/3: Checking server 'libre-wuji-wrk-0'...\n\u001b[0;34m[INFO]\u001b[0m Creating server 'libre-wuji-wrk-0'...\n\u001b[0;32m[✓]\u001b[0m 127585129 libre-wuji-wrk-0 createdIPv4: \n\u001b[0;32m[✓]\u001b[0m Server created: ID=127585129\n\u001b[0;34m[INFO]\u001b[0m Waiting for server 'libre-wuji-wrk-0' to reach running state...\n\u001b[0;32m[✓]\u001b[0m Server 'libre-wuji-wrk-0' is running\nServer 127585129 attached to Network 11875940\n\u001b[0;32m[✓]\u001b[0m Attached to network wuwei with IP 10.0.8.24\nResource protection enabled for Server 127585129\n\u001b[0;32m[✓]\u001b[0m Protection enabled: delete rebuild\n\u001b[0;34m[INFO]\u001b[0m Step 2/2: Attaching server to firewall 'librecloud-fw'...\nFirewall 10819279 applied to resource\n\u001b[0;32m[✓]\u001b[0m Server attached to firewall 'librecloud-fw'\n\u001b[0;34m[INFO]\u001b[0m Step 3/3: Assigning floating IP 'librecloud-fip-mail' to 'libre-wuji-wrk-0'...\nFloating IP 127388996 assigned to Server 127585129\n\u001b[0;32m[✓]\u001b[0m Assigned floating IP 'librecloud-fip-mail' (id=127388996) → libre-wuji-wrk-0 (id=127585129)\n\u001b[0;32m[✓]\u001b[0m ✓ Server provisioning complete\n\u001b[0;34m[INFO]\u001b[0m Summary:\n\u001b[0;34m[INFO]\u001b[0m • Server: libre-wuji-wrk-0 (ID: 127585129)\n\u001b[0;34m[INFO]\u001b[0m • Firewall: librecloud-fw\n\u001b[0;34m[INFO]\u001b[0m • Float IP: librecloud-fip-mail (id=127388996)\n\u001b[0;34m[INFO]\u001b[0m • Status: Ready for deployment\n", + "error": null, + "tags": {} +} \ No newline at end of file diff --git a/crates/data/tasks/cc76c7f4-1348-4bfc-9194-91fa6c2533de.json b/crates/data/tasks/cc76c7f4-1348-4bfc-9194-91fa6c2533de.json new file mode 100644 index 0000000..024e6ba --- /dev/null +++ b/crates/data/tasks/cc76c7f4-1348-4bfc-9194-91fa6c2533de.json @@ -0,0 +1,23 @@ +{ + "id": "cc76c7f4-1348-4bfc-9194-91fa6c2533de", + "name": "component_install_youki", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_cc76c7f4-1348-4bfc-9194-91fa6c2533de.tar.gz.b64' > /tmp/youki.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/youki.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/youki && mkdir -p /tmp/youki && tar xzf /tmp/youki.tar.gz -C /tmp/youki && cd /tmp/youki && sudo bash install-youki.sh install ; rc=$?; rm -f /tmp/youki.tar.gz && rm -rf /tmp/youki; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_cc76c7f4-1348-4bfc-9194-91fa6c2533de.tar.gz.b64' /tmp/youki.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T01:45:16.182670Z", + "started_at": "2026-04-21T01:45:16.223817Z", + "completed_at": "2026-04-21T01:45:19.821857Z", + "output": "", + "error": null, + "tags": { + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "type": "component", + "component": "youki", + "server": "libre-wuji-cp-0", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/cc7a2f8d-e389-4b9c-a436-a9dd641afb59.json b/crates/data/tasks/cc7a2f8d-e389-4b9c-a436-a9dd641afb59.json new file mode 100644 index 0000000..154ebb0 --- /dev/null +++ b/crates/data/tasks/cc7a2f8d-e389-4b9c-a436-a9dd641afb59.json @@ -0,0 +1,23 @@ +{ + "id": "cc7a2f8d-e389-4b9c-a436-a9dd641afb59", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_cc7a2f8d-e389-4b9c-a436-a9dd641afb59.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_cc7a2f8d-e389-4b9c-a436-a9dd641afb59.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T02:46:18.619584Z", + "started_at": "2026-04-21T02:46:18.724771Z", + "completed_at": "2026-04-21T02:46:21.991594Z", + "output": "No k8s_join.sh found\n", + "error": null, + "tags": { + "server": "libre-wuji-wrk-0", + "operation": "install", + "type": "component", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "component": "kubernetes" + } +} \ No newline at end of file diff --git a/crates/data/tasks/cd9a7acb-15e2-491f-a44b-348079136a7d.json b/crates/data/tasks/cd9a7acb-15e2-491f-a44b-348079136a7d.json new file mode 100644 index 0000000..0f9eeca --- /dev/null +++ b/crates/data/tasks/cd9a7acb-15e2-491f-a44b-348079136a7d.json @@ -0,0 +1,23 @@ +{ + "id": "cd9a7acb-15e2-491f-a44b-348079136a7d", + "name": "component_install_os", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_cd9a7acb-15e2-491f-a44b-348079136a7d.tar.gz.b64' > /tmp/os.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/os.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/os && mkdir -p /tmp/os && tar xzf /tmp/os.tar.gz -C /tmp/os && cd /tmp/os && sudo bash install-os.sh install ; rc=$?; rm -f /tmp/os.tar.gz && rm -rf /tmp/os; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_cd9a7acb-15e2-491f-a44b-348079136a7d.tar.gz.b64' /tmp/os.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T01:55:04.158083Z", + "started_at": "2026-04-21T01:55:04.481187Z", + "completed_at": "2026-04-21T01:55:11.579984Z", + "output": "APT::Get::Update::SourceListWarnings::NonFreeFirmware \"false\";\nHit:1 http://mirror.hetzner.com/debian/packages bookworm InRelease\nHit:2 http://deb.debian.org/debian bookworm InRelease\nHit:3 http://security.debian.org/debian-security bookworm-security InRelease\nHit:4 http://mirror.hetzner.com/debian/packages bookworm-backports InRelease\nHit:5 http://mirror.hetzner.com/debian/packages bookworm-updates InRelease\nHit:6 http://mirror.hetzner.com/debian/security bookworm-security InRelease\nHit:7 http://deb.debian.org/debian bookworm-updates InRelease\nReading package lists...\nReading package lists...\nBuilding dependency tree...\nReading state information...\nCalculating upgrade...\n0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.\nReading package lists...\nBuilding dependency tree...\nReading state information...\n0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.\n", + "error": null, + "tags": { + "component": "os", + "server": "libre-wuji-cp-0", + "type": "component", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/cf2157ab-5da5-46f7-b454-457dc4f4b16d.json b/crates/data/tasks/cf2157ab-5da5-46f7-b454-457dc4f4b16d.json new file mode 100644 index 0000000..3831862 --- /dev/null +++ b/crates/data/tasks/cf2157ab-5da5-46f7-b454-457dc4f4b16d.json @@ -0,0 +1,23 @@ +{ + "id": "cf2157ab-5da5-46f7-b454-457dc4f4b16d", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_cf2157ab-5da5-46f7-b454-457dc4f4b16d.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_cf2157ab-5da5-46f7-b454-457dc4f4b16d.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T02:47:05.734600Z", + "started_at": "2026-04-21T02:47:05.917786Z", + "completed_at": "2026-04-21T02:47:09.853549Z", + "output": "2026_04_21_024709 | Stale /etc/kubernetes detected — cleaning before install\nWarning: probe patch failed\n2026_04_21_024709 | apiserver probes patched: startup=300s liveness=120s readiness=15s\n/etc/kubernetes/admin.conf not found\n", + "error": null, + "tags": { + "component": "kubernetes", + "operation": "install", + "type": "component", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "server": "libre-wuji-cp-0" + } +} \ No newline at end of file diff --git a/crates/data/tasks/d3555cfc-7853-4568-a549-9ff3b045d0d5.json b/crates/data/tasks/d3555cfc-7853-4568-a549-9ff3b045d0d5.json new file mode 100644 index 0000000..41b5e81 --- /dev/null +++ b/crates/data/tasks/d3555cfc-7853-4568-a549-9ff3b045d0d5.json @@ -0,0 +1,23 @@ +{ + "id": "d3555cfc-7853-4568-a549-9ff3b045d0d5", + "name": "component_install_external_nfs", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_d3555cfc-7853-4568-a549-9ff3b045d0d5.tar.gz.b64' > /tmp/external_nfs.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/external_nfs.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/external_nfs && mkdir -p /tmp/external_nfs && tar xzf /tmp/external_nfs.tar.gz -C /tmp/external_nfs && cd /tmp/external_nfs && sudo bash install-external_nfs.sh install ; rc=$?; rm -f /tmp/external_nfs.tar.gz && rm -rf /tmp/external_nfs; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_d3555cfc-7853-4568-a549-9ff3b045d0d5.tar.gz.b64' /tmp/external_nfs.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Failed", + "created_at": "2026-04-21T02:49:14.136085Z", + "started_at": "2026-04-21T02:49:14.402412Z", + "completed_at": "2026-04-21T02:49:20.801101Z", + "output": null, + "error": "Command execution failed: Selecting previously unselected package nfs-kernel-server.\n(Reading database ... \r(Reading database ... 5%\r(Reading database ... 10%\r(Reading database ... 15%\r(Reading database ... 20%\r(Reading database ... 25%\r(Reading database ... 30%\r(Reading database ... 35%\r(Reading database ... 40%\r(Reading database ... 45%\r(Reading database ... 50%\r(Reading database ... 55%\r(Reading database ... 60%\r(Reading database ... 65%\r(Reading database ... 70%\r(Reading database ... 75%\r(Reading database ... 80%\r(Reading database ... 85%\r(Reading database ... 90%\r(Reading database ... 95%\r(Reading database ... 100%\r(Reading database ... 41938 files and directories currently installed.)\nPreparing to unpack .../nfs-kernel-server_1%3a2.6.2-4+deb12u1_arm64.deb ...\nUnpacking nfs-kernel-server (1:2.6.2-4+deb12u1) ...\nSetting up nfs-kernel-server (1:2.6.2-4+deb12u1) ...\nCreated symlink /etc/systemd/system/nfs-client.target.wants/nfs-blkmap.service → /lib/systemd/system/nfs-blkmap.service.\r\nCreated symlink /etc/systemd/system/multi-user.target.wants/nfs-server.service → /lib/systemd/system/nfs-server.service.\r\nnfs-mountd.service is a disabled or a static unit, not starting it.\nnfsdcld.service is a disabled or a static unit, not starting it.\n\nCreating config file /etc/exports with new version\n\nCreating config file /etc/default/nfs-kernel-server with new version\nProcessing triggers for man-db (2.11.2-2) ...\nError: IP NET SHARE_PATH not all set for NFS\n", + "tags": { + "type": "component", + "server": "libre-wuji-cp-0", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install", + "component": "external_nfs" + } +} \ No newline at end of file diff --git a/crates/data/tasks/d3b0d53f-6f70-4e09-97d9-bd1693f27d68.json b/crates/data/tasks/d3b0d53f-6f70-4e09-97d9-bd1693f27d68.json new file mode 100644 index 0000000..ddf5db5 --- /dev/null +++ b/crates/data/tasks/d3b0d53f-6f70-4e09-97d9-bd1693f27d68.json @@ -0,0 +1,23 @@ +{ + "id": "d3b0d53f-6f70-4e09-97d9-bd1693f27d68", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_d3b0d53f-6f70-4e09-97d9-bd1693f27d68.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_d3b0d53f-6f70-4e09-97d9-bd1693f27d68.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T02:11:19.972626Z", + "started_at": "2026-04-21T02:11:20.201878Z", + "completed_at": "2026-04-21T02:11:55.167529Z", + "output": "Hit:1 http://mirror.hetzner.com/debian/packages bookworm InRelease\nHit:2 http://deb.debian.org/debian bookworm InRelease\nHit:3 http://security.debian.org/debian-security bookworm-security InRelease\nHit:4 http://mirror.hetzner.com/debian/packages bookworm-backports InRelease\nHit:5 http://mirror.hetzner.com/debian/packages bookworm-updates InRelease\nHit:6 http://mirror.hetzner.com/debian/security bookworm-security InRelease\nHit:7 http://deb.debian.org/debian bookworm-updates InRelease\nReading package lists...\nReading package lists...\nBuilding dependency tree...\nReading state information...\napt-transport-https is already the newest version (2.6.1).\ncurl is already the newest version (7.88.1-10+deb12u14).\nThe following NEW packages will be installed:\n gnupg2\n0 upgraded, 1 newly installed, 0 to remove and 0 not upgraded.\nNeed to get 446 kB of archives.\nAfter this operation, 465 kB of additional disk space will be used.\nGet:1 http://deb.debian.org/debian bookworm/main arm64 gnupg2 all 2.2.40-1.1+deb12u2 [446 kB]\nFetched 446 kB in 0s (10.8 MB/s)\nSelecting previously unselected package gnupg2.\r\n(Reading database ... \r(Reading database ... 5%\r(Reading database ... 10%\r(Reading database ... 15%\r(Reading database ... 20%\r(Reading database ... 25%\r(Reading database ... 30%\r(Reading database ... 35%\r(Reading database ... 40%\r(Reading database ... 45%\r(Reading database ... 50%\r(Reading database ... 55%\r(Reading database ... 60%\r(Reading database ... 65%\r(Reading database ... 70%\r(Reading database ... 75%\r(Reading database ... 80%\r(Reading database ... 85%\r(Reading database ... 90%\r(Reading database ... 95%\r(Reading database ... 100%\r(Reading database ... 41877 files and directories currently installed.)\r\nPreparing to unpack .../gnupg2_2.2.40-1.1+deb12u2_all.deb ...\r\nUnpacking gnupg2 (2.2.40-1.1+deb12u2) ...\r\nSetting up gnupg2 (2.2.40-1.1+deb12u2) ...\r\nProcessing triggers for man-db (2.11.2-2) ...\r\nHit:1 http://mirror.hetzner.com/debian/packages bookworm InRelease\nHit:2 http://deb.debian.org/debian bookworm InRelease\nHit:3 http://security.debian.org/debian-security bookworm-security InRelease\nHit:4 http://mirror.hetzner.com/debian/packages bookworm-backports InRelease\nHit:5 http://mirror.hetzner.com/debian/packages bookworm-updates InRelease\nHit:6 http://mirror.hetzner.com/debian/security bookworm-security InRelease\nHit:7 http://deb.debian.org/debian bookworm-updates InRelease\nGet:8 https://prod-cdn.packages.k8s.io/repositories/isv:/kubernetes:/core:/stable:/v1.35/deb InRelease [1227 B]\nGet:9 https://prod-cdn.packages.k8s.io/repositories/isv:/kubernetes:/core:/stable:/v1.35/deb Packages [7603 B]\nFetched 8830 B in 1s (7120 B/s)\nReading package lists...\nkubelet was already not on hold.\nkubectl was already not on hold.\nkubeadm was already not on hold.\nReading package lists...\nBuilding dependency tree...\nReading state information...\nThe following additional packages will be installed:\n cri-tools kubernetes-cni\nThe following NEW packages will be installed:\n cri-tools kubeadm kubectl kubelet kubernetes-cni\n0 upgraded, 5 newly installed, 0 to remove and 0 not upgraded.\nNeed to get 81.7 MB of archives.\nAfter this operation, 311 MB of additional disk space will be used.\nGet:1 https://prod-cdn.packages.k8s.io/repositories/isv:/kubernetes:/core:/stable:/v1.35/deb cri-tools 1.35.0-1.1 [14.5 MB]\nGet:2 https://prod-cdn.packages.k8s.io/repositories/isv:/kubernetes:/core:/stable:/v1.35/deb kubeadm 1.35.4-1.1 [10.6 MB]\nGet:3 https://prod-cdn.packages.k8s.io/repositories/isv:/kubernetes:/core:/stable:/v1.35/deb kubectl 1.35.4-1.1 [9768 kB]\nGet:4 https://prod-cdn.packages.k8s.io/repositories/isv:/kubernetes:/core:/stable:/v1.35/deb kubernetes-cni 1.8.0-1.1 [36.0 MB]\nGet:5 https://prod-cdn.packages.k8s.io/repositories/isv:/kubernetes:/core:/stable:/v1.35/deb kubelet 1.35.4-1.1 [10.8 MB]\nFetched 81.7 MB in 1s (91.0 MB/s)\nSelecting previously unselected package cri-tools.\r\n(Reading database ... \r(Reading database ... 5%\r(Reading database ... 10%\r(Reading database ... 15%\r(Reading database ... 20%\r(Reading database ... 25%\r(Reading database ... 30%\r(Reading database ... 35%\r(Reading database ... 40%\r(Reading database ... 45%\r(Reading database ... 50%\r(Reading database ... 55%\r(Reading database ... 60%\r(Reading database ... 65%\r(Reading database ... 70%\r(Reading database ... 75%\r(Reading database ... 80%\r(Reading database ... 85%\r(Reading database ... 90%\r(Reading database ... 95%\r(Reading database ... 100%\r(Reading database ... 41884 files and directories currently installed.)\r\nPreparing to unpack .../cri-tools_1.35.0-1.1_arm64.deb ...\r\nUnpacking cri-tools (1.35.0-1.1) ...\r\nSelecting previously unselected package kubeadm.\r\nPreparing to unpack .../kubeadm_1.35.4-1.1_arm64.deb ...\r\nUnpacking kubeadm (1.35.4-1.1) ...\r\nSelecting previously unselected package kubectl.\r\nPreparing to unpack .../kubectl_1.35.4-1.1_arm64.deb ...\r\nUnpacking kubectl (1.35.4-1.1) ...\r\nSelecting previously unselected package kubernetes-cni.\r\nPreparing to unpack .../kubernetes-cni_1.8.0-1.1_arm64.deb ...\r\nUnpacking kubernetes-cni (1.8.0-1.1) ...\r\nSelecting previously unselected package kubelet.\r\nPreparing to unpack .../kubelet_1.35.4-1.1_arm64.deb ...\r\nUnpacking kubelet (1.35.4-1.1) ...\r\nSetting up kubectl (1.35.4-1.1) ...\r\nSetting up cri-tools (1.35.0-1.1) ...\r\nSetting up kubernetes-cni (1.8.0-1.1) ...\r\nSetting up kubeadm (1.35.4-1.1) ...\r\nSetting up kubelet (1.35.4-1.1) ...\r\nkubelet set on hold.\nkubectl set on hold.\nkubeadm set on hold.\n[init] Using Kubernetes version: v1.35.4\n[preflight] Running pre-flight checks\n[preflight] Pulling images required for setting up a Kubernetes cluster\n[preflight] This might take a minute or two, depending on the speed of your internet connection\n[preflight] You can also perform this action beforehand using 'kubeadm config images pull'\n[certs] Using certificateDir folder \"/etc/kubernetes/pki\"\n[certs] Generating \"ca\" certificate and key\n[certs] Generating \"apiserver\" certificate and key\n[certs] apiserver serving cert is signed for DNS names [kubernetes kubernetes.default kubernetes.default.svc kubernetes.default.svc.libre-wuji.local libre-wuji libre-wuji-cp-0] and IPs [10.96.0.1 10.0.8.20 127.0.0.1 ::1]\n[certs] Generating \"apiserver-kubelet-client\" certificate and key\n[certs] Generating \"front-proxy-ca\" certificate and key\n[certs] Generating \"front-proxy-client\" certificate and key\n[certs] Generating \"etcd/ca\" certificate and key\n[certs] Generating \"etcd/server\" certificate and key\n[certs] etcd/server serving cert is signed for DNS names [libre-wuji-cp-0 localhost] and IPs [10.0.8.20 127.0.0.1 ::1]\n[certs] Generating \"etcd/peer\" certificate and key\n[certs] etcd/peer serving cert is signed for DNS names [libre-wuji-cp-0 localhost] and IPs [10.0.8.20 127.0.0.1 ::1]\n[certs] Generating \"etcd/healthcheck-client\" certificate and key\n[certs] Generating \"apiserver-etcd-client\" certificate and key\n[certs] Generating \"sa\" key and public key\n[kubeconfig] Using kubeconfig folder \"/etc/kubernetes\"\n[kubeconfig] Writing \"admin.conf\" kubeconfig file\n[kubeconfig] Writing \"super-admin.conf\" kubeconfig file\n[kubeconfig] Writing \"kubelet.conf\" kubeconfig file\n[kubeconfig] Writing \"controller-manager.conf\" kubeconfig file\n[kubeconfig] Writing \"scheduler.conf\" kubeconfig file\n[etcd] Creating static Pod manifest for local etcd in \"/etc/kubernetes/manifests\"\n[control-plane] Using manifest folder \"/etc/kubernetes/manifests\"\n[control-plane] Creating static Pod manifest for \"kube-apiserver\"\n[control-plane] Creating static Pod manifest for \"kube-controller-manager\"\n[control-plane] Creating static Pod manifest for \"kube-scheduler\"\n[kubelet-start] Writing kubelet environment file with flags to file \"/var/lib/kubelet/kubeadm-flags.env\"\n[kubelet-start] Writing kubelet configuration to file \"/var/lib/kubelet/instance-config.yaml\"\n[patches] Applied patch of type \"application/strategic-merge-patch+json\" to target \"kubeletconfiguration\"\n[kubelet-start] Writing kubelet configuration to file \"/var/lib/kubelet/config.yaml\"\n[kubelet-start] Starting the kubelet\n[wait-control-plane] Waiting for the kubelet to boot up the control plane as static Pods from directory \"/etc/kubernetes/manifests\"\n[kubelet-check] Waiting for a healthy kubelet at http://127.0.0.1:10248/healthz. This can take up to 4m0s\n[kubelet-check] The kubelet is healthy after 501.576213ms\n[control-plane-check] Waiting for healthy control plane components. This can take up to 4m0s\n[control-plane-check] Checking kube-apiserver at https://10.0.8.20:6443/livez\n[control-plane-check] Checking kube-controller-manager at https://127.0.0.1:10257/healthz\n[control-plane-check] Checking kube-scheduler at https://127.0.0.1:10259/livez\n[control-plane-check] kube-controller-manager is healthy after 1.014367313s\n[control-plane-check] kube-scheduler is healthy after 3.198203942s\n[control-plane-check] kube-apiserver is healthy after 5.001868131s\n[upload-config] Storing the configuration used in ConfigMap \"kubeadm-config\" in the \"kube-system\" Namespace\n[kubelet] Creating a ConfigMap \"kubelet-config\" in namespace kube-system with the configuration for the kubelets in the cluster\n[upload-certs] Skipping phase. Please see --upload-certs\n[mark-control-plane] Marking the node libre-wuji-cp-0 as control-plane by adding the labels: [node-role.kubernetes.io/control-plane node.kubernetes.io/exclude-from-external-load-balancers]\n[bootstrap-token] Using token: aejeza.0y5qrxlzya0kkhpp\n[bootstrap-token] Configuring bootstrap tokens, cluster-info ConfigMap, RBAC Roles\n[bootstrap-token] Configured RBAC rules to allow Node Bootstrap tokens to get nodes\n[bootstrap-token] Configured RBAC rules to allow Node Bootstrap tokens to post CSRs in order for nodes to get long term certificate credentials\n[bootstrap-token] Configured RBAC rules to allow the csrapprover controller automatically approve CSRs from a Node Bootstrap Token\n[bootstrap-token] Configured RBAC rules to allow certificate rotation for all node client certificates in the cluster\n[bootstrap-token] Creating the \"cluster-info\" ConfigMap in the \"kube-public\" namespace\n[kubelet-finalize] Updating \"/etc/kubernetes/kubelet.conf\" to point to a rotatable kubelet client certificate and key\n[addons] Applied essential addon: CoreDNS\n\nYour Kubernetes control-plane has initialized successfully!\n\nTo start using your cluster, you need to run the following as a regular user:\n\n mkdir -p $HOME/.kube\n sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config\n sudo chown $(id -u):$(id -g) $HOME/.kube/config\n\nAlternatively, if you are the root user, you can run:\n\n export KUBECONFIG=/etc/kubernetes/admin.conf\n\nYou should now deploy a pod network to the cluster.\nRun \"kubectl apply -f [podnetwork].yaml\" with one of the options listed at:\n https://kubernetes.io/docs/concepts/cluster-administration/addons/\n\nYou can now join any number of control-plane nodes by copying certificate authorities\nand service account keys on each node and then running the following as root:\n\n kubeadm join 10.0.8.20:6443 --token aejeza.0y5qrxlzya0kkhpp \\\n\t--discovery-token-ca-cert-hash sha256:7719ca049ee2c1c012c7cebb9e324f0dd6d505acb5196cd0ead327daeaf1d350 \\\n\t--control-plane \n\nThen you can join any number of worker nodes by running the following on each as root:\n\nkubeadm join 10.0.8.20:6443 --token aejeza.0y5qrxlzya0kkhpp \\\n\t--discovery-token-ca-cert-hash sha256:7719ca049ee2c1c012c7cebb9e324f0dd6d505acb5196cd0ead327daeaf1d350 \nprobes patched\n2026_04_21_021152 | apiserver probes patched: startup=300s liveness=120s readiness=15s\n2026_04_21_021152 | etcd endpoints reordered: https://127.0.0.1:2379,https://127.0.0.1:2379\nUpdating certificates in /etc/ssl/certs...\n1 added, 0 removed; done.\nRunning hooks in /etc/ca-certificates/update.d...\ndone.\n[addons] Applied essential addon: CoreDNS\nruntimeclass.node.k8s.io/runc created\n2026_04_21_021154 | Waiting for RBAC bootstrap to complete...\n2026_04_21_021154 | RBAC bootstrap complete (attempt 1)\n", + "error": null, + "tags": { + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "server": "libre-wuji-cp-0", + "operation": "install", + "component": "kubernetes", + "type": "component" + } +} \ No newline at end of file diff --git a/crates/data/tasks/d3b47688-c34d-4b61-8448-278c33e9f825.json b/crates/data/tasks/d3b47688-c34d-4b61-8448-278c33e9f825.json new file mode 100644 index 0000000..a717b0c --- /dev/null +++ b/crates/data/tasks/d3b47688-c34d-4b61-8448-278c33e9f825.json @@ -0,0 +1,23 @@ +{ + "id": "d3b47688-c34d-4b61-8448-278c33e9f825", + "name": "component_install_os", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_d3b47688-c34d-4b61-8448-278c33e9f825.tar.gz.b64' > /tmp/os.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/os.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/os && mkdir -p /tmp/os && tar xzf /tmp/os.tar.gz -C /tmp/os && cd /tmp/os && sudo bash install-os.sh install ; rc=$?; rm -f /tmp/os.tar.gz && rm -rf /tmp/os; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_d3b47688-c34d-4b61-8448-278c33e9f825.tar.gz.b64' /tmp/os.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Failed", + "created_at": "2026-04-21T01:37:11.830777Z", + "started_at": "2026-04-21T01:37:11.879977Z", + "completed_at": "2026-04-21T01:37:12.257378Z", + "output": null, + "error": "Command execution failed: @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\n@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\nIT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!\nSomeone could be eavesdropping on you right now (man-in-the-middle attack)!\nIt is also possible that a host key has just been changed.\nThe fingerprint for the ED25519 key sent by the remote host is\nSHA256:TqR2p8WoCla4HO/j3C3nIYsK01qGnRszfb9CoBPlF88.\nPlease contact your system administrator.\nAdd correct host key in /Users/jesusperezlorenzo/.ssh/known_hosts to get rid of this message.\nOffending ECDSA key in /Users/jesusperezlorenzo/.ssh/known_hosts:231\nHost key for libre-wuji-wrk-0 has changed and you have requested strict checking.\nHost key verification failed.\nscp: Connection closed\n", + "tags": { + "type": "component", + "server": "libre-wuji-wrk-0", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "component": "os", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/d6bea434-4663-4e17-a3a1-0fcb8af2d015.json b/crates/data/tasks/d6bea434-4663-4e17-a3a1-0fcb8af2d015.json new file mode 100644 index 0000000..a0760e0 --- /dev/null +++ b/crates/data/tasks/d6bea434-4663-4e17-a3a1-0fcb8af2d015.json @@ -0,0 +1,17 @@ +{ + "id": "d6bea434-4663-4e17-a3a1-0fcb8af2d015", + "name": "execute_servers_script_libre-wuji-wrk-0", + "command": "bash", + "args": [ + "-c", + "base64 -d < /tmp/orchestrator_script_d6bea434-4663-4e17-a3a1-0fcb8af2d015.tar.gz.b64 | gunzip | tar -xOf - script.sh | bash +x" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T01:34:24.817359Z", + "started_at": "2026-04-21T01:34:25.239170Z", + "completed_at": "2026-04-21T01:35:07.352251Z", + "output": "✓ Prerequisites: HCLOUD_TOKEN set, hcloud CLI available\n\n=== Managing SSH Keys ===\n✓ SSH public key found: /Users/jesusperezlorenzo/.ssh/htz_ops.pub\nChecking if SSH key 'htz_ops' exists in Hetzner...\n✓ SSH key htz_ops already exists with ID: 106168627\n\n=== SSH Key Management Complete ===\nSSH_KEY_ID: 106168627\nSSH_KEY_NAME: htz_ops\nEnvironment exported to: /tmp/provisioning-wuji-20260421-023425/.env\n\n=== Managing Network ===\n✓ Network config validated: 10.0.8.0/22 with subnet 10.0.8.0/24 in zone eu-central\nChecking if network 'wuwei' exists...\n✓ Network 'wuwei' already exists with ID: 11875940\n ✓ Subnet 10.0.8.0/24 already present\n\n=== Network Management Complete ===\nNETWORK_ID: 11875940\nNETWORK_NAME: wuwei\nEnvironment exported to: /tmp/provisioning-wuji-20260421-023425/.env\n\u001b[0;34m[INFO]\u001b[0m Step 1/3: Checking server 'libre-wuji-wrk-0'...\n\u001b[0;34m[INFO]\u001b[0m Creating server 'libre-wuji-wrk-0'...\n\u001b[0;32m[✓]\u001b[0m 127588642 libre-wuji-wrk-0 createdIPv4: 2a01:4f8:c014:16e2::1IPv6\n\u001b[0;32m[✓]\u001b[0m Server created: ID=127588642\n\u001b[0;34m[INFO]\u001b[0m Waiting for server 'libre-wuji-wrk-0' to reach running state...\n\u001b[0;32m[✓]\u001b[0m Server 'libre-wuji-wrk-0' is running\nServer 127588642 attached to Network 11875940\n\u001b[0;32m[✓]\u001b[0m Attached to network wuwei with IP 10.0.8.24\nResource protection enabled for Server 127588642\n\u001b[0;32m[✓]\u001b[0m Protection enabled: delete rebuild\n\u001b[0;34m[INFO]\u001b[0m Step 2/2: Attaching server to firewall 'librecloud-fw'...\nFirewall 10819279 applied to resource\n\u001b[0;32m[✓]\u001b[0m Server attached to firewall 'librecloud-fw'\n\u001b[0;34m[INFO]\u001b[0m Step 3/3: Assigning floating IP 'librecloud-fip-mail' to 'libre-wuji-wrk-0'...\nFloating IP 127388996 assigned to Server 127588642\n\u001b[0;32m[✓]\u001b[0m Assigned floating IP 'librecloud-fip-mail' (id=127388996) → libre-wuji-wrk-0 (id=127588642)\n\u001b[0;32m[✓]\u001b[0m ✓ Server provisioning complete\n\u001b[0;34m[INFO]\u001b[0m Summary:\n\u001b[0;34m[INFO]\u001b[0m • Server: libre-wuji-wrk-0 (ID: 127588642)\n\u001b[0;34m[INFO]\u001b[0m • Firewall: librecloud-fw\n\u001b[0;34m[INFO]\u001b[0m • Float IP: librecloud-fip-mail (id=127388996)\n\u001b[0;34m[INFO]\u001b[0m • Status: Ready for deployment\n", + "error": null, + "tags": {} +} \ No newline at end of file diff --git a/crates/data/tasks/d72e1a5f-e65c-4381-8da6-3d3df628ef76.json b/crates/data/tasks/d72e1a5f-e65c-4381-8da6-3d3df628ef76.json new file mode 100644 index 0000000..90153e6 --- /dev/null +++ b/crates/data/tasks/d72e1a5f-e65c-4381-8da6-3d3df628ef76.json @@ -0,0 +1,23 @@ +{ + "id": "d72e1a5f-e65c-4381-8da6-3d3df628ef76", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_d72e1a5f-e65c-4381-8da6-3d3df628ef76.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_d72e1a5f-e65c-4381-8da6-3d3df628ef76.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T05:07:07.702706Z", + "started_at": "2026-04-21T05:07:07.993139Z", + "completed_at": "2026-04-21T05:07:13.569322Z", + "output": "", + "error": null, + "tags": { + "operation": "install", + "component": "kubernetes", + "type": "component", + "server": "libre-wuji-wrk-0", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji" + } +} \ No newline at end of file diff --git a/crates/data/tasks/d93a4e82-5c27-49f0-b04c-ecedff7d03ae.json b/crates/data/tasks/d93a4e82-5c27-49f0-b04c-ecedff7d03ae.json new file mode 100644 index 0000000..ebbba3f --- /dev/null +++ b/crates/data/tasks/d93a4e82-5c27-49f0-b04c-ecedff7d03ae.json @@ -0,0 +1,23 @@ +{ + "id": "d93a4e82-5c27-49f0-b04c-ecedff7d03ae", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_d93a4e82-5c27-49f0-b04c-ecedff7d03ae.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_d93a4e82-5c27-49f0-b04c-ecedff7d03ae.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T04:30:09.464411Z", + "started_at": "2026-04-21T04:30:09.626444Z", + "completed_at": "2026-04-21T04:30:22.009148Z", + "output": "", + "error": null, + "tags": { + "server": "libre-wuji-wrk-0", + "operation": "install", + "component": "kubernetes", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "type": "component" + } +} \ No newline at end of file diff --git a/crates/data/tasks/db625b13-bc29-4e00-924b-052d177dc751.json b/crates/data/tasks/db625b13-bc29-4e00-924b-052d177dc751.json new file mode 100644 index 0000000..4e791d5 --- /dev/null +++ b/crates/data/tasks/db625b13-bc29-4e00-924b-052d177dc751.json @@ -0,0 +1,23 @@ +{ + "id": "db625b13-bc29-4e00-924b-052d177dc751", + "name": "component_install_runc", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_db625b13-bc29-4e00-924b-052d177dc751.tar.gz.b64' > /tmp/runc.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/runc.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/runc && mkdir -p /tmp/runc && tar xzf /tmp/runc.tar.gz -C /tmp/runc && cd /tmp/runc && sudo bash install-runc.sh install ; rc=$?; rm -f /tmp/runc.tar.gz && rm -rf /tmp/runc; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_db625b13-bc29-4e00-924b-052d177dc751.tar.gz.b64' /tmp/runc.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T02:03:20.770116Z", + "started_at": "2026-04-21T02:03:20.877523Z", + "completed_at": "2026-04-21T02:03:23.627695Z", + "output": "", + "error": null, + "tags": { + "server": "libre-wuji-wrk-0", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "component": "runc", + "type": "component", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/de4a15c4-b679-47ad-86d7-32557980f007.json b/crates/data/tasks/de4a15c4-b679-47ad-86d7-32557980f007.json new file mode 100644 index 0000000..0a130de --- /dev/null +++ b/crates/data/tasks/de4a15c4-b679-47ad-86d7-32557980f007.json @@ -0,0 +1,23 @@ +{ + "id": "de4a15c4-b679-47ad-86d7-32557980f007", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_de4a15c4-b679-47ad-86d7-32557980f007.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_de4a15c4-b679-47ad-86d7-32557980f007.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T05:08:30.059079Z", + "started_at": "2026-04-21T05:08:30.315645Z", + "completed_at": "2026-04-21T05:08:35.500211Z", + "output": "", + "error": null, + "tags": { + "type": "component", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "server": "libre-wuji-wrk-0", + "component": "kubernetes", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/df9fb1dc-2f69-482a-9ab8-6609d8710e98.json b/crates/data/tasks/df9fb1dc-2f69-482a-9ab8-6609d8710e98.json new file mode 100644 index 0000000..bc8b903 --- /dev/null +++ b/crates/data/tasks/df9fb1dc-2f69-482a-9ab8-6609d8710e98.json @@ -0,0 +1,23 @@ +{ + "id": "df9fb1dc-2f69-482a-9ab8-6609d8710e98", + "name": "component_install_hetzner_csi", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_df9fb1dc-2f69-482a-9ab8-6609d8710e98.tar.gz.b64' > /tmp/hetzner_csi.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/hetzner_csi.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/hetzner_csi && mkdir -p /tmp/hetzner_csi && tar xzf /tmp/hetzner_csi.tar.gz -C /tmp/hetzner_csi && cd /tmp/hetzner_csi && sudo bash install-hetzner_csi.sh install ; rc=$?; rm -f /tmp/hetzner_csi.tar.gz && rm -rf /tmp/hetzner_csi; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_df9fb1dc-2f69-482a-9ab8-6609d8710e98.tar.gz.b64' /tmp/hetzner_csi.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Failed", + "created_at": "2026-04-21T02:22:45.750389Z", + "started_at": "2026-04-21T02:22:45.800986Z", + "completed_at": "2026-04-21T02:22:48.574608Z", + "output": null, + "error": "Command execution failed: ERROR: Secret 'hcloud' not found in kube-system.\n Create it first: kubectl -n kube-system create secret generic hcloud --from-literal=token=\n", + "tags": { + "type": "component", + "operation": "install", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "component": "hetzner_csi", + "server": "libre-wuji-cp-0" + } +} \ No newline at end of file diff --git a/crates/data/tasks/e51a4602-eb4a-404a-8a92-5959c5e47673.json b/crates/data/tasks/e51a4602-eb4a-404a-8a92-5959c5e47673.json new file mode 100644 index 0000000..65ba9a4 --- /dev/null +++ b/crates/data/tasks/e51a4602-eb4a-404a-8a92-5959c5e47673.json @@ -0,0 +1,23 @@ +{ + "id": "e51a4602-eb4a-404a-8a92-5959c5e47673", + "name": "component_install_resolv", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_e51a4602-eb4a-404a-8a92-5959c5e47673.tar.gz.b64' > /tmp/resolv.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/resolv.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/resolv && mkdir -p /tmp/resolv && tar xzf /tmp/resolv.tar.gz -C /tmp/resolv && cd /tmp/resolv && sudo bash install-resolv.sh install ; rc=$?; rm -f /tmp/resolv.tar.gz && rm -rf /tmp/resolv; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_e51a4602-eb4a-404a-8a92-5959c5e47673.tar.gz.b64' /tmp/resolv.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-20T10:40:56.821515Z", + "started_at": "2026-04-20T10:40:57.085192Z", + "completed_at": "2026-04-20T10:40:59.920824Z", + "output": "", + "error": null, + "tags": { + "type": "component", + "server": "libre-wuji-cp-0", + "component": "resolv", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/e580c958-565a-4fc3-b2b5-03d2af3fde4b.json b/crates/data/tasks/e580c958-565a-4fc3-b2b5-03d2af3fde4b.json new file mode 100644 index 0000000..b1d45c1 --- /dev/null +++ b/crates/data/tasks/e580c958-565a-4fc3-b2b5-03d2af3fde4b.json @@ -0,0 +1,23 @@ +{ + "id": "e580c958-565a-4fc3-b2b5-03d2af3fde4b", + "name": "component_install_crio", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_e580c958-565a-4fc3-b2b5-03d2af3fde4b.tar.gz.b64' > /tmp/crio.tar.gz && scp /tmp/crio.tar.gz 'root@:/tmp/' && ssh 'root@' 'rm -rf /tmp/crio && mkdir -p /tmp/crio && tar xzf /tmp/crio.tar.gz -C /tmp/crio && cd /tmp/crio && sudo bash install-crio.sh install ; rc=$?; rm -f /tmp/crio.tar.gz && rm -rf /tmp/crio; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_e580c958-565a-4fc3-b2b5-03d2af3fde4b.tar.gz.b64' /tmp/crio.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Failed", + "created_at": "2026-04-21T01:46:03.839259Z", + "started_at": "2026-04-21T01:46:03.887426Z", + "completed_at": "2026-04-21T01:46:03.944969Z", + "output": null, + "error": "Command execution failed: ssh: Could not resolve hostname : nodename nor servname provided, or not known\nscp: Connection closed\n", + "tags": { + "type": "component", + "component": "crio", + "operation": "install", + "server": "", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji" + } +} \ No newline at end of file diff --git a/crates/data/tasks/e584bbf5-8ad7-41bf-8783-92eb9172fb9f.json b/crates/data/tasks/e584bbf5-8ad7-41bf-8783-92eb9172fb9f.json new file mode 100644 index 0000000..e83547b --- /dev/null +++ b/crates/data/tasks/e584bbf5-8ad7-41bf-8783-92eb9172fb9f.json @@ -0,0 +1,23 @@ +{ + "id": "e584bbf5-8ad7-41bf-8783-92eb9172fb9f", + "name": "component_install_hetzner_csi", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_e584bbf5-8ad7-41bf-8783-92eb9172fb9f.tar.gz.b64' > /tmp/hetzner_csi.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/hetzner_csi.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/hetzner_csi && mkdir -p /tmp/hetzner_csi && tar xzf /tmp/hetzner_csi.tar.gz -C /tmp/hetzner_csi && cd /tmp/hetzner_csi && sudo bash install-hetzner_csi.sh install ; rc=$?; rm -f /tmp/hetzner_csi.tar.gz && rm -rf /tmp/hetzner_csi; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_e584bbf5-8ad7-41bf-8783-92eb9172fb9f.tar.gz.b64' /tmp/hetzner_csi.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Failed", + "created_at": "2026-04-21T02:24:50.940436Z", + "started_at": "2026-04-21T02:24:51.281269Z", + "completed_at": "2026-04-21T02:24:54.012456Z", + "output": null, + "error": "Command execution failed: ERROR: Secret 'hcloud' not found in kube-system.\n Create it first: kubectl -n kube-system create secret generic hcloud --from-literal=token=\n", + "tags": { + "type": "component", + "server": "libre-wuji-cp-0", + "operation": "install", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "component": "hetzner_csi" + } +} \ No newline at end of file diff --git a/crates/data/tasks/e6b257c4-c698-4e99-a3d2-d7ea3cac19a2.json b/crates/data/tasks/e6b257c4-c698-4e99-a3d2-d7ea3cac19a2.json new file mode 100644 index 0000000..4681db9 --- /dev/null +++ b/crates/data/tasks/e6b257c4-c698-4e99-a3d2-d7ea3cac19a2.json @@ -0,0 +1,23 @@ +{ + "id": "e6b257c4-c698-4e99-a3d2-d7ea3cac19a2", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_e6b257c4-c698-4e99-a3d2-d7ea3cac19a2.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_e6b257c4-c698-4e99-a3d2-d7ea3cac19a2.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T02:50:53.876875Z", + "started_at": "2026-04-21T02:50:54.278867Z", + "completed_at": "2026-04-21T02:50:57.586111Z", + "output": "No k8s_join.sh found\n", + "error": null, + "tags": { + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "component": "kubernetes", + "operation": "install", + "server": "libre-wuji-wrk-0", + "type": "component" + } +} \ No newline at end of file diff --git a/crates/data/tasks/ea9feaf9-cfe7-4de2-b178-83f898a1d4f3.json b/crates/data/tasks/ea9feaf9-cfe7-4de2-b178-83f898a1d4f3.json new file mode 100644 index 0000000..5aec41f --- /dev/null +++ b/crates/data/tasks/ea9feaf9-cfe7-4de2-b178-83f898a1d4f3.json @@ -0,0 +1,23 @@ +{ + "id": "ea9feaf9-cfe7-4de2-b178-83f898a1d4f3", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_ea9feaf9-cfe7-4de2-b178-83f898a1d4f3.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_ea9feaf9-cfe7-4de2-b178-83f898a1d4f3.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T05:10:01.685690Z", + "started_at": "2026-04-21T05:10:02.160670Z", + "completed_at": "2026-04-21T05:10:07.866688Z", + "output": "", + "error": null, + "tags": { + "type": "component", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "server": "libre-wuji-wrk-0", + "operation": "install", + "component": "kubernetes" + } +} \ No newline at end of file diff --git a/crates/data/tasks/eb2ee1ff-3353-4d4a-a6aa-9cbb61b595bb.json b/crates/data/tasks/eb2ee1ff-3353-4d4a-a6aa-9cbb61b595bb.json new file mode 100644 index 0000000..f7ea555 --- /dev/null +++ b/crates/data/tasks/eb2ee1ff-3353-4d4a-a6aa-9cbb61b595bb.json @@ -0,0 +1,23 @@ +{ + "id": "eb2ee1ff-3353-4d4a-a6aa-9cbb61b595bb", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_eb2ee1ff-3353-4d4a-a6aa-9cbb61b595bb.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_eb2ee1ff-3353-4d4a-a6aa-9cbb61b595bb.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T04:52:41.326636Z", + "started_at": "2026-04-21T04:52:41.713759Z", + "completed_at": "2026-04-21T04:52:52.789530Z", + "output": "", + "error": null, + "tags": { + "type": "component", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "component": "kubernetes", + "server": "libre-wuji-wrk-0", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/ed1522d0-9282-4672-8446-57d66be5cebd.json b/crates/data/tasks/ed1522d0-9282-4672-8446-57d66be5cebd.json new file mode 100644 index 0000000..5963e01 --- /dev/null +++ b/crates/data/tasks/ed1522d0-9282-4672-8446-57d66be5cebd.json @@ -0,0 +1,23 @@ +{ + "id": "ed1522d0-9282-4672-8446-57d66be5cebd", + "name": "component_install_crun", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_ed1522d0-9282-4672-8446-57d66be5cebd.tar.gz.b64' > /tmp/crun.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/crun.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/crun && mkdir -p /tmp/crun && tar xzf /tmp/crun.tar.gz -C /tmp/crun && cd /tmp/crun && sudo bash install-crun.sh install ; rc=$?; rm -f /tmp/crun.tar.gz && rm -rf /tmp/crun; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_ed1522d0-9282-4672-8446-57d66be5cebd.tar.gz.b64' /tmp/crun.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T01:44:12.292624Z", + "started_at": "2026-04-21T01:44:12.491817Z", + "completed_at": "2026-04-21T01:44:15.868240Z", + "output": "", + "error": null, + "tags": { + "type": "component", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "server": "libre-wuji-wrk-0", + "operation": "install", + "component": "crun" + } +} \ No newline at end of file diff --git a/crates/data/tasks/eec60caa-c67f-4245-9f14-ba925d132325.json b/crates/data/tasks/eec60caa-c67f-4245-9f14-ba925d132325.json new file mode 100644 index 0000000..1a8ae79 --- /dev/null +++ b/crates/data/tasks/eec60caa-c67f-4245-9f14-ba925d132325.json @@ -0,0 +1,23 @@ +{ + "id": "eec60caa-c67f-4245-9f14-ba925d132325", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_eec60caa-c67f-4245-9f14-ba925d132325.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_eec60caa-c67f-4245-9f14-ba925d132325.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T04:52:26.500905Z", + "started_at": "2026-04-21T04:52:26.657225Z", + "completed_at": "2026-04-21T04:52:32.610173Z", + "output": "", + "error": null, + "tags": { + "server": "libre-wuji-wrk-0", + "type": "component", + "operation": "install", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "component": "kubernetes" + } +} \ No newline at end of file diff --git a/crates/data/tasks/f106bf5b-62fb-419e-90ef-39c20fcff891.json b/crates/data/tasks/f106bf5b-62fb-419e-90ef-39c20fcff891.json new file mode 100644 index 0000000..4850d0e --- /dev/null +++ b/crates/data/tasks/f106bf5b-62fb-419e-90ef-39c20fcff891.json @@ -0,0 +1,23 @@ +{ + "id": "f106bf5b-62fb-419e-90ef-39c20fcff891", + "name": "component_install_runc", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_f106bf5b-62fb-419e-90ef-39c20fcff891.tar.gz.b64' > /tmp/runc.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/runc.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/runc && mkdir -p /tmp/runc && tar xzf /tmp/runc.tar.gz -C /tmp/runc && cd /tmp/runc && sudo bash install-runc.sh install ; rc=$?; rm -f /tmp/runc.tar.gz && rm -rf /tmp/runc; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_f106bf5b-62fb-419e-90ef-39c20fcff891.tar.gz.b64' /tmp/runc.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-20T10:27:57.190540Z", + "started_at": "2026-04-20T10:27:57.595116Z", + "completed_at": "2026-04-20T10:28:01.280644Z", + "output": "", + "error": null, + "tags": { + "operation": "install", + "server": "libre-wuji-wrk-0", + "type": "component", + "component": "runc", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji" + } +} \ No newline at end of file diff --git a/crates/data/tasks/f3937429-e4fa-4370-9cd7-2c0748f5bfa6.json b/crates/data/tasks/f3937429-e4fa-4370-9cd7-2c0748f5bfa6.json new file mode 100644 index 0000000..b4935ff --- /dev/null +++ b/crates/data/tasks/f3937429-e4fa-4370-9cd7-2c0748f5bfa6.json @@ -0,0 +1,23 @@ +{ + "id": "f3937429-e4fa-4370-9cd7-2c0748f5bfa6", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_f3937429-e4fa-4370-9cd7-2c0748f5bfa6.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_f3937429-e4fa-4370-9cd7-2c0748f5bfa6.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T04:45:40.003708Z", + "started_at": "2026-04-21T04:45:40.126107Z", + "completed_at": "2026-04-21T04:45:53.248843Z", + "output": "", + "error": null, + "tags": { + "type": "component", + "component": "kubernetes", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install", + "server": "libre-wuji-wrk-0" + } +} \ No newline at end of file diff --git a/crates/data/tasks/f3a72689-bb2e-4794-900c-b609886cc0ad.json b/crates/data/tasks/f3a72689-bb2e-4794-900c-b609886cc0ad.json new file mode 100644 index 0000000..bf788ea --- /dev/null +++ b/crates/data/tasks/f3a72689-bb2e-4794-900c-b609886cc0ad.json @@ -0,0 +1,17 @@ +{ + "id": "f3a72689-bb2e-4794-900c-b609886cc0ad", + "name": "execute_servers_script_libre-wuji-cp-0", + "command": "bash", + "args": [ + "-c", + "base64 -d < /tmp/orchestrator_script_f3a72689-bb2e-4794-900c-b609886cc0ad.tar.gz.b64 | gunzip | tar -xOf - script.sh | bash +x" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T01:34:24.817353Z", + "started_at": "2026-04-21T01:34:25.239167Z", + "completed_at": "2026-04-21T01:34:53.352333Z", + "output": "✓ Prerequisites: HCLOUD_TOKEN set, hcloud CLI available\n\n=== Managing SSH Keys ===\n✓ SSH public key found: /Users/jesusperezlorenzo/.ssh/htz_ops.pub\nChecking if SSH key 'htz_ops' exists in Hetzner...\n✓ SSH key htz_ops already exists with ID: 106168627\n\n=== SSH Key Management Complete ===\nSSH_KEY_ID: 106168627\nSSH_KEY_NAME: htz_ops\nEnvironment exported to: /tmp/provisioning-wuji-20260421-023425/.env\n\n=== Managing Network ===\n✓ Network config validated: 10.0.8.0/22 with subnet 10.0.8.0/24 in zone eu-central\nChecking if network 'wuwei' exists...\n✓ Network 'wuwei' already exists with ID: 11875940\n ✓ Subnet 10.0.8.0/24 already present\n\n=== Network Management Complete ===\nNETWORK_ID: 11875940\nNETWORK_NAME: wuwei\nEnvironment exported to: /tmp/provisioning-wuji-20260421-023425/.env\n\u001b[0;34m[INFO]\u001b[0m Step 1/3: Checking server 'libre-wuji-cp-0'...\n\u001b[0;34m[INFO]\u001b[0m Creating server 'libre-wuji-cp-0'...\n\u001b[0;32m[✓]\u001b[0m 127588641 libre-wuji-cp-0 createdIPv4: 2a01:4f8:c014:25e0::1IPv6\n\u001b[0;32m[✓]\u001b[0m Server created: ID=127588641\n\u001b[0;34m[INFO]\u001b[0m Waiting for server 'libre-wuji-cp-0' to reach running state...\n\u001b[0;32m[✓]\u001b[0m Server 'libre-wuji-cp-0' is running\nServer 127588641 attached to Network 11875940\n\u001b[0;32m[✓]\u001b[0m Attached to network wuwei with IP 10.0.8.20\nResource protection enabled for Server 127588641\n\u001b[0;32m[✓]\u001b[0m Protection enabled: delete rebuild\n\u001b[0;34m[INFO]\u001b[0m Step 2/2: Attaching server to firewall 'librecloud-fw'...\nFirewall 10819279 applied to resource\n\u001b[0;32m[✓]\u001b[0m Server attached to firewall 'librecloud-fw'\n\u001b[0;32m[✓]\u001b[0m ✓ Server provisioning complete\n\u001b[0;34m[INFO]\u001b[0m Summary:\n\u001b[0;34m[INFO]\u001b[0m • Server: libre-wuji-cp-0 (ID: 127588641)\n\u001b[0;34m[INFO]\u001b[0m • Firewall: librecloud-fw\n\u001b[0;34m[INFO]\u001b[0m • Status: Ready for deployment\n", + "error": null, + "tags": {} +} \ No newline at end of file diff --git a/crates/data/tasks/f5b68a7f-0b5a-41fb-9aa5-2d37114063a1.json b/crates/data/tasks/f5b68a7f-0b5a-41fb-9aa5-2d37114063a1.json new file mode 100644 index 0000000..baa6dd8 --- /dev/null +++ b/crates/data/tasks/f5b68a7f-0b5a-41fb-9aa5-2d37114063a1.json @@ -0,0 +1,23 @@ +{ + "id": "f5b68a7f-0b5a-41fb-9aa5-2d37114063a1", + "name": "component_install_runc", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_f5b68a7f-0b5a-41fb-9aa5-2d37114063a1.tar.gz.b64' > /tmp/runc.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/runc.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/runc && mkdir -p /tmp/runc && tar xzf /tmp/runc.tar.gz -C /tmp/runc && cd /tmp/runc && sudo bash install-runc.sh install ; rc=$?; rm -f /tmp/runc.tar.gz && rm -rf /tmp/runc; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_f5b68a7f-0b5a-41fb-9aa5-2d37114063a1.tar.gz.b64' /tmp/runc.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T01:59:37.365362Z", + "started_at": "2026-04-21T01:59:37.548781Z", + "completed_at": "2026-04-21T01:59:40.331930Z", + "output": "", + "error": null, + "tags": { + "type": "component", + "server": "libre-wuji-wrk-0", + "component": "runc", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/f7fc408d-59f6-4bdd-828b-9d4bb5750fe8.json b/crates/data/tasks/f7fc408d-59f6-4bdd-828b-9d4bb5750fe8.json new file mode 100644 index 0000000..61dcfc5 --- /dev/null +++ b/crates/data/tasks/f7fc408d-59f6-4bdd-828b-9d4bb5750fe8.json @@ -0,0 +1,23 @@ +{ + "id": "f7fc408d-59f6-4bdd-828b-9d4bb5750fe8", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_f7fc408d-59f6-4bdd-828b-9d4bb5750fe8.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_f7fc408d-59f6-4bdd-828b-9d4bb5750fe8.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T03:05:43.311883Z", + "started_at": "2026-04-21T03:05:43.737278Z", + "completed_at": "2026-04-21T03:06:58.313275Z", + "output": "[init] Using Kubernetes version: v1.35.4\n[preflight] Running pre-flight checks\n[preflight] Pulling images required for setting up a Kubernetes cluster\n[preflight] This might take a minute or two, depending on the speed of your internet connection\n[preflight] You can also perform this action beforehand using 'kubeadm config images pull'\n[certs] Using certificateDir folder \"/etc/kubernetes/pki\"\n[certs] Generating \"ca\" certificate and key\n[certs] Generating \"apiserver\" certificate and key\n[certs] apiserver serving cert is signed for DNS names [kubernetes kubernetes.default kubernetes.default.svc kubernetes.default.svc.libre-wuji.local libre-wuji libre-wuji-cp-0] and IPs [10.96.0.1 10.0.8.20 127.0.0.1 ::1]\n[certs] Generating \"apiserver-kubelet-client\" certificate and key\n[certs] Generating \"front-proxy-ca\" certificate and key\n[certs] Generating \"front-proxy-client\" certificate and key\n[certs] Generating \"etcd/ca\" certificate and key\n[certs] Generating \"etcd/server\" certificate and key\n[certs] etcd/server serving cert is signed for DNS names [libre-wuji-cp-0 localhost] and IPs [10.0.8.20 127.0.0.1 ::1]\n[certs] Generating \"etcd/peer\" certificate and key\n[certs] etcd/peer serving cert is signed for DNS names [libre-wuji-cp-0 localhost] and IPs [10.0.8.20 127.0.0.1 ::1]\n[certs] Generating \"etcd/healthcheck-client\" certificate and key\n[certs] Generating \"apiserver-etcd-client\" certificate and key\n[certs] Generating \"sa\" key and public key\n[kubeconfig] Using kubeconfig folder \"/etc/kubernetes\"\n[kubeconfig] Writing \"admin.conf\" kubeconfig file\n[kubeconfig] Writing \"super-admin.conf\" kubeconfig file\n[kubeconfig] Writing \"kubelet.conf\" kubeconfig file\n[kubeconfig] Writing \"controller-manager.conf\" kubeconfig file\n[kubeconfig] Writing \"scheduler.conf\" kubeconfig file\n[etcd] Creating static Pod manifest for local etcd in \"/etc/kubernetes/manifests\"\n[control-plane] Using manifest folder \"/etc/kubernetes/manifests\"\n[control-plane] Creating static Pod manifest for \"kube-apiserver\"\n[control-plane] Creating static Pod manifest for \"kube-controller-manager\"\n[control-plane] Creating static Pod manifest for \"kube-scheduler\"\n[kubelet-start] Writing kubelet environment file with flags to file \"/var/lib/kubelet/kubeadm-flags.env\"\n[kubelet-start] Writing kubelet configuration to file \"/var/lib/kubelet/instance-config.yaml\"\n[patches] Applied patch of type \"application/strategic-merge-patch+json\" to target \"kubeletconfiguration\"\n[kubelet-start] Writing kubelet configuration to file \"/var/lib/kubelet/config.yaml\"\n[kubelet-start] Starting the kubelet\n[wait-control-plane] Waiting for the kubelet to boot up the control plane as static Pods from directory \"/etc/kubernetes/manifests\"\n[kubelet-check] Waiting for a healthy kubelet at http://127.0.0.1:10248/healthz. This can take up to 4m0s\n[kubelet-check] The kubelet is healthy after 501.930954ms\n[control-plane-check] Waiting for healthy control plane components. This can take up to 4m0s\n[control-plane-check] Checking kube-apiserver at https://10.0.8.20:6443/livez\n[control-plane-check] Checking kube-controller-manager at https://127.0.0.1:10257/healthz\n[control-plane-check] Checking kube-scheduler at https://127.0.0.1:10259/livez\n[control-plane-check] kube-controller-manager is healthy after 1.008284896s\n[control-plane-check] kube-scheduler is healthy after 2.540470913s\n[control-plane-check] kube-apiserver is healthy after 4.501453226s\n[upload-config] Storing the configuration used in ConfigMap \"kubeadm-config\" in the \"kube-system\" Namespace\n[kubelet] Creating a ConfigMap \"kubelet-config\" in namespace kube-system with the configuration for the kubelets in the cluster\n[upload-certs] Skipping phase. Please see --upload-certs\n[mark-control-plane] Marking the node libre-wuji-cp-0 as control-plane by adding the labels: [node-role.kubernetes.io/control-plane node.kubernetes.io/exclude-from-external-load-balancers]\n[bootstrap-token] Using token: 649nkc.nqsxhvnjt0nacxi8\n[bootstrap-token] Configuring bootstrap tokens, cluster-info ConfigMap, RBAC Roles\n[bootstrap-token] Configured RBAC rules to allow Node Bootstrap tokens to get nodes\n[bootstrap-token] Configured RBAC rules to allow Node Bootstrap tokens to post CSRs in order for nodes to get long term certificate credentials\n[bootstrap-token] Configured RBAC rules to allow the csrapprover controller automatically approve CSRs from a Node Bootstrap Token\n[bootstrap-token] Configured RBAC rules to allow certificate rotation for all node client certificates in the cluster\n[bootstrap-token] Creating the \"cluster-info\" ConfigMap in the \"kube-public\" namespace\n[kubelet-finalize] Updating \"/etc/kubernetes/kubelet.conf\" to point to a rotatable kubelet client certificate and key\n[addons] Applied essential addon: CoreDNS\n\nYour Kubernetes control-plane has initialized successfully!\n\nTo start using your cluster, you need to run the following as a regular user:\n\n mkdir -p $HOME/.kube\n sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config\n sudo chown $(id -u):$(id -g) $HOME/.kube/config\n\nAlternatively, if you are the root user, you can run:\n\n export KUBECONFIG=/etc/kubernetes/admin.conf\n\nYou should now deploy a pod network to the cluster.\nRun \"kubectl apply -f [podnetwork].yaml\" with one of the options listed at:\n https://kubernetes.io/docs/concepts/cluster-administration/addons/\n\nYou can now join any number of control-plane nodes by copying certificate authorities\nand service account keys on each node and then running the following as root:\n\n kubeadm join 10.0.8.20:6443 --token 649nkc.nqsxhvnjt0nacxi8 \\\n\t--discovery-token-ca-cert-hash sha256:454558c5c7b8d275499e5c8e1c652c3f31441dd14d3fa90cc653a218cf0e158e \\\n\t--control-plane \n\nThen you can join any number of worker nodes by running the following on each as root:\n\nkubeadm join 10.0.8.20:6443 --token 649nkc.nqsxhvnjt0nacxi8 \\\n\t--discovery-token-ca-cert-hash sha256:454558c5c7b8d275499e5c8e1c652c3f31441dd14d3fa90cc653a218cf0e158e \nprobes patched\n2026_04_21_030556 | apiserver probes patched: startup=300s liveness=120s readiness=15s\n2026_04_21_030556 | etcd endpoints reordered: https://127.0.0.1:2379,https://127.0.0.1:2379\nUpdating certificates in /etc/ssl/certs...\n0 added, 0 removed; done.\nRunning hooks in /etc/ca-certificates/update.d...\ndone.\n[addons] Applied essential addon: CoreDNS\nruntimeclass.node.k8s.io/runc created\n2026_04_21_030657 | Waiting for RBAC bootstrap to complete...\n2026_04_21_030657 | RBAC bootstrap complete (attempt 1)\n", + "error": null, + "tags": { + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install", + "type": "component", + "component": "kubernetes", + "server": "libre-wuji-cp-0" + } +} \ No newline at end of file diff --git a/crates/data/tasks/f8e6eabb-df41-4f4c-ac4b-c6492f37bb16.json b/crates/data/tasks/f8e6eabb-df41-4f4c-ac4b-c6492f37bb16.json new file mode 100644 index 0000000..89befe8 --- /dev/null +++ b/crates/data/tasks/f8e6eabb-df41-4f4c-ac4b-c6492f37bb16.json @@ -0,0 +1,23 @@ +{ + "id": "f8e6eabb-df41-4f4c-ac4b-c6492f37bb16", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_f8e6eabb-df41-4f4c-ac4b-c6492f37bb16.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_f8e6eabb-df41-4f4c-ac4b-c6492f37bb16.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T03:29:20.530579Z", + "started_at": "2026-04-21T03:29:20.672766Z", + "completed_at": "2026-04-21T03:29:33.481725Z", + "output": "No k8s_join.sh found\n", + "error": null, + "tags": { + "server": "libre-wuji-wrk-0", + "type": "component", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "component": "kubernetes", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/fb6b2b1f-d229-4bae-8d8b-ef56c28dcb77.json b/crates/data/tasks/fb6b2b1f-d229-4bae-8d8b-ef56c28dcb77.json new file mode 100644 index 0000000..4ad89e0 --- /dev/null +++ b/crates/data/tasks/fb6b2b1f-d229-4bae-8d8b-ef56c28dcb77.json @@ -0,0 +1,23 @@ +{ + "id": "fb6b2b1f-d229-4bae-8d8b-ef56c28dcb77", + "name": "component_install_crio", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_fb6b2b1f-d229-4bae-8d8b-ef56c28dcb77.tar.gz.b64' > /tmp/crio.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/crio.tar.gz 'root@libre-wuji-cp-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-cp-0' 'rm -rf /tmp/crio && mkdir -p /tmp/crio && tar xzf /tmp/crio.tar.gz -C /tmp/crio && cd /tmp/crio && sudo bash install-crio.sh install ; rc=$?; rm -f /tmp/crio.tar.gz && rm -rf /tmp/crio; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_fb6b2b1f-d229-4bae-8d8b-ef56c28dcb77.tar.gz.b64' /tmp/crio.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T02:08:27.681709Z", + "started_at": "2026-04-21T02:08:28.038254Z", + "completed_at": "2026-04-21T02:08:37.646637Z", + "output": "/opt/cni/bin/*\n/etc/cni/net.d/10-crio-bridge.conflist.disabled\n/usr/libexec/crio/conmon\n/usr/libexec/crio/conmonrs\n/usr/libexec/crio/crun\n/usr/libexec/crio/runc\n/usr/libexec/crio/crio-credential-provider\n/usr/local/bin/crio\n/usr/local/bin/pinns\n/usr/local/bin/crictl\n/etc/crictl.yaml\n/usr/local/share/oci-umount/oci-umount.d/crio-umount.conf\n/etc/default/crio\n/etc/crio/policy.json\n/etc/crio/crio.conf.d/10-crio.conf\n/usr/local/share/man/man5/crio.conf.5\n/usr/local/share/man/man5/crio.conf.d.5\n/usr/local/share/man/man8/crio.8\n/usr/local/share/bash-completion/completions/crio\n/usr/local/share/fish/completions/crio.fish\n/usr/local/share/zsh/site-functions/_crio\n/usr/local/lib/systemd/system/crio.service\n/etc/containers/registries.conf.d/registries.conf\noverlay\nbr_netfilter\n", + "error": null, + "tags": { + "type": "component", + "component": "crio", + "server": "libre-wuji-cp-0", + "operation": "install", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji" + } +} \ No newline at end of file diff --git a/crates/data/tasks/fc9be86c-89ff-438c-a48f-81ede14ced54.json b/crates/data/tasks/fc9be86c-89ff-438c-a48f-81ede14ced54.json new file mode 100644 index 0000000..941ada4 --- /dev/null +++ b/crates/data/tasks/fc9be86c-89ff-438c-a48f-81ede14ced54.json @@ -0,0 +1,23 @@ +{ + "id": "fc9be86c-89ff-438c-a48f-81ede14ced54", + "name": "component_install_runc", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_fc9be86c-89ff-438c-a48f-81ede14ced54.tar.gz.b64' > /tmp/runc.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/runc.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/runc && mkdir -p /tmp/runc && tar xzf /tmp/runc.tar.gz -C /tmp/runc && cd /tmp/runc && sudo bash install-runc.sh install ; rc=$?; rm -f /tmp/runc.tar.gz && rm -rf /tmp/runc; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_fc9be86c-89ff-438c-a48f-81ede14ced54.tar.gz.b64' /tmp/runc.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T01:59:12.279149Z", + "started_at": "2026-04-21T01:59:12.448712Z", + "completed_at": "2026-04-21T01:59:15.253034Z", + "output": "", + "error": null, + "tags": { + "component": "runc", + "server": "libre-wuji-wrk-0", + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "type": "component", + "operation": "install" + } +} \ No newline at end of file diff --git a/crates/data/tasks/ff236d78-94b6-4c8b-97a4-21fd8bea8a8a.json b/crates/data/tasks/ff236d78-94b6-4c8b-97a4-21fd8bea8a8a.json new file mode 100644 index 0000000..df70e17 --- /dev/null +++ b/crates/data/tasks/ff236d78-94b6-4c8b-97a4-21fd8bea8a8a.json @@ -0,0 +1,23 @@ +{ + "id": "ff236d78-94b6-4c8b-97a4-21fd8bea8a8a", + "name": "component_install_kubernetes", + "command": "bash", + "args": [ + "-c", + "base64 -d < '/tmp/orchestrator_comp_ff236d78-94b6-4c8b-97a4-21fd8bea8a8a.tar.gz.b64' > /tmp/kubernetes.tar.gz && scp -i '/Users/jesusperezlorenzo/.ssh/htz_ops' /tmp/kubernetes.tar.gz 'root@libre-wuji-wrk-0:/tmp/' && ssh -i '/Users/jesusperezlorenzo/.ssh/htz_ops' 'root@libre-wuji-wrk-0' 'rm -rf /tmp/kubernetes && mkdir -p /tmp/kubernetes && tar xzf /tmp/kubernetes.tar.gz -C /tmp/kubernetes && cd /tmp/kubernetes && sudo bash install-kubernetes.sh install ; rc=$?; rm -f /tmp/kubernetes.tar.gz && rm -rf /tmp/kubernetes; exit $rc' ; rc=$?; rm -f '/tmp/orchestrator_comp_ff236d78-94b6-4c8b-97a4-21fd8bea8a8a.tar.gz.b64' /tmp/kubernetes.tar.gz; exit $rc" + ], + "dependencies": [], + "status": "Completed", + "created_at": "2026-04-21T04:41:44.585113Z", + "started_at": "2026-04-21T04:41:44.750897Z", + "completed_at": "2026-04-21T04:41:48.208914Z", + "output": "", + "error": null, + "tags": { + "workspace": "/Users/Akasha/project-provisioning/workspaces/libre-wuji", + "operation": "install", + "component": "kubernetes", + "type": "component", + "server": "libre-wuji-wrk-0" + } +} \ No newline at end of file diff --git a/crates/detector/Cargo.toml b/crates/detector/Cargo.toml deleted file mode 100644 index f85c675..0000000 --- a/crates/detector/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -authors.workspace = true -edition.workspace = true -license.workspace = true -name = "provisioning-detector" -repository.workspace = true -version.workspace = true - -[dependencies] -anyhow.workspace = true -chrono.workspace = true -clap = { workspace = true, features = ["derive"] } -regex.workspace = true -serde = { version = "1.0", features = ["derive"] } -serde_json.workspace = true -thiserror.workspace = true -tokio.workspace = true -toml.workspace = true -walkdir.workspace = true - -[dev-dependencies] -tempfile.workspace = true diff --git a/crates/detector/src/bin/provisioning-detector.rs b/crates/detector/src/bin/provisioning-detector.rs deleted file mode 100644 index 89da7d8..0000000 --- a/crates/detector/src/bin/provisioning-detector.rs +++ /dev/null @@ -1,9 +0,0 @@ -/// Provisioning Detector CLI Binary -/// -/// Main entry point for the provisioning detector CLI tool -/// Provides `detect` and `complete` subcommands for infrastructure analysis -use provisioning_detector::cli::Cli; - -fn main() -> anyhow::Result<()> { - Cli::run() -} diff --git a/crates/detector/src/cli/commands.rs b/crates/detector/src/cli/commands.rs deleted file mode 100644 index f6a4ef8..0000000 --- a/crates/detector/src/cli/commands.rs +++ /dev/null @@ -1,423 +0,0 @@ -use std::path::PathBuf; - -use clap::Parser; -use serde_json::json; - -use crate::completion::GapAnalyzer; -/// CLI command implementations -use crate::{Completer, StackDetector}; - -/// Detect command - analyze project and infer requirements -#[derive(Parser, Debug)] -pub struct DetectCommand { - /// Path to project directory - #[arg(value_name = "PATH")] - pub path: PathBuf, - - /// Output format (json, yaml, or text) - #[arg(short, long, default_value = "text")] - pub format: OutputFormat, - - /// Show only high-confidence detections - #[arg(short = 'C', long)] - pub high_confidence_only: bool, - - /// Pretty-print JSON output - #[arg(short = 'p', long)] - pub pretty: bool, -} - -#[derive(Debug, Clone, Copy)] -pub enum OutputFormat { - Json, - Yaml, - Text, -} - -impl std::str::FromStr for OutputFormat { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "json" => Ok(OutputFormat::Json), - "yaml" | "yml" => Ok(OutputFormat::Yaml), - "text" => Ok(OutputFormat::Text), - _ => Err(format!("Invalid format: {}", s)), - } - } -} - -impl std::fmt::Display for OutputFormat { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - OutputFormat::Json => write!(f, "json"), - OutputFormat::Yaml => write!(f, "yaml"), - OutputFormat::Text => write!(f, "text"), - } - } -} - -impl DetectCommand { - pub fn execute(&self) -> anyhow::Result<()> { - // Verify path exists - if !self.path.exists() { - anyhow::bail!("Project path does not exist: {}", self.path.display()); - } - - // Run detection - let detector = StackDetector::new(); - let analysis = detector.detect_all(&self.path)?; - - // Filter by confidence if requested - let detections = if self.high_confidence_only { - analysis - .detections - .iter() - .filter(|d| d.is_high_confidence()) - .cloned() - .collect::>() - } else { - analysis.detections.clone() - }; - - // Output results - match self.format { - OutputFormat::Json => self.output_json(&analysis, &detections)?, - OutputFormat::Yaml => self.output_yaml(&analysis, &detections)?, - OutputFormat::Text => self.output_text(&analysis, &detections)?, - } - - Ok(()) - } - - fn output_json( - &self, - analysis: &crate::ProjectAnalysis, - detections: &[crate::Detection], - ) -> anyhow::Result<()> { - let output = json!({ - "project_path": analysis.project_path.display().to_string(), - "overall_confidence": analysis.overall_confidence, - "detections": detections.iter().map(|d| json!({ - "technology": d.technology.as_str(), - "confidence": d.confidence, - "evidence_count": d.evidence.len(), - })).collect::>(), - "requirements": analysis.requirements.iter().map(|r| json!({ - "taskserv": r.taskserv, - "reason": r.reason, - "confidence": r.confidence, - "required": r.required, - })).collect::>(), - }); - - if self.pretty { - println!("{}", serde_json::to_string_pretty(&output)?); - } else { - println!("{}", output); - } - - Ok(()) - } - - fn output_yaml( - &self, - analysis: &crate::ProjectAnalysis, - detections: &[crate::Detection], - ) -> anyhow::Result<()> { - // Convert JSON to YAML for simplicity - let json_output = json!({ - "project_path": analysis.project_path.display().to_string(), - "overall_confidence": analysis.overall_confidence, - "detections": detections.iter().map(|d| json!({ - "technology": d.technology.as_str(), - "confidence": d.confidence, - "evidence_count": d.evidence.len(), - })).collect::>(), - "requirements": analysis.requirements.iter().map(|r| json!({ - "taskserv": r.taskserv, - "reason": r.reason, - "confidence": r.confidence, - "required": r.required, - })).collect::>(), - }); - - // For now, use JSON with pretty printing as YAML equivalent - println!("{}", serde_json::to_string_pretty(&json_output)?); - Ok(()) - } - - fn output_text( - &self, - analysis: &crate::ProjectAnalysis, - detections: &[crate::Detection], - ) -> anyhow::Result<()> { - println!("\n╔════════════════════════════════════════════════╗"); - println!("║ Project Technology Detection ║"); - println!("╚════════════════════════════════════════════════╝\n"); - - println!("Project Path: {}", analysis.project_path.display()); - println!( - "Overall Confidence: {:.1}%\n", - analysis.overall_confidence * 100.0 - ); - - if !detections.is_empty() { - println!("Detected Technologies:"); - for detection in detections { - let confidence_indicator = if detection.is_high_confidence() { - "✓" - } else { - "○" - }; - println!( - " {} {:<15} - {:.1}% confidence ({} evidence)", - confidence_indicator, - detection.technology.as_str(), - detection.confidence * 100.0, - detection.evidence.len() - ); - } - println!(); - } - - if !analysis.requirements.is_empty() { - println!("Inferred Requirements:"); - let required: Vec<_> = analysis - .requirements - .iter() - .filter(|r| r.required) - .collect(); - let optional: Vec<_> = analysis - .requirements - .iter() - .filter(|r| !r.required) - .collect(); - - if !required.is_empty() { - println!(" Required:"); - for req in required { - println!( - " • {:<20} - {:.1}% confidence ({})", - req.taskserv, - req.confidence * 100.0, - req.reason - ); - } - } - - if !optional.is_empty() { - println!(" Optional:"); - for req in optional { - println!( - " • {:<20} - {:.1}% confidence ({})", - req.taskserv, - req.confidence * 100.0, - req.reason - ); - } - } - println!(); - } - - Ok(()) - } -} - -/// Complete command - analyze gaps and suggest completions -#[derive(Parser, Debug)] -pub struct CompleteCommand { - /// Path to project directory - #[arg(value_name = "PATH")] - pub path: PathBuf, - - /// Output format (json, yaml, or text) - #[arg(short, long, default_value = "text")] - pub format: OutputFormat, - - /// Check mode (don't apply changes) - #[arg(short = 'c', long)] - pub check: bool, - - /// Pretty-print JSON output - #[arg(short = 'p', long)] - pub pretty: bool, -} - -impl CompleteCommand { - pub fn execute(&self) -> anyhow::Result<()> { - // Verify path exists - if !self.path.exists() { - anyhow::bail!("Project path does not exist: {}", self.path.display()); - } - - // Run detection first - let detector = StackDetector::new(); - let analysis = detector.detect_all(&self.path)?; - - // Analyze gaps - let required_taskservs: Vec = analysis - .requirements - .iter() - .filter(|r| r.required) - .map(|r| r.taskserv.clone()) - .collect(); - - let gaps = GapAnalyzer::analyze(&analysis, &required_taskservs); - - // Plan completion - let completer = Completer::new(); - let completion_result = completer.complete(&analysis, Vec::new()); - - // Output results - match self.format { - OutputFormat::Json => self.output_json(&gaps, &completion_result)?, - OutputFormat::Yaml => self.output_yaml(&gaps, &completion_result)?, - OutputFormat::Text => self.output_text(&gaps, &completion_result)?, - } - - Ok(()) - } - - fn output_json( - &self, - gaps: &crate::completion::GapAnalysisResult, - result: &crate::completion::CompletionResult, - ) -> anyhow::Result<()> { - let output = json!({ - "completeness": gaps.completeness, - "gaps": gaps.gaps.iter().map(|gap| json!({ - "severity": format!("{:?}", gap.severity), - "message": gap.message, - "suggestion": gap.suggestion, - })).collect::>(), - "error_count": gaps.error_count, - "warning_count": gaps.warning_count, - "changes_needed": result.changes_needed, - "is_safe": result.is_safe, - "version_bump": result.version_bump, - "change_summary": result.change_summary, - }); - - if self.pretty { - println!("{}", serde_json::to_string_pretty(&output)?); - } else { - println!("{}", output); - } - - Ok(()) - } - - fn output_yaml( - &self, - gaps: &crate::completion::GapAnalysisResult, - result: &crate::completion::CompletionResult, - ) -> anyhow::Result<()> { - let json_output = json!({ - "completeness": gaps.completeness, - "gaps": gaps.gaps.iter().map(|gap| json!({ - "severity": format!("{:?}", gap.severity), - "message": gap.message, - "suggestion": gap.suggestion, - })).collect::>(), - "error_count": gaps.error_count, - "warning_count": gaps.warning_count, - "changes_needed": result.changes_needed, - "is_safe": result.is_safe, - "version_bump": result.version_bump, - "change_summary": result.change_summary, - }); - - println!("{}", serde_json::to_string_pretty(&json_output)?); - Ok(()) - } - - fn output_text( - &self, - gaps: &crate::completion::GapAnalysisResult, - result: &crate::completion::CompletionResult, - ) -> anyhow::Result<()> { - println!("\n╔════════════════════════════════════════════════╗"); - println!("║ Infrastructure Declaration Completion ║"); - println!("╚════════════════════════════════════════════════╝\n"); - - println!("Completeness: {:.1}%", gaps.completeness * 100.0); - println!( - "Errors: {} | Warnings: {} | Info: {}", - gaps.error_count, gaps.warning_count, gaps.info_count - ); - println!(); - - if !gaps.gaps.is_empty() { - println!("Identified Gaps:"); - for gap in &gaps.gaps { - let severity_icon = match gap.severity { - crate::completion::Severity::Error => "✗", - crate::completion::Severity::Warning => "⚠", - crate::completion::Severity::Info => "ℹ", - }; - println!(" {} {}", severity_icon, gap.message); - println!(" → {}", gap.suggestion); - } - println!(); - } - - println!("Completion Plan:"); - println!(" Changes Needed: {}", result.changes_needed); - println!( - " Is Safe: {}", - if result.is_safe { "✓ Yes" } else { "✗ No" } - ); - println!(" Version Bump: {}", result.version_bump); - println!(" Summary: {}", result.change_summary); - println!(); - - if !self.check { - println!("Mode: Apply changes"); - } else { - println!("Mode: Check only (no changes applied)"); - } - println!(); - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_output_format_from_str() { - assert!(matches!( - "json".parse::(), - Ok(OutputFormat::Json) - )); - assert!(matches!( - "yaml".parse::(), - Ok(OutputFormat::Yaml) - )); - assert!(matches!( - "text".parse::(), - Ok(OutputFormat::Text) - )); - } - - #[test] - fn test_output_format_display() { - assert_eq!(OutputFormat::Json.to_string(), "json"); - assert_eq!(OutputFormat::Yaml.to_string(), "yaml"); - assert_eq!(OutputFormat::Text.to_string(), "text"); - } - - #[test] - fn test_detect_command_creation() { - let cmd = DetectCommand { - path: PathBuf::from("/tmp"), - format: OutputFormat::Text, - high_confidence_only: false, - pretty: false, - }; - assert_eq!(cmd.path, PathBuf::from("/tmp")); - } -} diff --git a/crates/detector/src/cli/mod.rs b/crates/detector/src/cli/mod.rs deleted file mode 100644 index ac86f2c..0000000 --- a/crates/detector/src/cli/mod.rs +++ /dev/null @@ -1,66 +0,0 @@ -/// Command-line interface for detector functionality -/// -/// Provides two main commands: -/// - `detect`: Analyze a project to detect technologies and infer requirements -/// - `complete`: Complete an infrastructure declaration with inferred -/// requirements -pub mod commands; - -use clap::{Parser, Subcommand}; -pub use commands::{CompleteCommand, DetectCommand}; - -/// Infrastructure-from-Code Detector CLI -/// -/// Detects project technologies, infers infrastructure requirements, -/// and completes incomplete declarations. -#[derive(Parser, Debug)] -#[command(name = "provisioning-detector")] -#[command(about = "Detect and complete infrastructure specifications", long_about = None)] -#[command(version = "0.1.0")] -pub struct Cli { - #[command(subcommand)] - pub command: Commands, -} - -#[derive(Subcommand, Debug)] -pub enum Commands { - /// Detect technologies and infer requirements in a project - Detect(DetectCommand), - - /// Complete an infrastructure declaration with inferred requirements - Complete(CompleteCommand), -} - -impl Cli { - /// Run the CLI - pub fn run() -> anyhow::Result<()> { - let cli = Self::parse(); - cli.execute() - } - - fn execute(&self) -> anyhow::Result<()> { - match &self.command { - Commands::Detect(cmd) => cmd.execute(), - Commands::Complete(cmd) => cmd.execute(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_cli_parse_detect() { - let args = vec!["cli", "detect", "/path/to/project"]; - let cli = Cli::try_parse_from(args); - assert!(cli.is_ok()); - } - - #[test] - fn test_cli_parse_complete() { - let args = vec!["cli", "complete", "/path/to/project"]; - let cli = Cli::try_parse_from(args); - assert!(cli.is_ok()); - } -} diff --git a/crates/detector/src/completion/change_tracker.rs b/crates/detector/src/completion/change_tracker.rs deleted file mode 100644 index a3d1b5a..0000000 --- a/crates/detector/src/completion/change_tracker.rs +++ /dev/null @@ -1,220 +0,0 @@ -/// Change Tracker -/// -/// Tracks all changes made during merge for: -/// - Changelog generation -/// - Rollback capability -/// - Audit trail -/// - Version bumping -use chrono::Utc; - -/// Type of change -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ChangeKind { - AddTaskserv, - RemoveTaskserv, - UpdateVersion, - UpdateProfile, - UpdateField, - PreserveCustomization, -} - -impl ChangeKind { - pub fn is_breaking(&self) -> bool { - matches!(self, ChangeKind::RemoveTaskserv) - } -} - -/// Single tracked change -#[derive(Debug, Clone)] -pub struct Change { - pub kind: ChangeKind, - pub timestamp: String, - pub description: String, - pub details: std::collections::HashMap, - pub breaking: bool, -} - -impl Change { - pub fn new(kind: ChangeKind, description: impl Into) -> Self { - Self { - kind, - timestamp: Utc::now().to_rfc3339(), - description: description.into(), - details: std::collections::HashMap::new(), - breaking: kind.is_breaking(), - } - } - - pub fn with_detail(mut self, key: impl Into, value: impl Into) -> Self { - self.details.insert(key.into(), value.into()); - self - } -} - -/// Change tracker for accumulating all changes -#[derive(Debug, Clone)] -pub struct ChangeTracker { - changes: Vec, - preserved_customizations: Vec, -} - -impl ChangeTracker { - pub fn new() -> Self { - Self { - changes: Vec::new(), - preserved_customizations: Vec::new(), - } - } - - pub fn add_change(&mut self, change: Change) { - self.changes.push(change); - } - - pub fn preserve_customization(&mut self, location: impl Into) { - self.preserved_customizations.push(location.into()); - } - - pub fn get_changes(&self) -> &[Change] { - &self.changes - } - - pub fn get_preserved(&self) -> &[String] { - &self.preserved_customizations - } - - pub fn has_breaking_changes(&self) -> bool { - self.changes.iter().any(|c| c.breaking) - } - - /// Suggest version bump based on changes - pub fn suggest_version_bump(&self) -> VersionBump { - let has_breaking = self.has_breaking_changes(); - let has_new_features = self - .changes - .iter() - .any(|c| matches!(c.kind, ChangeKind::AddTaskserv)); - let has_fixes = self - .changes - .iter() - .any(|c| matches!(c.kind, ChangeKind::UpdateVersion)); - - if has_breaking { - VersionBump::Major - } else if has_new_features { - VersionBump::Minor - } else if has_fixes { - VersionBump::Patch - } else { - VersionBump::None - } - } - - /// Generate changelog text - pub fn generate_changelog_entry(&self, version: &str) -> String { - let mut lines = vec![format!("## Version {}", version)]; - lines.push(format!("Date: {}", Utc::now().to_rfc3339())); - lines.push("".into()); - - if !self.changes.is_empty() { - lines.push("### Changes".into()); - for change in &self.changes { - lines.push(format!("- {}", change.description)); - } - lines.push("".into()); - } - - if !self.preserved_customizations.is_empty() { - lines.push("### Preserved".into()); - for preserved in &self.preserved_customizations { - lines.push(format!("- {}", preserved)); - } - lines.push("".into()); - } - - if self.has_breaking_changes() { - lines.push("### ⚠️ Breaking Changes".into()); - for change in self.changes.iter().filter(|c| c.breaking) { - lines.push(format!("- {}", change.description)); - } - } - - lines.join("\n") - } -} - -impl Default for ChangeTracker { - fn default() -> Self { - Self::new() - } -} - -/// Version bump suggestion -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum VersionBump { - Major, - Minor, - Patch, - None, -} - -impl VersionBump { - pub fn apply(&self, version: &str) -> String { - let parts: Vec<&str> = version.split('.').collect(); - if parts.len() != 3 { - return version.to_string(); - } - - let major = parts[0].parse::().unwrap_or(0); - let minor = parts[1].parse::().unwrap_or(0); - let patch = parts[2].parse::().unwrap_or(0); - - match self { - VersionBump::Major => format!("{}.0.0", major + 1), - VersionBump::Minor => format!("{}.{}.0", major, minor + 1), - VersionBump::Patch => format!("{}.{}.{}", major, minor, patch + 1), - VersionBump::None => version.to_string(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_version_bump_major() { - let bump = VersionBump::Major; - assert_eq!(bump.apply("1.2.3"), "2.0.0"); - } - - #[test] - fn test_version_bump_minor() { - let bump = VersionBump::Minor; - assert_eq!(bump.apply("1.2.3"), "1.3.0"); - } - - #[test] - fn test_version_bump_patch() { - let bump = VersionBump::Patch; - assert_eq!(bump.apply("1.2.3"), "1.2.4"); - } - - #[test] - fn test_breaking_change_suggests_major() { - let mut tracker = ChangeTracker::new(); - tracker.add_change(Change::new( - ChangeKind::RemoveTaskserv, - "Removed deprecated taskserv", - )); - - assert_eq!(tracker.suggest_version_bump(), VersionBump::Major); - } - - #[test] - fn test_add_feature_suggests_minor() { - let mut tracker = ChangeTracker::new(); - tracker.add_change(Change::new(ChangeKind::AddTaskserv, "Added redis taskserv")); - - assert_eq!(tracker.suggest_version_bump(), VersionBump::Minor); - } -} diff --git a/crates/detector/src/completion/completer.rs b/crates/detector/src/completion/completer.rs deleted file mode 100644 index 32c6509..0000000 --- a/crates/detector/src/completion/completer.rs +++ /dev/null @@ -1,147 +0,0 @@ -/// Completer Orchestrator -/// -/// Coordinates gap analysis, merging, and rendering to complete -/// an existing declaration with missing or inferred requirements. -use super::gap_analyzer::{GapAnalysisResult, GapAnalyzer}; -use super::merger::{CurrentTaskserv, IncrementalMerger}; -use crate::models::ProjectAnalysis; - -/// Completion result -#[derive(Debug)] -pub struct CompletionResult { - pub gaps: GapAnalysisResult, - pub changes_needed: usize, - pub is_safe: bool, - pub version_bump: String, - pub change_summary: String, -} - -impl CompletionResult { - pub fn success(&self) -> bool { - !self.gaps.has_errors() - } -} - -/// Main completer orchestrator -pub struct Completer { - min_completeness: f32, // 0.0-1.0 -} - -impl Completer { - pub fn new() -> Self { - Self { - min_completeness: 0.90, // 90% completeness required - } - } - - pub fn with_completeness_threshold(mut self, threshold: f32) -> Self { - self.min_completeness = threshold; - self - } - - /// Complete a declaration based on project analysis - pub fn complete( - &self, - analysis: &ProjectAnalysis, - current_taskservs: Vec, - ) -> CompletionResult { - // Step 1: Analyze gaps - let required_taskservs: Vec = analysis - .requirements - .iter() - .filter(|r| r.required) - .map(|r| r.taskserv.clone()) - .collect(); - - let gaps = GapAnalyzer::analyze(analysis, &required_taskservs); - - // Step 2: Check if completeness is acceptable - if gaps.completeness < self.min_completeness && gaps.has_errors() { - return CompletionResult { - gaps, - changes_needed: 0, - is_safe: false, - version_bump: "None".into(), - change_summary: "Cannot complete: too many critical gaps".into(), - }; - } - - // Step 3: Plan merge - let desired = analysis.requirements.to_vec(); - let merge_result = IncrementalMerger::merge(current_taskservs, desired); - - let is_safe = IncrementalMerger::is_safe_merge(&merge_result); - let version_bump = merge_result.change_tracker.suggest_version_bump(); - let changes_needed = - merge_result.added.len() + merge_result.removed.len() + merge_result.updated.len(); - - // Step 4: Generate summary - let mut summary_lines = Vec::new(); - - if !merge_result.added.is_empty() { - summary_lines.push(format!("+ Adding: {}", merge_result.added.join(", "))); - } - - if !merge_result.removed.is_empty() { - summary_lines.push(format!("- Removing: {}", merge_result.removed.join(", "))); - } - - if !merge_result.updated.is_empty() { - for (name, old, new) in &merge_result.updated { - summary_lines.push(format!("~ Updating {}: {} → {}", name, old, new)); - } - } - - if !merge_result.preserved.is_empty() { - summary_lines.push(format!( - "✓ Preserving {} customization(s)", - merge_result.preserved.len() - )); - } - - let change_summary = if summary_lines.is_empty() { - "No changes needed".into() - } else { - summary_lines.join("\n") - }; - - CompletionResult { - gaps, - changes_needed, - is_safe, - version_bump: format!("{:?}", version_bump), - change_summary, - } - } -} - -impl Default for Completer { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use super::*; - - #[test] - fn test_completer_safe_mode() { - let completer = Completer::new(); - - let mut analysis = ProjectAnalysis::new(PathBuf::from("/test")); - analysis - .requirements - .push(crate::models::Requirement::new("postgres", "DB", 1.0, true)); - - let current = vec![]; - let result = completer.complete(&analysis, current); - - // Should suggest changes and be safe (adding new requirements without removals) - assert!(result.changes_needed > 0); - assert!(result.is_safe); // Adding new taskservs without removals is - // safe - } -} diff --git a/crates/detector/src/completion/gap_analyzer.rs b/crates/detector/src/completion/gap_analyzer.rs deleted file mode 100644 index c49dfd4..0000000 --- a/crates/detector/src/completion/gap_analyzer.rs +++ /dev/null @@ -1,212 +0,0 @@ -/// Gap Analyzer -/// -/// Detects gaps between: -/// 1. Current declaration (what exists) -/// 2. Requirements (what should exist) -/// -/// Gap types: -/// - Missing taskserv (required by inference, not in declaration) -/// - Outdated version (current < recommended) -/// - Missing field (incomplete configuration) -/// - Dependency issue (missing dependency) -use crate::models::ProjectAnalysis; - -/// Single gap found -#[derive(Debug, Clone)] -pub struct Gap { - pub kind: GapKind, - pub severity: Severity, - pub message: String, - pub suggestion: String, -} - -#[derive(Debug, Clone)] -pub enum GapKind { - /// Taskserv is required but missing from declaration - MissingTaskserv { - name: String, - reason: String, - required: bool, - }, - - /// Version is outdated - OutdatedVersion { - taskserv: String, - current: String, - recommended: String, - }, - - /// Configuration field is missing - MissingField { schema: String, field: String }, - - /// Taskserv missing a dependency - MissingDependency { - taskserv: String, - depends_on: String, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum Severity { - Error, // Blocks deployment - Warning, // Should fix - Info, // Optional improvement -} - -/// Gap analysis result -#[derive(Debug, Clone)] -pub struct GapAnalysisResult { - pub gaps: Vec, - pub error_count: usize, - pub warning_count: usize, - pub info_count: usize, - pub completeness: f32, // 0.0 (empty) to 1.0 (complete) -} - -impl GapAnalysisResult { - pub fn has_errors(&self) -> bool { - self.error_count > 0 - } - - pub fn is_complete(&self) -> bool { - self.completeness >= 0.95 - } -} - -/// Analyzes gaps in a project/declaration -pub struct GapAnalyzer; - -impl GapAnalyzer { - /// Analyze gaps given current state and requirements - pub fn analyze(analysis: &ProjectAnalysis, required_taskservs: &[String]) -> GapAnalysisResult { - let mut gaps = Vec::new(); - let mut error_count = 0; - let mut warning_count = 0; - - // Check for missing required taskservs - for taskserv_name in required_taskservs { - if !Self::has_taskserv(analysis, taskserv_name) { - let gap = Gap { - kind: GapKind::MissingTaskserv { - name: taskserv_name.clone(), - reason: "Required by project analysis".into(), - required: true, - }, - severity: Severity::Error, - message: format!("Required taskserv '{}' is missing", taskserv_name), - suggestion: format!("Add {} to requirements", taskserv_name), - }; - error_count += 1; - gaps.push(gap); - } - } - - // Check for recommended but not required taskservs - for requirement in &analysis.requirements { - if !requirement.required - && requirement.confidence > 0.7 - && !Self::has_taskserv(analysis, &requirement.taskserv) - { - let gap = Gap { - kind: GapKind::MissingTaskserv { - name: requirement.taskserv.clone(), - reason: requirement.reason.clone(), - required: false, - }, - severity: Severity::Warning, - message: format!( - "Recommended taskserv '{}' not in declaration", - requirement.taskserv - ), - suggestion: format!( - "Consider adding {} ({})", - requirement.taskserv, requirement.reason - ), - }; - warning_count += 1; - gaps.push(gap); - } - } - - // Check for database without backup - if analysis.has_database() { - let has_backup = analysis - .requirements - .iter() - .any(|r| r.taskserv.contains("backup")); - if !has_backup { - let gap = Gap { - kind: GapKind::MissingField { - schema: "TaskservRequirement".into(), - field: "backup_strategy".into(), - }, - severity: Severity::Warning, - message: "Production database without backup configuration".into(), - suggestion: "Add backup strategy for database taskserv".into(), - }; - warning_count += 1; - gaps.push(gap); - } - } - - // Calculate completeness - let total_possible = - required_taskservs.len() + (if analysis.has_database() { 2 } else { 0 }); - let total_met = total_possible - error_count - warning_count; - let completeness = if total_possible > 0 { - (total_met as f32) / (total_possible as f32) - } else { - 1.0 - }; - - GapAnalysisResult { - gaps, - error_count, - warning_count, - info_count: 0, - completeness: completeness.clamp(0.0, 1.0), - } - } - - fn has_taskserv(analysis: &ProjectAnalysis, name: &str) -> bool { - analysis.requirements.iter().any(|r| r.taskserv == name) - } -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use super::*; - use crate::models::{ProjectAnalysis, Requirement}; - - #[test] - fn test_missing_required_taskserv() { - let mut analysis = ProjectAnalysis::new(PathBuf::from("/test")); - analysis - .requirements - .push(Requirement::new("postgres", "Database detected", 1.0, true)); - - let required = vec!["postgres".to_string(), "redis".to_string()]; - let result = GapAnalyzer::analyze(&analysis, &required); - - assert_eq!(result.error_count, 1); - assert!(result.has_errors()); - } - - #[test] - fn test_optional_taskserv_not_error() { - let mut analysis = ProjectAnalysis::new(PathBuf::from("/test")); - analysis.requirements.push(Requirement::new( - "redis", - "Caching recommendation", - 0.8, - false, - )); - - let required = vec![]; - let result = GapAnalyzer::analyze(&analysis, &required); - - assert_eq!(result.error_count, 0); - } -} diff --git a/crates/detector/src/completion/merger.rs b/crates/detector/src/completion/merger.rs deleted file mode 100644 index 23a53ff..0000000 --- a/crates/detector/src/completion/merger.rs +++ /dev/null @@ -1,209 +0,0 @@ -use std::collections::{HashMap, HashSet}; - -/// Incremental Merger -/// -/// Merges desired state (requirements) with current state (declaration) -/// while preserving user customizations. -/// -/// Algorithm: -/// 1. Detect customizations in current declaration (marked with _custom_ -/// prefix) -/// 2. Compute diff (additions, removals, updates) -/// 3. Apply diff to declaration -/// 4. Restore customizations -/// 5. Return change tracking -use super::change_tracker::{Change, ChangeKind, ChangeTracker}; -use crate::models::Requirement; - -/// Represents a requirement in the current state -#[derive(Debug, Clone, PartialEq)] -pub struct CurrentTaskserv { - pub name: String, - pub version: Option, - pub profile: String, - pub custom_config: Option, // Preserved customization -} - -/// Desired state from inference -#[derive(Debug, Clone)] -pub struct DesiredState { - pub taskservs: Vec, -} - -/// Result of merge -#[derive(Debug, Clone)] -pub struct MergeResult { - pub added: Vec, - pub removed: Vec, - pub updated: Vec<(String, String, String)>, // (name, old_version, new_version) - pub preserved: Vec, - pub change_tracker: ChangeTracker, -} - -/// Incremental merger -pub struct IncrementalMerger; - -impl IncrementalMerger { - /// Merge desired state with current state - pub fn merge(current: Vec, desired: Vec) -> MergeResult { - let mut tracker = ChangeTracker::new(); - let mut added = Vec::new(); - let mut removed = Vec::new(); - let mut updated = Vec::new(); - let mut preserved = Vec::new(); - - // Build lookup tables - let current_map: HashMap = - current.iter().map(|t| (t.name.clone(), t)).collect(); - - let desired_set: HashSet = desired.iter().map(|r| r.taskserv.clone()).collect(); - - // Detect additions - for req in &desired { - if !current_map.contains_key(&req.taskserv) { - added.push(req.taskserv.clone()); - tracker.add_change( - Change::new( - ChangeKind::AddTaskserv, - format!("Added {} taskserv", req.taskserv), - ) - .with_detail("taskserv", &req.taskserv) - .with_detail("version", req.min_version.as_deref().unwrap_or("latest")), - ); - } - } - - // Detect removals (current but not desired) - for current_ts in ¤t { - if !desired_set.contains(¤t_ts.name) { - removed.push(current_ts.name.clone()); - tracker.add_change( - Change::new( - ChangeKind::RemoveTaskserv, - format!("Removed {} taskserv", current_ts.name), - ) - .with_detail("taskserv", ¤t_ts.name), - ); - } - } - - // Detect updates (version/profile changes) - for req in &desired { - let Some(current_ts) = current_map.get(&req.taskserv) else { - continue; - }; - // Check version update - if let (Some(new_version), Some(old_version)) = (&req.min_version, ¤t_ts.version) - { - if old_version != new_version { - updated.push(( - req.taskserv.clone(), - old_version.clone(), - new_version.clone(), - )); - tracker.add_change( - Change::new( - ChangeKind::UpdateVersion, - format!( - "Updated {} from {} to {}", - req.taskserv, old_version, new_version - ), - ) - .with_detail("taskserv", &req.taskserv) - .with_detail("from", old_version) - .with_detail("to", new_version), - ); - } - } - - // Note: Profile tracking could be added to Requirement struct in future - // For now, we track version updates only - - // Preserve customizations - if current_ts.custom_config.is_some() { - preserved.push(format!("{}.custom_config", req.taskserv)); - tracker.preserve_customization(format!("{}.custom_config", req.taskserv)); - } - } - - MergeResult { - added, - removed, - updated, - preserved, - change_tracker: tracker, - } - } - - /// Check if merge would be non-breaking - pub fn is_safe_merge(result: &MergeResult) -> bool { - result.removed.is_empty() && result.change_tracker.suggest_version_bump().is_safe() - } -} - -// Extension trait for VersionBump -trait SafeVersionBump { - fn is_safe(&self) -> bool; -} - -impl SafeVersionBump for super::change_tracker::VersionBump { - fn is_safe(&self) -> bool { - matches!( - self, - super::change_tracker::VersionBump::Minor | super::change_tracker::VersionBump::Patch - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_merge_additions() { - let current = vec![]; - let desired = vec![ - Requirement::new("postgres", "DB", 1.0, true), - Requirement::new("redis", "Cache", 0.8, false), - ]; - - let result = IncrementalMerger::merge(current, desired); - - assert_eq!(result.added.len(), 2); - assert!(result.added.contains(&"postgres".to_string())); - assert!(result.added.contains(&"redis".to_string())); - } - - #[test] - fn test_merge_removals() { - let current = vec![CurrentTaskserv { - name: "old-taskserv".to_string(), - version: Some("1.0.0".to_string()), - profile: "default".to_string(), - custom_config: None, - }]; - let desired = vec![]; - - let result = IncrementalMerger::merge(current, desired); - - assert_eq!(result.removed.len(), 1); - assert!(result.removed.contains(&"old-taskserv".to_string())); - } - - #[test] - fn test_merge_preserves_customizations() { - let current = vec![CurrentTaskserv { - name: "postgres".to_string(), - version: Some("15.0".to_string()), - profile: "default".to_string(), - custom_config: Some("my-custom-setting".to_string()), - }]; - - let desired = vec![Requirement::new("postgres", "DB", 1.0, true)]; - - let result = IncrementalMerger::merge(current, desired); - - assert_eq!(result.preserved.len(), 1); - assert!(result.preserved[0].contains("postgres")); - } -} diff --git a/crates/detector/src/completion/mod.rs b/crates/detector/src/completion/mod.rs deleted file mode 100644 index 7e8452b..0000000 --- a/crates/detector/src/completion/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -pub mod change_tracker; -pub mod completer; -/// Completion Engine -/// -/// Analyzes gaps in existing declarations and applies incremental updates -/// while preserving user customizations. -pub mod gap_analyzer; -pub mod merger; - -pub use change_tracker::{Change, ChangeKind, ChangeTracker, VersionBump}; -pub use completer::{Completer, CompletionResult}; -pub use gap_analyzer::{Gap, GapAnalysisResult, GapAnalyzer, GapKind, Severity}; -pub use merger::IncrementalMerger; diff --git a/crates/detector/src/detectors.rs b/crates/detector/src/detectors.rs deleted file mode 100644 index fffcc48..0000000 --- a/crates/detector/src/detectors.rs +++ /dev/null @@ -1,135 +0,0 @@ -use std::path::Path; - -/// Base trait and implementations for technology detection -/// Follows Nushell single-purpose principle: each detector does one thing -use crate::error::DetectionResult; -use crate::models::Detection; - -/// Core trait for detecting a technology -/// Each implementation is pure and returns Ok(Some(Detection)) or Ok(None) -pub trait Detector: Send + Sync { - /// Name of this detector (e.g., "nodejs", "postgres") - fn name(&self) -> &str; - - /// Attempt to detect technology in given project path - /// Returns: - /// - Ok(Some(detection)) if technology detected - /// - Ok(None) if technology not detected - /// - Err if detection failed (e.g., I/O error) - fn detect(&self, project_path: &Path) -> DetectionResult>; - - /// Detector priority (higher = run first) - /// Used for ordering multi-detector runs - fn priority(&self) -> u8 { - 50 // Default priority - } -} - -pub mod docker; -pub mod nodejs; -pub mod postgres; -pub mod python; -pub mod redis; -pub mod rust; - -pub use docker::DockerDetector; -pub use nodejs::NodeJsDetector; -pub use postgres::PostgresDetector; -pub use python::PythonDetector; -pub use redis::RedisDetector; -pub use rust::RustDetector; - -/// Helper functions for detectors -pub mod helpers { - use std::path::Path; - - use crate::error::{DetectionError, DetectionResult}; - - /// Check if file exists in project - pub fn file_exists(project_path: &Path, filename: &str) -> DetectionResult { - let path = project_path.join(filename); - Ok(path.exists()) - } - - /// Read file content as string - pub fn read_file(project_path: &Path, filename: &str) -> DetectionResult { - let path = project_path.join(filename); - - if !path.exists() { - return Err(DetectionError::file_not_found(&path)); - } - - std::fs::read_to_string(&path).map_err(|_| DetectionError::file_not_found(&path)) - } - - /// Parse JSON file - pub fn read_json( - project_path: &Path, - filename: &str, - ) -> DetectionResult { - let content = read_file(project_path, filename)?; - serde_json::from_str(&content) - .map_err(|e| DetectionError::invalid_json(project_path.join(filename), e.to_string())) - } - - /// Parse TOML file - pub fn read_toml( - project_path: &Path, - filename: &str, - ) -> DetectionResult { - let content = read_file(project_path, filename)?; - toml::from_str(&content) - .map_err(|e| DetectionError::invalid_toml(project_path.join(filename), e.to_string())) - } - - /// Search for pattern in files (grep-like) - pub fn grep_in_project( - project_path: &Path, - pattern: ®ex::Regex, - extensions: &[&str], - ) -> DetectionResult> { - let mut results = Vec::new(); - - for entry in walkdir::WalkDir::new(project_path) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.path().is_file()) - { - let path = entry.path(); - - // Check extension - if !extensions.is_empty() { - let ext = match path.extension() { - Some(e) => e.to_str().unwrap_or(""), - None => continue, - }; - if !extensions.contains(&ext) { - continue; - } - } - - // Read and search - let Ok(content) = std::fs::read_to_string(path) else { - continue; - }; - for (line_num, line) in content.lines().enumerate() { - if pattern.is_match(line) { - results.push(GrepResult { - path: path.to_path_buf(), - line_num: line_num + 1, - content: line.to_string(), - }); - } - } - } - - Ok(results) - } - - #[derive(Debug, Clone)] - pub struct GrepResult { - pub path: std::path::PathBuf, - pub line_num: usize, - pub content: String, - } -} diff --git a/crates/detector/src/detectors/docker.rs b/crates/detector/src/detectors/docker.rs deleted file mode 100644 index 892185f..0000000 --- a/crates/detector/src/detectors/docker.rs +++ /dev/null @@ -1,82 +0,0 @@ -use std::path::Path; - -/// Docker detector -/// Detects: Docker usage via Dockerfile, docker-compose.yml -use super::helpers; -use crate::error::DetectionResult; -use crate::models::{Detection, Evidence, Technology}; - -pub struct DockerDetector; - -impl Default for DockerDetector { - fn default() -> Self { - Self - } -} - -impl DockerDetector { - pub fn new() -> Self { - Self - } -} - -impl super::Detector for DockerDetector { - fn name(&self) -> &str { - "docker" - } - - fn detect(&self, project_path: &Path) -> DetectionResult> { - let mut confidence = 0.0; - let mut evidence = Vec::new(); - - // Check for Dockerfile - if helpers::file_exists(project_path, "Dockerfile")? { - evidence.push(Evidence::FileExists { - path: project_path.join("Dockerfile"), - reason: "Dockerfile is primary Docker indicator".into(), - }); - confidence += 0.5; - } - - // Check for .dockerignore - if helpers::file_exists(project_path, ".dockerignore")? { - evidence.push(Evidence::FileExists { - path: project_path.join(".dockerignore"), - reason: ".dockerignore suggests Docker setup".into(), - }); - confidence += 0.3; - } - - // Check for docker-compose.yml/yaml - if helpers::file_exists(project_path, "docker-compose.yml")? { - evidence.push(Evidence::FileExists { - path: project_path.join("docker-compose.yml"), - reason: "docker-compose.yml indicates Docker Compose".into(), - }); - confidence += 0.4; - } - - if helpers::file_exists(project_path, "docker-compose.yaml")? { - evidence.push(Evidence::FileExists { - path: project_path.join("docker-compose.yaml"), - reason: "docker-compose.yaml indicates Docker Compose".into(), - }); - confidence += 0.4; - } - - if confidence > 0.0 { - let mut detection = Detection::new(Technology::Docker); - for ev in evidence { - detection = detection.add_evidence(ev); - } - detection.calculate_confidence(); - Ok(Some(detection)) - } else { - Ok(None) - } - } - - fn priority(&self) -> u8 { - 60 // Higher priority - Dockerfile is very specific - } -} diff --git a/crates/detector/src/detectors/nodejs.rs b/crates/detector/src/detectors/nodejs.rs deleted file mode 100644 index 5a58c56..0000000 --- a/crates/detector/src/detectors/nodejs.rs +++ /dev/null @@ -1,185 +0,0 @@ -use std::path::Path; - -use serde::{Deserialize, Serialize}; - -/// Node.js project detector -/// Detects: Node.js, npm/yarn/pnpm package managers, Express framework -use super::helpers; -use crate::error::DetectionResult; -use crate::models::{Detection, Evidence, Technology}; - -/// Package.json structure (minimal for detection) -#[derive(Debug, Deserialize, Serialize)] -pub struct PackageJson { - pub name: Option, - pub version: Option, - pub engines: Option, - pub dependencies: Option>, - #[serde(rename = "devDependencies")] - pub dev_dependencies: Option>, -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct Engines { - pub node: Option, - pub npm: Option, -} - -pub struct NodeJsDetector; - -impl Default for NodeJsDetector { - fn default() -> Self { - Self - } -} - -impl NodeJsDetector { - pub fn new() -> Self { - Self - } - - /// Detect Node.js by checking package.json - fn detect_package_json(&self, project_path: &Path) -> DetectionResult> { - if !helpers::file_exists(project_path, "package.json")? { - return Ok(None); - } - - let package_json: PackageJson = helpers::read_json(project_path, "package.json")?; - - let mut detection = Detection::new(Technology::NodeJs); - - // Add evidence: file exists - detection = detection.add_evidence(Evidence::FileExists { - path: project_path.join("package.json"), - reason: "package.json is primary Node.js indicator".into(), - }); - - // Extract Node.js version from engines field - if let Some(engines) = &package_json.engines { - if let Some(version) = &engines.node { - detection = detection.with_version(version.clone()); - detection = detection.add_evidence(Evidence::Version { - version: version.clone(), - source: "package.json engines.node".into(), - }); - } - } - - // Detect package manager (npm, yarn, pnpm) - detection = self.detect_package_manager(project_path, detection)?; - - // Detect framework (Express, etc.) - if let Some(deps) = &package_json.dependencies { - if deps.contains_key("express") { - detection = detection.add_evidence(Evidence::Dependency { - name: "express".into(), - version: deps.get("express").cloned(), - reason: "Express is popular web framework".into(), - }); - } - } - - detection.calculate_confidence(); - Ok(Some(detection)) - } - - fn detect_package_manager( - &self, - project_path: &Path, - mut detection: Detection, - ) -> DetectionResult { - // Check for yarn - if helpers::file_exists(project_path, "yarn.lock")? { - detection = detection.add_evidence(Evidence::FileExists { - path: project_path.join("yarn.lock"), - reason: "yarn.lock indicates yarn package manager".into(), - }); - } - - // Check for pnpm - if helpers::file_exists(project_path, "pnpm-lock.yaml")? { - detection = detection.add_evidence(Evidence::FileExists { - path: project_path.join("pnpm-lock.yaml"), - reason: "pnpm-lock.yaml indicates pnpm package manager".into(), - }); - } - - // Check for .nvmrc (Node version manager) - if helpers::file_exists(project_path, ".nvmrc")? { - if let Ok(content) = helpers::read_file(project_path, ".nvmrc") { - let version = content.trim().to_string(); - detection = detection.add_evidence(Evidence::Version { - version, - source: ".nvmrc".into(), - }); - } - } - - Ok(detection) - } -} - -impl super::Detector for NodeJsDetector { - fn name(&self) -> &str { - "nodejs" - } - - fn detect(&self, project_path: &Path) -> DetectionResult> { - self.detect_package_json(project_path) - } - - fn priority(&self) -> u8 { - 80 // High priority - package.json is very reliable - } -} - -#[cfg(test)] -mod tests { - use tempfile::TempDir; - - use super::*; - use crate::detectors::Detector; - - #[test] - fn test_detect_nodejs_with_package_json() -> anyhow::Result<()> { - let temp_dir = TempDir::new()?; - let package_json_path = temp_dir.path().join("package.json"); - - std::fs::write( - &package_json_path, - r#"{ - "name": "test-app", - "version": "1.0.0", - "engines": { - "node": "20.0.0" - }, - "dependencies": { - "express": "^4.18.0" - } - }"#, - )?; - - let detector = NodeJsDetector::new(); - let result = detector.detect(temp_dir.path())?; - - assert!(result.is_some()); - let detection = result.unwrap(); - assert_eq!(detection.technology, Technology::NodeJs); - assert_eq!(detection.version, Some("20.0.0".into())); - assert!(detection.confidence > 0.0); - - Ok(()) - } - - #[test] - fn test_detect_nodejs_missing_package_json() -> anyhow::Result<()> { - let temp_dir = TempDir::new()?; - - let detector = NodeJsDetector::new(); - let result = detector.detect(temp_dir.path())?; - - assert!(result.is_none()); - - Ok(()) - } -} diff --git a/crates/detector/src/detectors/postgres.rs b/crates/detector/src/detectors/postgres.rs deleted file mode 100644 index f3b09b3..0000000 --- a/crates/detector/src/detectors/postgres.rs +++ /dev/null @@ -1,119 +0,0 @@ -use std::path::Path; - -/// PostgreSQL detector -/// Detects: PostgreSQL usage via dependencies, migrations, connection strings -use super::helpers; -use crate::error::DetectionResult; -use crate::models::{Detection, Evidence, Technology}; - -pub struct PostgresDetector; - -impl Default for PostgresDetector { - fn default() -> Self { - Self - } -} - -impl PostgresDetector { - pub fn new() -> Self { - Self - } - - fn detect(&self, project_path: &Path) -> DetectionResult> { - let mut confidence = 0.0; - let mut evidence = Vec::new(); - - // Check for database/migrations directory - if helpers::file_exists(project_path, "migrations")? { - evidence.push(Evidence::FileExists { - path: project_path.join("migrations"), - reason: "Database migrations directory suggests SQL database".into(), - }); - confidence += 0.3; - } - - if helpers::file_exists(project_path, "database")? { - evidence.push(Evidence::FileExists { - path: project_path.join("database"), - reason: "Database directory".into(), - }); - confidence += 0.2; - } - - // Check for postgres dependency in various package managers - if let Ok(content) = helpers::read_file(project_path, "package.json") { - if content.contains("\"pg\"") || content.contains("\"postgres\"") { - evidence.push(Evidence::Dependency { - name: "pg".into(), - version: None, - reason: "PostgreSQL client for Node.js".into(), - }); - confidence += 0.4; - } - } - - if let Ok(content) = helpers::read_file(project_path, "requirements.txt") { - if content.contains("psycopg") || content.contains("sqlalchemy") { - evidence.push(Evidence::Dependency { - name: "psycopg2".into(), - version: None, - reason: "PostgreSQL adapter for Python".into(), - }); - confidence += 0.4; - } - } - - if let Ok(content) = helpers::read_file(project_path, "Cargo.toml") { - if content.contains("tokio-postgres") || content.contains("sqlx") { - evidence.push(Evidence::Dependency { - name: "tokio-postgres".into(), - version: None, - reason: "PostgreSQL driver for Rust".into(), - }); - confidence += 0.5; - } - } - - // Search for connection strings in code - if let Ok(pattern) = regex::Regex::new(r"postgres://|postgresql://") { - if let Ok(results) = - helpers::grep_in_project(project_path, &pattern, &["js", "ts", "py", "rs"]) - { - if !results.is_empty() { - evidence.push(Evidence::FileContent { - path: results[0].path.clone(), - pattern: "postgres://".into(), - line: results[0].line_num, - reason: "PostgreSQL connection string found".into(), - }); - confidence += 0.3; - } - } - } - - if confidence > 0.0 { - let mut detection = Detection::new(Technology::Postgres); - for ev in evidence { - detection = detection.add_evidence(ev); - } - detection.calculate_confidence(); - Ok(Some(detection)) - } else { - Ok(None) - } - } -} - -impl super::Detector for PostgresDetector { - fn name(&self) -> &str { - "postgres" - } - - fn detect(&self, project_path: &Path) -> DetectionResult> { - self.detect(project_path) - } - - fn priority(&self) -> u8 { - 50 // Medium priority - } -} diff --git a/crates/detector/src/detectors/python.rs b/crates/detector/src/detectors/python.rs deleted file mode 100644 index 9bc9bbe..0000000 --- a/crates/detector/src/detectors/python.rs +++ /dev/null @@ -1,176 +0,0 @@ -use std::path::Path; - -/// Python project detector -/// Detects: Python, pip/poetry/pipenv/uv, frameworks (Django, FastAPI, etc.) -use super::helpers; -use crate::error::DetectionResult; -use crate::models::{Detection, Evidence, Technology}; - -pub struct PythonDetector; - -impl Default for PythonDetector { - fn default() -> Self { - Self - } -} - -impl PythonDetector { - pub fn new() -> Self { - Self - } - - fn detect_requirements(&self, project_path: &Path) -> DetectionResult> { - // Check for requirements.txt, setup.py, pyproject.toml, Pipfile, poetry.lock, - // etc. - - let mut detection = None; - - // Primary indicators (high confidence) - if helpers::file_exists(project_path, "requirements.txt")? { - let mut det = Detection::new(Technology::Python); - det = det.add_evidence(Evidence::FileExists { - path: project_path.join("requirements.txt"), - reason: "requirements.txt is pip dependency file".into(), - }); - detection = Some(det); - } - - if helpers::file_exists(project_path, "pyproject.toml")? { - if detection.is_none() { - let mut det = Detection::new(Technology::Python); - det = det.add_evidence(Evidence::FileExists { - path: project_path.join("pyproject.toml"), - reason: "pyproject.toml is Python project config".into(), - }); - detection = Some(det); - } else if let Some(ref mut det) = detection { - *det = det.clone().add_evidence(Evidence::FileExists { - path: project_path.join("pyproject.toml"), - reason: "pyproject.toml is Python project config".into(), - }); - } - } - - if helpers::file_exists(project_path, "setup.py")? { - if detection.is_none() { - let mut det = Detection::new(Technology::Python); - det = det.add_evidence(Evidence::FileExists { - path: project_path.join("setup.py"), - reason: "setup.py is Python packaging file".into(), - }); - detection = Some(det); - } else if let Some(ref mut det) = detection { - *det = det.clone().add_evidence(Evidence::FileExists { - path: project_path.join("setup.py"), - reason: "setup.py is Python packaging file".into(), - }); - } - } - - // Package manager files - if helpers::file_exists(project_path, "Pipfile")? { - if let Some(ref mut det) = detection { - *det = det.clone().add_evidence(Evidence::FileExists { - path: project_path.join("Pipfile"), - reason: "Pipfile indicates pipenv".into(), - }); - } - } - - if helpers::file_exists(project_path, "poetry.lock")? { - if let Some(ref mut det) = detection { - *det = det.clone().add_evidence(Evidence::FileExists { - path: project_path.join("poetry.lock"), - reason: "poetry.lock indicates poetry".into(), - }); - } - } - - if helpers::file_exists(project_path, "uv.lock")? { - if let Some(ref mut det) = detection { - *det = det.clone().add_evidence(Evidence::FileExists { - path: project_path.join("uv.lock"), - reason: "uv.lock indicates uv package manager".into(), - }); - } - } - - // Framework detection - if let Some(ref mut det) = detection { - self.detect_frameworks(project_path, det)?; - det.calculate_confidence(); - } - - Ok(detection) - } - - fn detect_frameworks( - &self, - project_path: &Path, - detection: &mut Detection, - ) -> DetectionResult<()> { - let frameworks = [ - ("django", "Web framework"), - ("fastapi", "Async web framework"), - ("flask", "Lightweight web framework"), - ("requests", "HTTP library"), - ("sqlalchemy", "ORM"), - ]; - - // Check in requirements.txt - if let Ok(content) = helpers::read_file(project_path, "requirements.txt") { - for (name, description) in &frameworks { - if content.contains(name) { - *detection = detection.clone().add_evidence(Evidence::Dependency { - name: name.to_string(), - version: None, - reason: description.to_string(), - }); - } - } - } - - Ok(()) - } -} - -impl super::Detector for PythonDetector { - fn name(&self) -> &str { - "python" - } - - fn detect(&self, project_path: &Path) -> DetectionResult> { - self.detect_requirements(project_path) - } - - fn priority(&self) -> u8 { - 80 // High priority - } -} - -#[cfg(test)] -mod tests { - use tempfile::TempDir; - - use super::*; - use crate::detectors::Detector; - - #[test] - fn test_detect_python_with_requirements() -> anyhow::Result<()> { - let temp_dir = TempDir::new()?; - std::fs::write( - temp_dir.path().join("requirements.txt"), - "flask==2.0.0\nrequests==2.26.0\n", - )?; - - let detector = PythonDetector::new(); - let result = detector.detect(temp_dir.path())?; - - assert!(result.is_some()); - let detection = result.unwrap(); - assert_eq!(detection.technology, Technology::Python); - assert!(detection.confidence > 0.0); - - Ok(()) - } -} diff --git a/crates/detector/src/detectors/redis.rs b/crates/detector/src/detectors/redis.rs deleted file mode 100644 index 19db81e..0000000 --- a/crates/detector/src/detectors/redis.rs +++ /dev/null @@ -1,100 +0,0 @@ -use std::path::Path; - -/// Redis detector -/// Detects: Redis usage via dependencies, connection strings -use super::helpers; -use crate::error::DetectionResult; -use crate::models::{Detection, Evidence, Technology}; - -pub struct RedisDetector; - -impl Default for RedisDetector { - fn default() -> Self { - Self - } -} - -impl RedisDetector { - pub fn new() -> Self { - Self - } -} - -impl super::Detector for RedisDetector { - fn name(&self) -> &str { - "redis" - } - - fn detect(&self, project_path: &Path) -> DetectionResult> { - let mut confidence = 0.0; - let mut evidence = Vec::new(); - - // Check for redis dependency in package.json - if let Ok(content) = helpers::read_file(project_path, "package.json") { - if content.contains("\"redis\"") { - evidence.push(Evidence::Dependency { - name: "redis".into(), - version: None, - reason: "Redis client for Node.js".into(), - }); - confidence += 0.4; - } - } - - // Check for Redis in requirements.txt - if let Ok(content) = helpers::read_file(project_path, "requirements.txt") { - if content.contains("redis") { - evidence.push(Evidence::Dependency { - name: "redis".into(), - version: None, - reason: "Redis client for Python".into(), - }); - confidence += 0.4; - } - } - - // Check for redis-rs in Cargo.toml - if let Ok(content) = helpers::read_file(project_path, "Cargo.toml") { - if content.contains("redis") { - evidence.push(Evidence::Dependency { - name: "redis".into(), - version: None, - reason: "Redis client for Rust".into(), - }); - confidence += 0.4; - } - } - - // Search for connection strings - if let Ok(pattern) = regex::Regex::new(r"redis://|localhost:6379") { - if let Ok(results) = - helpers::grep_in_project(project_path, &pattern, &["js", "ts", "py", "rs"]) - { - if !results.is_empty() { - evidence.push(Evidence::FileContent { - path: results[0].path.clone(), - pattern: "redis://".into(), - line: results[0].line_num, - reason: "Redis connection string found".into(), - }); - confidence += 0.3; - } - } - } - - if confidence > 0.0 { - let mut detection = Detection::new(Technology::Redis); - for ev in evidence { - detection = detection.add_evidence(ev); - } - detection.calculate_confidence(); - Ok(Some(detection)) - } else { - Ok(None) - } - } - - fn priority(&self) -> u8 { - 50 // Medium priority - } -} diff --git a/crates/detector/src/detectors/rust.rs b/crates/detector/src/detectors/rust.rs deleted file mode 100644 index f211817..0000000 --- a/crates/detector/src/detectors/rust.rs +++ /dev/null @@ -1,170 +0,0 @@ -use std::path::Path; - -use serde::Deserialize; - -/// Rust project detector -/// Detects: Rust, Cargo, frameworks (Axum, Tokio, etc.) -use super::helpers; -use crate::error::DetectionResult; -use crate::models::{Detection, Evidence, Technology}; - -#[derive(Debug, Deserialize)] -pub struct CargoToml { - pub package: Option, - pub dependencies: Option>, - #[serde(rename = "dev-dependencies")] - pub dev_dependencies: Option>, -} - -#[derive(Debug, Deserialize)] -pub struct CargoPackage { - pub name: Option, - pub version: Option, - pub edition: Option, -} - -pub struct RustDetector; - -impl Default for RustDetector { - fn default() -> Self { - Self - } -} - -impl RustDetector { - pub fn new() -> Self { - Self - } - - fn detect_cargo_toml(&self, project_path: &Path) -> DetectionResult> { - if !helpers::file_exists(project_path, "Cargo.toml")? { - return Ok(None); - } - - let cargo_toml: CargoToml = helpers::read_toml(project_path, "Cargo.toml")?; - - let mut detection = Detection::new(Technology::Rust); - - // Add evidence: Cargo.toml exists - detection = detection.add_evidence(Evidence::FileExists { - path: project_path.join("Cargo.toml"), - reason: "Cargo.toml is primary Rust indicator".into(), - }); - - // Extract version from package - if let Some(pkg) = &cargo_toml.package { - if let Some(version) = &pkg.version { - detection = detection.with_version(version.clone()); - } - } - - // Check for framework dependencies - self.detect_frameworks(&cargo_toml, &mut detection); - - // Check for src/main.rs or src/lib.rs - self.check_source_files(project_path, &mut detection)?; - - detection.calculate_confidence(); - Ok(Some(detection)) - } - - fn detect_frameworks(&self, cargo_toml: &CargoToml, detection: &mut Detection) { - let frameworks = [ - ("tokio", "Async runtime"), - ("axum", "Web framework"), - ("actix-web", "Web framework"), - ("rocket", "Web framework"), - ("serde", "Serialization"), - ("clap", "CLI argument parser"), - ]; - - if let Some(deps) = &cargo_toml.dependencies { - for (name, description) in &frameworks { - if deps.contains_key(*name) { - *detection = detection.clone().add_evidence(Evidence::Dependency { - name: name.to_string(), - version: deps.get(*name).and_then(|v| v.as_str()).map(String::from), - reason: description.to_string(), - }); - } - } - } - } - - fn check_source_files( - &self, - project_path: &Path, - detection: &mut Detection, - ) -> DetectionResult<()> { - if helpers::file_exists(project_path, "src/main.rs")? { - *detection = detection.clone().add_evidence(Evidence::FileExists { - path: project_path.join("src/main.rs"), - reason: "Rust binary project".into(), - }); - } - - if helpers::file_exists(project_path, "src/lib.rs")? { - *detection = detection.clone().add_evidence(Evidence::FileExists { - path: project_path.join("src/lib.rs"), - reason: "Rust library project".into(), - }); - } - - Ok(()) - } -} - -impl super::Detector for RustDetector { - fn name(&self) -> &str { - "rust" - } - - fn detect(&self, project_path: &Path) -> DetectionResult> { - self.detect_cargo_toml(project_path) - } - - fn priority(&self) -> u8 { - 85 // High priority - Cargo.toml is very reliable - } -} - -#[cfg(test)] -mod tests { - use tempfile::TempDir; - - use super::*; - use crate::detectors::Detector; - - #[test] - fn test_detect_rust_with_cargo_toml() -> anyhow::Result<()> { - let temp_dir = TempDir::new()?; - let cargo_path = temp_dir.path().join("Cargo.toml"); - - std::fs::write( - &cargo_path, - r#"[package] -name = "my-app" -version = "0.1.0" -edition = "2021" - -[dependencies] -tokio = { version = "1.0", features = ["full"] } -axum = "0.7" -"#, - )?; - - // Create src/main.rs - std::fs::create_dir(temp_dir.path().join("src"))?; - std::fs::write(temp_dir.path().join("src/main.rs"), "fn main() {}")?; - - let detector = RustDetector::new(); - let result = detector.detect(temp_dir.path())?; - - assert!(result.is_some()); - let detection = result.unwrap(); - assert_eq!(detection.technology, Technology::Rust); - assert!(detection.confidence > 0.0); - - Ok(()) - } -} diff --git a/crates/detector/src/error.rs b/crates/detector/src/error.rs deleted file mode 100644 index dbc5e0a..0000000 --- a/crates/detector/src/error.rs +++ /dev/null @@ -1,177 +0,0 @@ -/// Error handling following M-ERRORS-CANONICAL-STRUCTS pattern -/// Each detector has specific errors with context and backtrace -use std::error::Error; -use std::fmt; -use std::path::PathBuf; - -/// Detection errors - specific struct, not generic enum -#[derive(Debug)] -pub struct DetectionError { - kind: DetectionErrorKind, - context: String, - source: Option>, -} - -#[derive(Debug)] -pub enum DetectionErrorKind { - FileNotFound { path: PathBuf }, - InvalidJson { path: PathBuf, reason: String }, - InvalidToml { path: PathBuf, reason: String }, - ReadError { path: PathBuf }, - IoError, - InvalidVersion { version: String }, - RegexError { pattern: String }, -} - -impl DetectionError { - pub fn file_not_found(path: impl Into) -> Self { - Self { - kind: DetectionErrorKind::FileNotFound { path: path.into() }, - context: "Failed to find required file".into(), - source: None, - } - } - - pub fn invalid_json(path: impl Into, reason: impl Into) -> Self { - Self { - kind: DetectionErrorKind::InvalidJson { - path: path.into(), - reason: reason.into(), - }, - context: "Invalid JSON in configuration file".into(), - source: None, - } - } - - pub fn invalid_toml(path: impl Into, reason: impl Into) -> Self { - Self { - kind: DetectionErrorKind::InvalidToml { - path: path.into(), - reason: reason.into(), - }, - context: "Invalid TOML in configuration file".into(), - source: None, - } - } - - pub fn with_source(mut self, source: Box) -> Self { - self.source = Some(source); - self - } - - pub fn with_context(mut self, context: impl Into) -> Self { - self.context = context.into(); - self - } - - pub fn is_file_not_found(&self) -> bool { - matches!(self.kind, DetectionErrorKind::FileNotFound { .. }) - } - - pub fn is_invalid_json(&self) -> bool { - matches!(self.kind, DetectionErrorKind::InvalidJson { .. }) - } - - pub fn context(&self) -> &str { - &self.context - } -} - -impl fmt::Display for DetectionError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match &self.kind { - DetectionErrorKind::FileNotFound { path } => { - write!(f, "File not found: {}", path.display()) - } - DetectionErrorKind::InvalidJson { path, reason } => { - write!(f, "Invalid JSON in {}: {}", path.display(), reason) - } - DetectionErrorKind::InvalidToml { path, reason } => { - write!(f, "Invalid TOML in {}: {}", path.display(), reason) - } - DetectionErrorKind::ReadError { path } => { - write!(f, "Failed to read file: {}", path.display()) - } - DetectionErrorKind::IoError => { - write!(f, "I/O error during detection") - } - DetectionErrorKind::InvalidVersion { version } => { - write!(f, "Invalid version format: {}", version) - } - DetectionErrorKind::RegexError { pattern } => { - write!(f, "Invalid regex pattern: {}", pattern) - } - } - } -} - -impl Error for DetectionError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - self.source.as_ref().map(|e| e.as_ref() as &dyn Error) - } -} - -/// InferenceError - for composition rules and requirement analysis -#[derive(Debug)] -pub struct InferenceError { - kind: InferenceErrorKind, - context: String, - source: Option>, -} - -#[derive(Debug)] -pub enum InferenceErrorKind { - InvalidRule { name: String }, - ConflictingRequirements { taskservs: Vec }, - MissingDependency { taskserv: String }, -} - -impl InferenceError { - pub fn conflicting_requirements(taskservs: Vec) -> Self { - Self { - kind: InferenceErrorKind::ConflictingRequirements { - taskservs: taskservs.clone(), - }, - context: format!( - "Taskservs {} have conflicting requirements", - taskservs.join(", ") - ), - source: None, - } - } - - pub fn is_conflict(&self) -> bool { - matches!( - self.kind, - InferenceErrorKind::ConflictingRequirements { .. } - ) - } -} - -impl fmt::Display for InferenceError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match &self.kind { - InferenceErrorKind::InvalidRule { name } => { - write!(f, "Invalid inference rule: {}", name) - } - InferenceErrorKind::ConflictingRequirements { taskservs } => { - write!(f, "Conflicting requirements: {}", taskservs.join(", ")) - } - InferenceErrorKind::MissingDependency { taskserv } => { - write!(f, "Missing dependency for {}", taskserv) - } - } - } -} - -impl Error for InferenceError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - self.source.as_ref().map(|e| e.as_ref() as &dyn Error) - } -} - -/// Type alias for Results with DetectionError -pub type DetectionResult = Result; - -/// Type alias for Results with InferenceError -pub type InferenceResult = Result; diff --git a/crates/detector/src/inference.rs b/crates/detector/src/inference.rs deleted file mode 100644 index 067c67e..0000000 --- a/crates/detector/src/inference.rs +++ /dev/null @@ -1,205 +0,0 @@ -/// Inference engine -/// Converts technology detections into infrastructure requirements -/// Uses rule-based system to suggest taskservs based on what was detected -use crate::models::{Detection, Requirement, Technology}; - -/// Inference rule: detect pattern → infer requirement -pub struct InferenceRule { - pub name: &'static str, - pub condition: fn(&[Detection]) -> bool, - pub infer: fn(&[Detection]) -> Vec, -} - -/// Main inference engine -pub struct InferenceEngine { - rules: Vec, -} - -impl InferenceEngine { - pub fn new(rules: Vec) -> Self { - Self { rules } - } - - /// Create engine with default rules - pub fn with_default_rules() -> Self { - let rules = vec![ - // Rule 1: Node.js + Express → Redis (API caching) - InferenceRule { - name: "NodeJS API recommends Redis", - condition: |detections| { - detections - .iter() - .any(|d| d.technology == Technology::NodeJs) - && detections - .iter() - .any(|d| d.technology == Technology::Express) - }, - infer: |_| { - vec![Requirement::new( - "redis", - "Express.js APIs benefit from caching layer", - 0.85, - false, - )] - }, - }, - // Rule 2: Any database detected → needs backups - InferenceRule { - name: "Databases need backup strategy", - condition: |detections| { - detections.iter().any(|d| { - matches!( - d.technology, - Technology::Postgres | Technology::Mysql | Technology::Mongodb - ) - }) - }, - infer: |detections| { - detections - .iter() - .filter(|d| { - matches!( - d.technology, - Technology::Postgres | Technology::Mysql | Technology::Mongodb - ) - }) - .map(|d| { - let taskserv = match d.technology { - Technology::Postgres => "postgres-backup", - Technology::Mysql => "mysql-backup", - Technology::Mongodb => "mongodb-backup", - _ => "backup", - }; - Requirement::new( - taskserv, - "Production databases require backup strategy", - 0.90, - false, - ) - }) - .collect() - }, - }, - // Rule 3: Containerized apps need reverse proxy - InferenceRule { - name: "Docker apps need reverse proxy", - condition: |detections| { - detections - .iter() - .any(|d| d.technology == Technology::Docker) - }, - infer: |_| { - vec![Requirement::new( - "nginx", - "Containerized applications should run behind reverse proxy", - 0.75, - false, - )] - }, - }, - // Rule 4: Any language detected → needs runtime - InferenceRule { - name: "Languages need runtime", - condition: |detections| { - detections.iter().any(|d| { - matches!( - d.technology, - Technology::NodeJs | Technology::Python | Technology::Rust - ) - }) - }, - infer: |detections| { - let lang = detections.iter().find(|d| { - matches!( - d.technology, - Technology::NodeJs | Technology::Python | Technology::Rust - ) - }); - - match lang.map(|d| d.technology) { - Some(Technology::NodeJs) => vec![], // Already detected - Some(Technology::Python) => vec![], // Already detected - Some(Technology::Rust) => vec![], // Already detected - _ => vec![], - } - }, - }, - // Rule 5: PostgreSQL detected → add monitoring - InferenceRule { - name: "PostgreSQL should have monitoring", - condition: |detections| { - detections - .iter() - .any(|d| d.technology == Technology::Postgres) - }, - infer: |_| { - vec![Requirement::new( - "pg-monitoring", - "Monitor PostgreSQL performance in production", - 0.70, - false, - ) - .with_min_version("14.0".to_string())] - }, - }, - ]; - - Self::new(rules) - } - - /// Infer requirements from detections - pub fn infer_requirements(&self, detections: &[Detection]) -> Vec { - let mut requirements = Vec::new(); - - for rule in &self.rules { - if (rule.condition)(detections) { - let inferred = (rule.infer)(detections); - requirements.extend(inferred); - } - } - - // Deduplicate and keep highest confidence - requirements.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap()); - requirements.dedup_by(|a, b| a.taskserv == b.taskserv); - - requirements - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_nodejs_express_recommends_redis() { - let detections = vec![ - Detection::new(Technology::NodeJs), - Detection::new(Technology::Express), - ]; - - let engine = InferenceEngine::with_default_rules(); - let requirements = engine.infer_requirements(&detections); - - assert!(requirements.iter().any(|r| r.taskserv == "redis")); - } - - #[test] - fn test_docker_recommends_nginx() { - let detections = vec![Detection::new(Technology::Docker)]; - - let engine = InferenceEngine::with_default_rules(); - let requirements = engine.infer_requirements(&detections); - - assert!(requirements.iter().any(|r| r.taskserv == "nginx")); - } - - #[test] - fn test_postgres_detected() { - let detections = vec![Detection::new(Technology::Postgres)]; - - let engine = InferenceEngine::with_default_rules(); - let requirements = engine.infer_requirements(&detections); - - assert!(!requirements.is_empty()); - } -} diff --git a/crates/detector/src/lib.rs b/crates/detector/src/lib.rs deleted file mode 100644 index d5450e8..0000000 --- a/crates/detector/src/lib.rs +++ /dev/null @@ -1,144 +0,0 @@ -#![allow( - dead_code, - unused_imports, - unused_variables, - unused_assignments, - unused -)] - -pub mod cli; -pub mod completion; -pub mod detectors; -/// Provisioning project detector -/// Analyzes projects to detect technologies and infer infrastructure -/// requirements -/// -/// # Architecture -/// -/// ## Error Handling -/// Follows M-ERRORS-CANONICAL-STRUCTS pattern with situation-specific error -/// types -/// -/// ## Detector Pattern -/// Each detector implements the `Detector` trait and detects one technology -/// Following the single-purpose principle from Nushell guidelines -/// -/// ## Example -/// ```ignore -/// use provisioning_detector::{StackDetector, ProjectAnalysis}; -/// use std::path::Path; -/// -/// let analysis = StackDetector::new() -/// .detect_all(Path::new("/path/to/project")) -/// .expect("Failed to analyze project"); -/// -/// println!("Detected technologies: {:?}", analysis.detections); -/// println!("Inferred requirements: {:?}", analysis.requirements); -/// ``` -pub mod error; -pub mod inference; -pub mod models; -pub mod questionnaire; - -pub use cli::{Cli, Commands}; -pub use completion::{ChangeTracker, Completer, GapAnalyzer, IncrementalMerger}; -pub use detectors::Detector; -pub use error::{DetectionError, DetectionResult, InferenceError, InferenceResult}; -pub use inference::InferenceEngine; -pub use models::{Detection, Evidence, ProjectAnalysis, Requirement, Technology}; -pub use questionnaire::{ - Answer, DecisionNode, DecisionTree, Expression, InteractiveTUI, Question, QuestionKind, - QuestionnaireEngine, QuestionnaireResponse, ValidationRule, -}; - -/// Main orchestrator for detecting all technologies in a project -pub struct StackDetector { - detectors: Vec>, -} - -impl StackDetector { - /// Create new StackDetector with default detectors - pub fn new() -> Self { - Self::with_default_detectors() - } - - /// Create with default set of detectors - pub fn with_default_detectors() -> Self { - let detectors: Vec> = vec![ - Box::new(detectors::NodeJsDetector::new()), - Box::new(detectors::RustDetector::new()), - Box::new(detectors::PythonDetector::new()), - Box::new(detectors::DockerDetector::new()), - Box::new(detectors::PostgresDetector::new()), - Box::new(detectors::RedisDetector::new()), - ]; - - Self { detectors } - } - - /// Run all detectors and return combined analysis - pub fn detect_all(&self, project_path: &std::path::Path) -> DetectionResult { - let mut analysis = ProjectAnalysis::new(project_path.to_path_buf()); - - // Sort detectors by priority (higher first) - let mut sorted_detectors = self.detectors.iter().collect::>(); - sorted_detectors.sort_by_key(|d| std::cmp::Reverse(d.priority())); - - // Run each detector - for detector in sorted_detectors { - if let Some(detection) = detector.detect(project_path)? { - analysis.detections.push(detection); - } - } - - // Run inference engine - let inference = InferenceEngine::with_default_rules(); - analysis.requirements = inference.infer_requirements(&analysis.detections); - - analysis.calculate_overall_confidence(); - - Ok(analysis) - } - - /// Add custom detector - pub fn add_detector(mut self, detector: Box) -> Self { - self.detectors.push(detector); - self - } -} - -impl Default for StackDetector { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use tempfile::TempDir; - - use super::*; - - #[test] - fn test_detect_nodejs_project() -> DetectionResult<()> { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let package_json_path = temp_dir.path().join("package.json"); - - std::fs::write( - &package_json_path, - r#"{"name": "test", "dependencies": {"express": "^4.18.0"}}"#, - ) - .expect("Failed to write package.json"); - - let detector = StackDetector::new(); - let analysis = detector.detect_all(temp_dir.path())?; - - assert!(!analysis.detections.is_empty()); - assert!(analysis - .detections - .iter() - .any(|d| d.technology == Technology::NodeJs)); - - Ok(()) - } -} diff --git a/crates/detector/src/models.rs b/crates/detector/src/models.rs deleted file mode 100644 index f2043d7..0000000 --- a/crates/detector/src/models.rs +++ /dev/null @@ -1,317 +0,0 @@ -use std::path::PathBuf; - -/// Core data structures for project detection and technology inference -use serde::{Deserialize, Serialize}; - -/// Detected technology or framework in a project -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Technology { - // Languages - #[serde(rename = "nodejs")] - NodeJs, - Python, - Rust, - Go, - Java, - Ruby, - Php, - - // Frameworks - #[serde(rename = "expressjs")] - Express, - Django, - Axum, - Gin, - Spring, - Rails, - Laravel, - - // Databases - Postgres, - Mysql, - Mongodb, - Redis, - Sqlite, - - // Infrastructure - Docker, - Kubernetes, - Nginx, - - // Package Managers - Npm, - Yarn, - Pnpm, - Pip, - Cargo, - - // Build Tools - Webpack, - Vite, - Maven, - Gradle, -} - -impl Technology { - pub fn display_name(&self) -> &'static str { - match self { - Technology::NodeJs => "Node.js", - Technology::Express => "Express", - Technology::Postgres => "PostgreSQL", - Technology::Mysql => "MySQL", - Technology::Docker => "Docker", - Technology::Kubernetes => "Kubernetes", - _ => "Unknown", - } - } - - pub fn as_str(&self) -> &'static str { - match self { - Technology::NodeJs => "nodejs", - Technology::Python => "python", - Technology::Rust => "rust", - Technology::Go => "go", - Technology::Java => "java", - Technology::Ruby => "ruby", - Technology::Php => "php", - Technology::Express => "expressjs", - Technology::Django => "django", - Technology::Axum => "axum", - Technology::Gin => "gin", - Technology::Spring => "spring", - Technology::Rails => "rails", - Technology::Laravel => "laravel", - Technology::Postgres => "postgres", - Technology::Mysql => "mysql", - Technology::Mongodb => "mongodb", - Technology::Redis => "redis", - Technology::Sqlite => "sqlite", - Technology::Docker => "docker", - Technology::Kubernetes => "kubernetes", - Technology::Nginx => "nginx", - Technology::Npm => "npm", - Technology::Yarn => "yarn", - Technology::Pnpm => "pnpm", - Technology::Pip => "pip", - Technology::Cargo => "cargo", - Technology::Webpack => "webpack", - Technology::Vite => "vite", - Technology::Maven => "maven", - Technology::Gradle => "gradle", - } - } -} - -/// Evidence supporting a technology detection -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum Evidence { - /// File exists (e.g., package.json, Cargo.toml) - FileExists { path: PathBuf, reason: String }, - - /// File contains specific pattern - FileContent { - path: PathBuf, - pattern: String, - line: usize, - reason: String, - }, - - /// Dependency in manifest (npm, cargo, pip, etc.) - Dependency { - name: String, - version: Option, - reason: String, - }, - - /// Version info extracted - Version { version: String, source: String }, -} - -impl Evidence { - pub fn weight(&self) -> f32 { - match self { - Evidence::FileExists { .. } => 0.4, - Evidence::FileContent { .. } => 0.5, - Evidence::Dependency { .. } => 0.6, - Evidence::Version { .. } => 0.3, - } - } -} - -/// Result of detecting a single technology -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Detection { - pub technology: Technology, - pub version: Option, - pub evidence: Vec, - pub confidence: f32, -} - -impl Detection { - pub fn new(technology: Technology) -> Self { - Self { - technology, - version: None, - evidence: Vec::new(), - confidence: 0.0, - } - } - - pub fn with_version(mut self, version: String) -> Self { - self.version = Some(version); - self - } - - pub fn add_evidence(mut self, evidence: Evidence) -> Self { - self.evidence.push(evidence); - self - } - - pub fn calculate_confidence(&mut self) { - if self.evidence.is_empty() { - self.confidence = 0.0; - } else { - let total_weight: f32 = self.evidence.iter().map(|e| e.weight()).sum(); - let max_weight = self.evidence.len() as f32 * 0.6; // Max single evidence weight - self.confidence = (total_weight / max_weight).clamp(0.0, 1.0); - } - } - - pub fn is_high_confidence(&self) -> bool { - self.confidence > 0.7 - } -} - -/// Requirement inferred from detections -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Requirement { - /// Name of taskserv (e.g., "redis", "postgres") - pub taskserv: String, - - /// Human-readable reason - pub reason: String, - - /// Confidence (0.0 - 1.0) - higher = more certain - pub confidence: f32, - - /// Is this required (true) or optional (false)? - pub required: bool, - - /// Minimum version requirement, if any - pub min_version: Option, -} - -impl Requirement { - pub fn new( - taskserv: impl Into, - reason: impl Into, - confidence: f32, - required: bool, - ) -> Self { - Self { - taskserv: taskserv.into(), - reason: reason.into(), - confidence, - required, - min_version: None, - } - } - - pub fn with_min_version(mut self, version: String) -> Self { - self.min_version = Some(version); - self - } - - pub fn is_high_confidence(&self) -> bool { - self.confidence > 0.7 - } -} - -/// Complete project analysis result -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProjectAnalysis { - pub project_path: PathBuf, - pub detections: Vec, - pub requirements: Vec, - pub overall_confidence: f32, -} - -impl ProjectAnalysis { - pub fn new(project_path: PathBuf) -> Self { - Self { - project_path, - detections: Vec::new(), - requirements: Vec::new(), - overall_confidence: 0.0, - } - } - - pub fn calculate_overall_confidence(&mut self) { - if self.detections.is_empty() { - self.overall_confidence = 0.0; - } else { - let avg: f32 = self.detections.iter().map(|d| d.confidence).sum::() - / self.detections.len() as f32; - self.overall_confidence = avg; - } - } - - pub fn get_detection(&self, tech: Technology) -> Option<&Detection> { - self.detections.iter().find(|d| d.technology == tech) - } - - pub fn get_language(&self) -> Option<&Detection> { - self.detections.iter().find(|d| { - matches!( - d.technology, - Technology::NodeJs - | Technology::Python - | Technology::Rust - | Technology::Go - | Technology::Java - | Technology::Ruby - | Technology::Php - ) - }) - } - - pub fn has_docker(&self) -> bool { - self.detections - .iter() - .any(|d| d.technology == Technology::Docker) - } - - pub fn has_database(&self) -> bool { - self.detections.iter().any(|d| { - matches!( - d.technology, - Technology::Postgres | Technology::Mysql | Technology::Mongodb | Technology::Sqlite - ) - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_detection_confidence() { - let det = Detection::new(Technology::NodeJs).add_evidence(Evidence::FileExists { - path: PathBuf::from("package.json"), - reason: "Primary Node.js indicator".into(), - }); - let mut det = det; - det.calculate_confidence(); - - assert!(det.confidence > 0.0); - assert!(!det.is_high_confidence()); // Only one evidence - } - - #[test] - fn test_requirement_confidence() { - let req = Requirement::new("redis", "API caching", 0.85, false); - assert!(req.is_high_confidence()); - } -} diff --git a/crates/detector/src/questionnaire/decision_tree.rs b/crates/detector/src/questionnaire/decision_tree.rs deleted file mode 100644 index 82805b8..0000000 --- a/crates/detector/src/questionnaire/decision_tree.rs +++ /dev/null @@ -1,258 +0,0 @@ -/// Decision Tree Engine -/// -/// Manages conditional question flow based on user answers. -/// Evaluates expressions to determine which questions to ask next. -/// -/// Features: -/// - Tree traversal with expression evaluation -/// - Conditional question display -/// - Dependency tracking -/// - Answer validation -use std::collections::HashMap; - -use regex::Regex; - -/// Expression evaluator for conditional logic -#[derive(Debug, Clone)] -pub struct Expression { - expr: String, -} - -impl Expression { - pub fn new(expr: impl Into) -> Self { - Self { expr: expr.into() } - } - - /// Evaluate expression against current answers - /// - /// Supports: - /// - Simple equality: "field == 'value'" - /// - Comparisons: "field >= 5" - /// - Boolean logic: "a and b", "x or y" - /// - Set containment: "field in ['a', 'b', 'c']" - pub fn evaluate(&self, answers: &HashMap) -> bool { - let expr = self.expr.clone(); - - // Handle simple equality comparisons: "key == 'value'" - if let Some(caps) = Regex::new(r#"(\w+)\s*==\s*['"]([^'"]*)['"']"#) - .ok() - .and_then(|re| re.captures(&expr)) - { - let key = caps.get(1).map(|m| m.as_str()).unwrap_or(""); - let expected = caps.get(2).map(|m| m.as_str()).unwrap_or(""); - if let Some(actual) = answers.get(key) { - return actual == expected; - } - return false; - } - - // Handle "in" operator: "key in ['a', 'b', 'c']" - if let Some(caps) = Regex::new(r"(\w+)\s+in\s+\[\s*([^\]]*)\s*\]") - .ok() - .and_then(|re| re.captures(&expr)) - { - let key = caps.get(1).map(|m| m.as_str()).unwrap_or(""); - let values_str = caps.get(2).map(|m| m.as_str()).unwrap_or(""); - - if let Some(actual) = answers.get(key) { - let values: Vec<&str> = values_str - .split(',') - .map(|s| s.trim().trim_matches('\'').trim_matches('"')) - .collect(); - return values.contains(&actual.as_str()); - } - return false; - } - - // Handle "and" operator - if expr.contains(" and ") { - let parts: Vec<&str> = expr.split(" and ").collect(); - return parts - .iter() - .all(|part| Expression::new(*part).evaluate(answers)); - } - - // Handle "or" operator - if expr.contains(" or ") { - let parts: Vec<&str> = expr.split(" or ").collect(); - return parts - .iter() - .any(|part| Expression::new(*part).evaluate(answers)); - } - - // Default: treat as true (no condition means always ask) - true - } -} - -/// Single node in decision tree -#[derive(Debug, Clone)] -pub struct DecisionNode { - pub question_id: String, - pub next_nodes: HashMap, // answer -> next question_id - pub default_next: Option, // fallback if answer not in next_nodes -} - -impl DecisionNode { - pub fn new(question_id: impl Into) -> Self { - Self { - question_id: question_id.into(), - next_nodes: HashMap::new(), - default_next: None, - } - } - - pub fn add_transition( - mut self, - answer: impl Into, - next_question: impl Into, - ) -> Self { - self.next_nodes.insert(answer.into(), next_question.into()); - self - } - - pub fn set_default(mut self, next_question: impl Into) -> Self { - self.default_next = Some(next_question.into()); - self - } - - /// Get next question ID based on answer - pub fn get_next(&self, answer: &str) -> Option { - self.next_nodes - .get(answer) - .cloned() - .or_else(|| { - // Try wildcard match (*) for "any answer" transitions - self.next_nodes.get("*").cloned() - }) - .or_else(|| self.default_next.clone()) - } -} - -/// Complete decision tree for questionnaire -#[derive(Debug, Clone)] -pub struct DecisionTree { - root: String, - nodes: HashMap, -} - -impl DecisionTree { - pub fn new(root: impl Into) -> Self { - Self { - root: root.into(), - nodes: HashMap::new(), - } - } - - pub fn add_node(mut self, node: DecisionNode) -> Self { - self.nodes.insert(node.question_id.clone(), node); - self - } - - /// Get root question ID - pub fn get_root(&self) -> &str { - &self.root - } - - /// Get starting node - pub fn get_start_node(&self) -> Option<&DecisionNode> { - self.nodes.get(&self.root) - } - - /// Get next question based on current answer - pub fn get_next_question(&self, current_question: &str, answer: &str) -> Option { - self.nodes - .get(current_question) - .and_then(|node| node.get_next(answer)) - } - - /// Check if question exists in tree - pub fn has_question(&self, question_id: &str) -> bool { - self.nodes.contains_key(question_id) - } - - /// Get all question IDs in tree - pub fn all_question_ids(&self) -> Vec<&str> { - self.nodes.keys().map(|k| k.as_str()).collect() - } - - /// Check if answer leads to end of questionnaire - pub fn is_terminal(&self, current_question: &str, answer: &str) -> bool { - match self.get_next_question(current_question, answer) { - None => true, - Some(next_id) => next_id == "END" || !self.has_question(&next_id), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_expression_equality() { - let expr = Expression::new("deployment_mode == 'prod'"); - let mut answers = HashMap::new(); - answers.insert("deployment_mode".to_string(), "prod".to_string()); - assert!(expr.evaluate(&answers)); - - answers.insert("deployment_mode".to_string(), "dev".to_string()); - assert!(!expr.evaluate(&answers)); - } - - #[test] - fn test_expression_in_operator() { - let expr = Expression::new("env in ['prod', 'staging']"); - let mut answers = HashMap::new(); - answers.insert("env".to_string(), "prod".to_string()); - assert!(expr.evaluate(&answers)); - - answers.insert("env".to_string(), "dev".to_string()); - assert!(!expr.evaluate(&answers)); - } - - #[test] - fn test_expression_and_operator() { - let expr = Expression::new("has_db == 'true' and deployment_mode == 'prod'"); - let mut answers = HashMap::new(); - answers.insert("has_db".to_string(), "true".to_string()); - answers.insert("deployment_mode".to_string(), "prod".to_string()); - assert!(expr.evaluate(&answers)); - - answers.insert("has_db".to_string(), "false".to_string()); - assert!(!expr.evaluate(&answers)); - } - - #[test] - fn test_decision_tree_traversal() { - let root_node = DecisionNode::new("q1") - .add_transition("yes", "q2") - .add_transition("no", "END"); - - let q2_node = DecisionNode::new("q2").add_transition("*", "q3"); - - let tree = DecisionTree::new("q1") - .add_node(root_node) - .add_node(q2_node) - .add_node(DecisionNode::new("q3")); - - assert_eq!(tree.get_next_question("q1", "yes"), Some("q2".to_string())); - assert_eq!(tree.get_next_question("q1", "no"), Some("END".to_string())); - assert_eq!( - tree.get_next_question("q2", "anything"), - Some("q3".to_string()) - ); - } - - #[test] - fn test_terminal_question() { - let q1_node = DecisionNode::new("q1") - .add_transition("finish", "END") - .set_default("q2"); - let q2_node = DecisionNode::new("q2"); - let tree = DecisionTree::new("q1").add_node(q1_node).add_node(q2_node); - - assert!(tree.is_terminal("q1", "finish")); // Maps to END - assert!(!tree.is_terminal("q1", "continue")); // Maps to q2 via default - } -} diff --git a/crates/detector/src/questionnaire/mod.rs b/crates/detector/src/questionnaire/mod.rs deleted file mode 100644 index 4a68912..0000000 --- a/crates/detector/src/questionnaire/mod.rs +++ /dev/null @@ -1,45 +0,0 @@ -/// Questionnaire System -/// -/// Interactive questionnaire engine with: -/// - Conditional questions (only ask if conditions met) -/// - Decision tree navigation -/// - Expression evaluation for conditional logic -/// - TUI interface for interactive prompts -/// -/// Example: -/// ``` -/// use provisioning_detector::questionnaire::{ -/// DecisionTree, DecisionNode, QuestionnaireEngine, Question, QuestionKind, -/// Expression, InteractiveTUI, -/// }; -/// use std::collections::HashMap; -/// -/// // Create decision tree -/// let root = DecisionNode::new("deployment_mode") -/// .add_transition("prod", "enable_backup") -/// .set_default("END"); -/// let tree = DecisionTree::new("deployment_mode") -/// .add_node(root); -/// -/// // Create questions -/// let q1 = Question::new("deployment_mode", QuestionKind::Select, "Deployment mode?"); -/// let q2 = Question::new("enable_backup", QuestionKind::Confirm, "Enable backups?") -/// .with_condition(Expression::new("deployment_mode == 'prod'")); -/// -/// // Create engine -/// let engine = QuestionnaireEngine::new("Setup", "1.0.0", tree) -/// .add_question(q1) -/// .add_question(q2); -/// -/// // Run interactive session -/// let response = InteractiveTUI::run(&engine).unwrap(); -/// ``` -pub mod decision_tree; -pub mod questionnaire_engine; -pub mod tui; - -pub use decision_tree::{DecisionNode, DecisionTree, Expression}; -pub use questionnaire_engine::{ - Answer, Question, QuestionKind, QuestionnaireEngine, QuestionnaireResponse, ValidationRule, -}; -pub use tui::InteractiveTUI; diff --git a/crates/detector/src/questionnaire/questionnaire_engine.rs b/crates/detector/src/questionnaire/questionnaire_engine.rs deleted file mode 100644 index 55f3f9e..0000000 --- a/crates/detector/src/questionnaire/questionnaire_engine.rs +++ /dev/null @@ -1,402 +0,0 @@ -use std::collections::HashMap; - -use regex::Regex; - -/// Questionnaire Engine -/// -/// Manages questionnaire flow, validation, and response collection. -/// Orchestrates decision tree with questions to create interactive sessions. -/// -/// Features: -/// - Question definition and validation -/// - Conditional question display -/// - Answer validation -/// - Response tracking -/// - Session state management -use super::decision_tree::{DecisionTree, Expression}; - -/// Question type -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum QuestionKind { - Text, - Select, - Multiselect, - Confirm, - Number, -} - -impl QuestionKind { - pub fn as_str(&self) -> &'static str { - match self { - Self::Text => "text", - Self::Select => "select", - Self::Multiselect => "multiselect", - Self::Confirm => "confirm", - Self::Number => "number", - } - } - - pub fn parse(s: &str) -> Option { - match s { - "text" => Some(Self::Text), - "select" => Some(Self::Select), - "multiselect" => Some(Self::Multiselect), - "confirm" => Some(Self::Confirm), - "number" => Some(Self::Number), - _ => None, - } - } -} - -/// Validation rule for answer -#[derive(Debug, Clone)] -pub struct ValidationRule { - pub required: bool, - pub pattern: Option, - pub min_value: Option, - pub max_value: Option, - pub choices: Option>, -} - -impl ValidationRule { - pub fn new() -> Self { - Self { - required: false, - pattern: None, - min_value: None, - max_value: None, - choices: None, - } - } - - pub fn required(mut self, required: bool) -> Self { - self.required = required; - self - } - - pub fn pattern(mut self, pattern: impl Into) -> Self { - self.pattern = Some(pattern.into()); - self - } - - pub fn choices(mut self, choices: Vec) -> Self { - self.choices = Some(choices); - self - } - - pub fn validate(&self, value: &str) -> Result<(), String> { - if self.required && value.is_empty() { - return Err("This field is required".to_string()); - } - - if let Some(pattern) = &self.pattern { - if let Ok(regex) = Regex::new(pattern) { - if !regex.is_match(value) { - return Err(format!("Value must match pattern: {}", pattern)); - } - } - } - - if let Some(choices) = &self.choices { - if !choices.contains(&value.to_string()) { - return Err(format!("Must choose from: {}", choices.join(", "))); - } - } - - Ok(()) - } -} - -impl Default for ValidationRule { - fn default() -> Self { - Self::new() - } -} - -/// Single question in questionnaire -#[derive(Debug, Clone)] -pub struct Question { - pub id: String, - pub kind: QuestionKind, - pub message: String, - pub help: Option, - pub default: Option, - pub when: Option, - pub validation: ValidationRule, - pub ai_suggest: bool, -} - -impl Question { - pub fn new(id: impl Into, kind: QuestionKind, message: impl Into) -> Self { - Self { - id: id.into(), - kind, - message: message.into(), - help: None, - default: None, - when: None, - validation: ValidationRule::new(), - ai_suggest: false, - } - } - - pub fn with_help(mut self, help: impl Into) -> Self { - self.help = Some(help.into()); - self - } - - pub fn with_default(mut self, default: impl Into) -> Self { - self.default = Some(default.into()); - self - } - - pub fn with_condition(mut self, when: Expression) -> Self { - self.when = Some(when); - self - } - - pub fn with_validation(mut self, validation: ValidationRule) -> Self { - self.validation = validation; - self - } - - pub fn with_ai_suggest(mut self, enabled: bool) -> Self { - self.ai_suggest = enabled; - self - } - - /// Check if question should be asked given current answers - pub fn should_ask(&self, answers: &HashMap) -> bool { - if let Some(condition) = &self.when { - condition.evaluate(answers) - } else { - true - } - } -} - -/// User answer to a question -#[derive(Debug, Clone)] -pub struct Answer { - pub question_id: String, - pub value: String, - pub timestamp: String, -} - -impl Answer { - pub fn new(question_id: impl Into, value: impl Into) -> Self { - Self { - question_id: question_id.into(), - value: value.into(), - timestamp: chrono::Utc::now().to_rfc3339(), - } - } -} - -/// Complete questionnaire response -#[derive(Debug, Clone)] -pub struct QuestionnaireResponse { - pub questionnaire_name: String, - pub questionnaire_version: String, - pub answers: HashMap, - pub completed: bool, - pub completion_time: Option, -} - -impl QuestionnaireResponse { - pub fn new(name: impl Into, version: impl Into) -> Self { - Self { - questionnaire_name: name.into(), - questionnaire_version: version.into(), - answers: HashMap::new(), - completed: false, - completion_time: None, - } - } - - pub fn add_answer(&mut self, question_id: impl Into, value: impl Into) { - self.answers.insert(question_id.into(), value.into()); - } - - pub fn complete(mut self) -> Self { - self.completed = true; - self.completion_time = Some(chrono::Utc::now().to_rfc3339()); - self - } -} - -/// Questionnaire engine orchestrator -pub struct QuestionnaireEngine { - name: String, - version: String, - questions: HashMap, - decision_tree: DecisionTree, -} - -impl QuestionnaireEngine { - pub fn new( - name: impl Into, - version: impl Into, - decision_tree: DecisionTree, - ) -> Self { - Self { - name: name.into(), - version: version.into(), - questions: HashMap::new(), - decision_tree, - } - } - - pub fn add_question(mut self, question: Question) -> Self { - self.questions.insert(question.id.clone(), question); - self - } - - /// Get next question to ask - pub fn get_next_question( - &self, - current_question: Option<&str>, - current_answer: Option<&str>, - answers: &HashMap, - ) -> Option<&Question> { - let next_id = if let (Some(q_id), Some(answer)) = (current_question, current_answer) { - self.decision_tree.get_next_question(q_id, answer)? - } else { - self.decision_tree.get_root().to_string() - }; - - if next_id == "END" { - return None; - } - - // Get the question - let question = self.questions.get(&next_id)?; - - // Check if it should be asked - if question.should_ask(answers) { - Some(question) - } else { - // Skip to next question - self.get_next_question(Some(&next_id), None, answers) - } - } - - /// Validate answer for question - pub fn validate_answer(&self, question_id: &str, value: &str) -> Result<(), String> { - let question = self - .questions - .get(question_id) - .ok_or_else(|| "Question not found".to_string())?; - - question.validation.validate(value) - } - - /// Get question by ID - pub fn get_question(&self, id: &str) -> Option<&Question> { - self.questions.get(id) - } - - /// Check if questionnaire is complete - pub fn is_complete(&self, answers: &HashMap) -> bool { - let mut current_question = Some(self.decision_tree.get_root()); - - loop { - let next = current_question.and_then(|q_id| { - let question = self.questions.get(q_id)?; - if question.should_ask(answers) { - Some(q_id) - } else { - None - } - }); - - if next.is_none() { - // No more questions to ask - return true; - } - - current_question = next; - - let Some(q_id) = current_question else { - return true; - }; - - let Some(answer) = answers.get(q_id) else { - return false; // Question not answered - }; - - let next_q = self.get_next_question(Some(q_id), Some(answer), answers); - if next_q.is_none() { - return true; - } - } - } - - pub fn questionnaire_name(&self) -> &str { - &self.name - } - - pub fn questionnaire_version(&self) -> &str { - &self.version - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_question_kind_conversion() { - assert_eq!(QuestionKind::Text.as_str(), "text"); - assert_eq!(QuestionKind::parse("select"), Some(QuestionKind::Select)); - assert_eq!(QuestionKind::parse("invalid"), None); - } - - #[test] - fn test_validation_required() { - let rule = ValidationRule::new().required(true); - assert!(rule.validate("").is_err()); - assert!(rule.validate("value").is_ok()); - } - - #[test] - fn test_validation_pattern() { - let rule = ValidationRule::new().pattern(r"^\d+$"); - assert!(rule.validate("123").is_ok()); - assert!(rule.validate("abc").is_err()); - } - - #[test] - fn test_validation_choices() { - let rule = ValidationRule::new().choices(vec!["prod".to_string(), "staging".to_string()]); - assert!(rule.validate("prod").is_ok()); - assert!(rule.validate("dev").is_err()); - } - - #[test] - fn test_question_conditional() { - let question = Question::new("q1", QuestionKind::Text, "Deploy mode?") - .with_condition(Expression::new("is_prod == 'true'")); - - let mut answers = HashMap::new(); - answers.insert("is_prod".to_string(), "true".to_string()); - assert!(question.should_ask(&answers)); - - answers.insert("is_prod".to_string(), "false".to_string()); - assert!(!question.should_ask(&answers)); - } - - #[test] - fn test_questionnaire_response() { - let mut response = QuestionnaireResponse::new("Test", "1.0.0"); - response.add_answer("q1", "answer1"); - response.add_answer("q2", "answer2"); - - assert_eq!(response.answers.get("q1"), Some(&"answer1".to_string())); - assert_eq!(response.answers.len(), 2); - - let completed = response.complete(); - assert!(completed.completed); - assert!(completed.completion_time.is_some()); - } -} diff --git a/crates/detector/src/questionnaire/tui.rs b/crates/detector/src/questionnaire/tui.rs deleted file mode 100644 index 340e6a6..0000000 --- a/crates/detector/src/questionnaire/tui.rs +++ /dev/null @@ -1,243 +0,0 @@ -use std::io::{self, BufRead, Write}; - -/// Interactive TUI (Text User Interface) -/// -/// Simple CLI-based terminal interface for interactive questionnaires. -/// Provides user-friendly prompts without heavy dependencies. -/// -/// Features: -/// - Text, select, multiselect, confirm, and number input modes -/// - Live validation with error messages -/// - Progress tracking -/// - Help text display -/// - Simple command-line interaction -use crate::questionnaire::questionnaire_engine::{ - Question, QuestionKind, QuestionnaireEngine, QuestionnaireResponse, -}; - -/// Interactive TUI for questionnaires -pub struct InteractiveTUI; - -impl InteractiveTUI { - /// Run interactive questionnaire session - pub fn run(engine: &QuestionnaireEngine) -> io::Result { - let stdin = io::stdin(); - let mut stdout = io::stdout(); - let mut reader = stdin.lock(); - let mut response = - QuestionnaireResponse::new(engine.questionnaire_name(), engine.questionnaire_version()); - - println!("\n╔════════════════════════════════════════════════╗"); - println!( - "║ {} v{}", - engine.questionnaire_name(), - engine.questionnaire_version() - ); - println!("╚════════════════════════════════════════════════╝\n"); - - let mut current_question = engine.get_next_question(None, None, &response.answers); - let mut question_count = 0; - - while let Some(question) = current_question { - question_count += 1; - let answer = Self::ask_question(&mut reader, &mut stdout, question, question_count)?; - - // Validate answer - match engine.validate_answer(&question.id, &answer) { - Ok(()) => { - response.add_answer(&question.id, answer.clone()); - - // Get next question - current_question = engine.get_next_question( - Some(&question.id), - Some(&answer), - &response.answers, - ); - } - Err(err) => { - println!(" ✗ Validation error: {}\n", err); - continue; - } - } - } - - println!("\n✓ Questionnaire complete!"); - Ok(response.complete()) - } - - fn ask_question( - reader: &mut dyn BufRead, - stdout: &mut dyn Write, - question: &Question, - question_number: usize, - ) -> io::Result { - loop { - println!("\n[Question {}/?]", question_number); - println!(" {}", question.message); - - if let Some(help) = &question.help { - println!(" ℹ {}", help); - } - - match question.kind { - QuestionKind::Text => { - Self::prompt_text(reader, stdout, question)?; - } - QuestionKind::Number => { - return Self::prompt_number(reader, stdout, question); - } - QuestionKind::Confirm => { - return Self::prompt_confirm(reader, stdout, question); - } - QuestionKind::Select => { - return Self::prompt_select(reader, stdout, question); - } - QuestionKind::Multiselect => { - return Self::prompt_multiselect(reader, stdout, question); - } - } - - let mut input = String::new(); - write!(stdout, " > ")?; - stdout.flush()?; - reader.read_line(&mut input)?; - let input = input.trim().to_string(); - - if input.is_empty() && question.default.is_some() { - return Ok(question.default.as_ref().unwrap().clone()); - } - - if !input.is_empty() { - return Ok(input); - } - - if question.validation.required { - println!(" ✗ This field is required"); - } - } - } - - fn prompt_text( - _reader: &mut dyn BufRead, - _stdout: &mut dyn Write, - question: &Question, - ) -> io::Result<()> { - if let Some(default) = &question.default { - print!(" [{default}] > "); - } else { - print!(" > "); - } - io::stdout().flush()?; - Ok(()) - } - - fn prompt_number( - reader: &mut dyn BufRead, - stdout: &mut dyn Write, - question: &Question, - ) -> io::Result { - if let Some(default) = &question.default { - write!(stdout, " [{default}] > ")?; - } else { - write!(stdout, " > ")?; - } - stdout.flush()?; - - let mut input = String::new(); - reader.read_line(&mut input)?; - let input = input.trim().to_string(); - - if input.is_empty() && question.default.is_some() { - return Ok(question.default.as_ref().unwrap().clone()); - } - - Ok(input) - } - - fn prompt_confirm( - reader: &mut dyn BufRead, - stdout: &mut dyn Write, - question: &Question, - ) -> io::Result { - if let Some(default) = &question.default { - write!(stdout, " [{default}] (y/n) > ")?; - } else { - write!(stdout, " (y/n) > ")?; - } - stdout.flush()?; - - let mut input = String::new(); - reader.read_line(&mut input)?; - let input = input.trim().to_lowercase(); - - match input.as_str() { - "y" | "yes" => Ok("true".to_string()), - "n" | "no" => Ok("false".to_string()), - "" if question.default.is_some() => Ok(question.default.as_ref().unwrap().clone()), - _ => Ok(input), - } - } - - fn prompt_select( - reader: &mut dyn BufRead, - stdout: &mut dyn Write, - question: &Question, - ) -> io::Result { - if let Some(choices) = &question.validation.choices { - for (idx, choice) in choices.iter().enumerate() { - writeln!(stdout, " [{}] {}", idx + 1, choice)?; - } - } - - write!(stdout, " Choose option: ")?; - stdout.flush()?; - - let mut input = String::new(); - reader.read_line(&mut input)?; - Ok(input.trim().to_string()) - } - - fn prompt_multiselect( - reader: &mut dyn BufRead, - stdout: &mut dyn Write, - question: &Question, - ) -> io::Result { - if let Some(choices) = &question.validation.choices { - for (idx, choice) in choices.iter().enumerate() { - writeln!(stdout, " [{}] {}", idx + 1, choice)?; - } - writeln!(stdout, " (Enter multiple options separated by commas)")?; - } - - write!(stdout, " Choose options: ")?; - stdout.flush()?; - - let mut input = String::new(); - reader.read_line(&mut input)?; - Ok(input.trim().to_string()) - } -} -// Helper methods for testing -impl InteractiveTUI { - fn prompt_confirm_response(input: &str) -> String { - match input.to_lowercase().as_str() { - "y" | "yes" => "true".to_string(), - "n" | "no" => "false".to_string(), - _ => input.to_string(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_confirm_response_parsing() { - // Test that confirm responses are properly parsed - assert_eq!(InteractiveTUI::prompt_confirm_response("y"), "true"); - assert_eq!(InteractiveTUI::prompt_confirm_response("n"), "false"); - assert_eq!(InteractiveTUI::prompt_confirm_response("yes"), "true"); - assert_eq!(InteractiveTUI::prompt_confirm_response("no"), "false"); - } -} diff --git a/crates/detector/tests/integration_tests.rs b/crates/detector/tests/integration_tests.rs deleted file mode 100644 index af417ad..0000000 --- a/crates/detector/tests/integration_tests.rs +++ /dev/null @@ -1,393 +0,0 @@ -use std::path::PathBuf; - -/// Integration tests for the full Detection + Completion workflow -/// -/// Tests the complete Infrastructure-from-Code pipeline: -/// 1. Project technology detection -/// 2. Infrastructure requirement inference -/// 3. Gap analysis -/// 4. Declaration completion -use provisioning_detector::{Completer, GapAnalyzer, StackDetector, Technology}; -use tempfile::TempDir; - -/// Helper function to create a Node.js + Express project structure -fn create_nodejs_express_project() -> (TempDir, PathBuf) { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let project_path = temp_dir.path().to_path_buf(); - - // Create package.json with Express dependency - let package_json = r#"{ - "name": "express-app", - "version": "1.0.0", - "dependencies": { - "express": "^4.18.0", - "redis": "^4.0.0", - "pg": "^8.8.0" - } - }"#; - - std::fs::write(project_path.join("package.json"), package_json) - .expect("Failed to write package.json"); - - // Create .nvmrc - std::fs::write(project_path.join(".nvmrc"), "18.0.0").expect("Failed to write .nvmrc"); - - // Create Dockerfile - std::fs::write( - project_path.join("Dockerfile"), - "FROM node:18\nWORKDIR /app\nCOPY . .\nRUN npm install\nCMD [\"npm\", \"start\"]", - ) - .expect("Failed to write Dockerfile"); - - // Create source directory - std::fs::create_dir_all(project_path.join("src")).expect("Failed to create src dir"); - std::fs::write( - project_path.join("src/index.js"), - "const express = require('express');\nconst app = express();\napp.listen(3000);", - ) - .expect("Failed to write index.js"); - - (temp_dir, project_path) -} - -/// Helper function to create a Python + Django project structure -fn create_python_django_project() -> (TempDir, PathBuf) { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let project_path = temp_dir.path().to_path_buf(); - - // Create requirements.txt - let requirements = "django==4.2\npsycopg2-binary==2.9\ncelery==5.3\n"; - std::fs::write(project_path.join("requirements.txt"), requirements) - .expect("Failed to write requirements.txt"); - - // Create manage.py - std::fs::write( - project_path.join("manage.py"), - "#!/usr/bin/env python\nimport django", - ) - .expect("Failed to write manage.py"); - - // Create migrations directory - std::fs::create_dir_all(project_path.join("migrations")).expect("Failed to create migrations"); - std::fs::write( - project_path.join("migrations/0001_initial.py"), - "# Django migration", - ) - .expect("Failed to write migration"); - - (temp_dir, project_path) -} - -#[test] -fn test_detect_nodejs_express_project() { - let (_temp_dir, project_path) = create_nodejs_express_project(); - - // Run detection - let detector = StackDetector::new(); - let analysis = detector - .detect_all(&project_path) - .expect("Detection failed"); - - // Verify Node.js was detected from package.json and .nvmrc - assert!( - !analysis.detections.is_empty(), - "Should detect at least one technology" - ); - assert!( - analysis - .detections - .iter() - .any(|d| d.technology == Technology::NodeJs), - "Should detect Node.js technology" - ); - - // Verify overall confidence is calculated - assert!(analysis.overall_confidence >= 0.0); - - // Should have some inferred requirements - println!( - "Requirements: {:?}", - analysis - .requirements - .iter() - .map(|r| &r.taskserv) - .collect::>() - ); - - // Requirements inference depends on the inference engine - // At minimum, technology detection should succeed - assert!(!analysis.detections.is_empty()); -} - -#[test] -fn test_detect_python_django_project() { - let (_temp_dir, project_path) = create_python_django_project(); - - // Run detection - let detector = StackDetector::new(); - let analysis = detector - .detect_all(&project_path) - .expect("Detection failed"); - - // Verify Python was detected from requirements.txt, manage.py, or migrations - let detections = analysis - .detections - .iter() - .map(|d| d.technology) - .collect::>(); - println!("Detections: {:?}", detections); - - // Should detect Python from requirements.txt or manage.py - assert!( - detections.contains(&Technology::Python), - "Should detect Python: {:?}", - detections - ); - - println!( - "Requirements: {:?}", - analysis - .requirements - .iter() - .map(|r| &r.taskserv) - .collect::>() - ); -} - -#[test] -fn test_gap_analysis_with_empty_declaration() { - let (_temp_dir, project_path) = create_nodejs_express_project(); - - // Run detection - let detector = StackDetector::new(); - let analysis = detector - .detect_all(&project_path) - .expect("Detection failed"); - - // Extract required taskservs - let required_taskservs: Vec = analysis - .requirements - .iter() - .filter(|r| r.required) - .map(|r| r.taskserv.clone()) - .collect(); - - // Run gap analysis (assuming empty declaration - no current taskservs) - let gaps = GapAnalyzer::analyze(&analysis, &required_taskservs); - - // Should calculate completeness based on required taskservs - println!("Completeness: {:.1}%", gaps.completeness * 100.0); - println!("Gaps: {}", gaps.gaps.len()); - println!("Required taskservs: {}", required_taskservs.len()); - - // If there are no required taskservs, completeness will be 100% - // If there are required taskservs but none detected, completeness will be 0% - assert!(gaps.completeness >= 0.0 && gaps.completeness <= 1.0); -} - -#[test] -fn test_complete_declaration_workflow() { - let (_temp_dir, project_path) = create_nodejs_express_project(); - - // Step 1: Detect - let detector = StackDetector::new(); - let analysis = detector - .detect_all(&project_path) - .expect("Detection failed"); - - // Step 2: Plan completion - let completer = Completer::new(); - let completion_result = completer.complete(&analysis, Vec::new()); - - // Verify completion plan - assert!( - completion_result.changes_needed > 0, - "Should identify changes needed" - ); - println!("Changes needed: {}", completion_result.changes_needed); - println!("Is safe: {}", completion_result.is_safe); - println!("Summary: {}", completion_result.change_summary); - - // Adding taskservs without removals is safe - assert!(completion_result.is_safe); -} - -#[test] -fn test_multiple_technologies_detection() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let project_path = temp_dir.path().to_path_buf(); - - // Create a project with multiple indicators - // Node.js + Rust combo (monorepo style) - let package_json = r#"{ - "name": "full-stack-app", - "dependencies": { - "express": "^4.18.0", - "postgresql": "^14.0" - } - }"#; - std::fs::write(project_path.join("package.json"), package_json) - .expect("Failed to write package.json"); - - // Create Cargo.toml for Rust component - let cargo_toml = r#"[package] -name = "backend-service" -version = "0.1.0" -edition = "2021" - -[dependencies] -tokio = { version = "1", features = ["full"] } -"#; - std::fs::create_dir_all(project_path.join("backend")).expect("Failed to create backend"); - std::fs::write(project_path.join("backend/Cargo.toml"), cargo_toml) - .expect("Failed to write Cargo.toml"); - - // Run detection - let detector = StackDetector::new(); - let analysis = detector - .detect_all(&project_path) - .expect("Detection failed"); - - // Should detect both Node.js and Rust - let detections = analysis - .detections - .iter() - .map(|d| d.technology) - .collect::>(); - - println!("Detected: {:?}", detections); - assert!(detections.contains(&Technology::NodeJs) || detections.contains(&Technology::Rust)); -} - -#[test] -fn test_confidence_scoring() { - let (_temp_dir, project_path) = create_nodejs_express_project(); - - // Run detection - let detector = StackDetector::new(); - let analysis = detector - .detect_all(&project_path) - .expect("Detection failed"); - - // Each detection should have a confidence score - for detection in &analysis.detections { - assert!(detection.confidence >= 0.0 && detection.confidence <= 1.0); - assert!(!detection.evidence.is_empty(), "Should have evidence"); - } - - // Overall confidence should be calculated - assert!(analysis.overall_confidence >= 0.0 && analysis.overall_confidence <= 1.0); -} - -#[test] -fn test_requirement_classification() { - let (_temp_dir, project_path) = create_nodejs_express_project(); - - // Run detection - let detector = StackDetector::new(); - let analysis = detector - .detect_all(&project_path) - .expect("Detection failed"); - - // Separate requirements by classification - let required: Vec<_> = analysis - .requirements - .iter() - .filter(|r| r.required) - .collect(); - let optional: Vec<_> = analysis - .requirements - .iter() - .filter(|r| !r.required) - .collect(); - - println!("Required: {}", required.len()); - println!("Optional: {}", optional.len()); - - // Should have at least some inferred requirements - assert!(!analysis.requirements.is_empty()); - - // Requirements should be classified as either required or optional - assert_eq!( - required.len() + optional.len(), - analysis.requirements.len(), - "All requirements should be classified" - ); -} - -#[test] -fn test_requirement_inference_logic() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let project_path = temp_dir.path().to_path_buf(); - - // Create a project with specific inference triggers - let package_json = r#"{ - "name": "test-app", - "dependencies": { - "express": "^4.18.0", - "redis": "^4.0.0" - } - }"#; - std::fs::write(project_path.join("package.json"), package_json) - .expect("Failed to write package.json"); - - // Run detection and inference - let detector = StackDetector::new(); - let analysis = detector - .detect_all(&project_path) - .expect("Detection failed"); - - // Verify inference rules were applied: - // Express should trigger Nginx recommendation - let has_express = analysis - .detections - .iter() - .any(|d| d.technology == Technology::Express); - if has_express { - // Could have inferred nginx - let services: Vec<_> = analysis - .requirements - .iter() - .map(|r| r.taskserv.as_str()) - .collect(); - println!("Inferred services: {:?}", services); - } -} - -#[test] -fn test_detection_with_nonexistent_path() { - let detector = StackDetector::new(); - let nonexistent = PathBuf::from("/nonexistent/path"); - - // Detection should complete even if path doesn't exist - // but will have no detections - match detector.detect_all(&nonexistent) { - Ok(analysis) => { - // May succeed with empty detections - println!("Detections: {}", analysis.detections.len()); - } - Err(_e) => { - // Or may fail, both are acceptable - println!("Path detection failed as expected"); - } - } -} - -#[test] -fn test_empty_project_detection() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let project_path = temp_dir.path().to_path_buf(); - - // Empty project with no tech indicators - let detector = StackDetector::new(); - let analysis = detector - .detect_all(&project_path) - .expect("Detection failed"); - - // Should have no detections but not crash - println!("Detections: {}", analysis.detections.len()); - println!("Requirements: {}", analysis.requirements.len()); - - // Empty projects should result in low or zero confidence - assert!(analysis.overall_confidence == 0.0 || !analysis.detections.is_empty()); -} diff --git a/crates/extension-manager/Cargo.toml b/crates/extension-manager/Cargo.toml new file mode 100644 index 0000000..63e6475 --- /dev/null +++ b/crates/extension-manager/Cargo.toml @@ -0,0 +1,57 @@ +[package] +authors.workspace = true +description = "Observability daemon for provisioning components and provider artifacts" +edition.workspace = true +name = "extension-manager" +version.workspace = true + +[dependencies] +# Runtime +async-trait = { workspace = true } +tokio = { workspace = true, features = ["rt", "rt-multi-thread", "signal", "time", "fs", "io-util", "process"] } +futures = { workspace = true } + +# Serialization +serde = { workspace = true } +serde_json = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } + +# Error handling +anyhow = { workspace = true } +thiserror = { workspace = true } + +# Logging +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +# Web server +axum = { workspace = true } +tower-http = { workspace = true, features = ["cors", "trace"] } + +# Concurrent in-memory cache +dashmap = { workspace = true } + +# HTTP client (provider APIs + HTTP health probes) +reqwest = { workspace = true } + +# NATS events from orchestrator +async-nats = { workspace = true } + +# SSH for health checks +russh = { workspace = true } +russh-keys = { workspace = true } + +# Config (NCL → TOML → typed Rust via platform-config pattern) +platform-config = { workspace = true } + +# Path expansion +shellexpand = { workspace = true } + +[lib] +name = "extension_manager" +path = "src/lib.rs" + +[[bin]] +name = "extension-manager" +path = "src/main.rs" diff --git a/crates/extension-registry/Cargo.toml b/crates/extension-registry/Cargo.toml index f41dd1c..f64e97a 100644 --- a/crates/extension-registry/Cargo.toml +++ b/crates/extension-registry/Cargo.toml @@ -18,6 +18,11 @@ tokio = { workspace = true, features = ["full"] } # Web server and API axum = { workspace = true } tower = { workspace = true, features = ["full"] } + +# Ontoref API catalog +ontoref-ontology = { workspace = true } +ontoref-derive = { workspace = true } +inventory = { workspace = true } tower-http = { workspace = true, features = ["cors", "trace"] } # Serialization @@ -28,7 +33,7 @@ serde_json = { workspace = true } platform-config = { workspace = true } # Centralized observability (logging, metrics, health, tracing) -observability = { workspace = true, features = ["logging", "metrics-prometheus", "health"] } +platform-observability = { workspace = true, features = ["logging", "metrics-prometheus", "health"] } # Error handling anyhow = { workspace = true } diff --git a/crates/extension-registry/src/api/routes.rs b/crates/extension-registry/src/api/routes.rs index 486acf0..98b8cf2 100644 --- a/crates/extension-registry/src/api/routes.rs +++ b/crates/extension-registry/src/api/routes.rs @@ -15,13 +15,13 @@ pub fn build_routes(state: handlers::AppState) -> Router { // Extension operations .route("/extensions", get(handlers::list_extensions)) .route("/extensions/search", get(handlers::search_extensions)) - .route("/extensions/:type/:name", get(handlers::get_extension)) + .route("/extensions/{type}/{name}", get(handlers::get_extension)) .route( - "/extensions/:type/:name/versions", + "/extensions/{type}/{name}/versions", get(handlers::list_versions), ) .route( - "/extensions/:type/:name/:version", + "/extensions/{type}/{name}/{version}", get(handlers::download_extension), ) // System endpoints diff --git a/crates/extension-registry/src/api_catalog.rs b/crates/extension-registry/src/api_catalog.rs new file mode 100644 index 0000000..b30c4cf --- /dev/null +++ b/crates/extension-registry/src/api_catalog.rs @@ -0,0 +1,11 @@ +use axum::{extract::State, response::IntoResponse, Json}; +use ontoref_ontology::api::ApiRouteEntry; +use serde_json::json; + +use crate::handlers::AppState; + +pub async fn api_catalog(State(_state): State) -> impl IntoResponse { + let mut routes: Vec<&'static ApiRouteEntry> = inventory::iter::().collect(); + routes.sort_by(|a, b| a.path.cmp(b.path).then(a.method.cmp(b.method))); + Json(json!({ "service": "extension-registry", "routes": routes })) +} diff --git a/crates/extension-registry/src/handlers.rs b/crates/extension-registry/src/handlers.rs index 1305e68..f42fa2b 100644 --- a/crates/extension-registry/src/handlers.rs +++ b/crates/extension-registry/src/handlers.rs @@ -10,6 +10,7 @@ use axum::{ use extension_registry::service::{ ExtensionMetadata, ExtensionRegistry, ImageManifest, PushBlobRequest, }; +use ontoref_derive::onto_api; use serde_json::json; /// Application state @@ -69,6 +70,15 @@ impl RegistryError { } /// Check if blob exists (HEAD /v2//blobs/) +#[onto_api( + method = "HEAD", + path = "/v2/{name}/blobs/{digest}", + description = "Check if blob exists (OCI v2)", + auth = "bearer", + actors = "developer, agent", + tags = "oci, blobs", + feature = "" +)] async fn blob_exists( Path((_name, digest)): Path<(String, String)>, State(state): State, @@ -83,6 +93,15 @@ async fn blob_exists( } /// Pull blob (GET /v2//blobs/) +#[onto_api( + method = "GET", + path = "/v2/{name}/blobs/{digest}", + description = "Pull blob content (OCI v2)", + auth = "bearer", + actors = "developer, agent", + tags = "oci, blobs", + feature = "" +)] async fn pull_blob( Path((_name, digest)): Path<(String, String)>, State(state): State, @@ -101,6 +120,15 @@ async fn pull_blob( } /// Push blob (POST /v2//blobs/uploads/) +#[onto_api( + method = "POST", + path = "/v2/{name}/blobs/uploads", + description = "Push blob content (OCI v2)", + auth = "bearer", + actors = "developer", + tags = "oci, blobs", + feature = "" +)] async fn push_blob( Path(_name): Path, State(state): State, @@ -122,6 +150,15 @@ async fn push_blob( } /// Pull manifest (GET /v2//manifests/) +#[onto_api( + method = "GET", + path = "/v2/{name}/manifests/{reference}", + description = "Pull extension manifest (OCI v2)", + auth = "bearer", + actors = "developer, agent", + tags = "oci, manifests", + feature = "" +)] async fn pull_manifest( Path((name, reference)): Path<(String, String)>, State(state): State, @@ -149,6 +186,15 @@ async fn pull_manifest( } /// Push manifest (PUT /v2//manifests/) +#[onto_api( + method = "PUT", + path = "/v2/{name}/manifests/{reference}", + description = "Push extension manifest (OCI v2)", + auth = "bearer", + actors = "developer", + tags = "oci, manifests", + feature = "" +)] async fn push_manifest( Path((name, reference)): Path<(String, String)>, State(state): State, @@ -169,6 +215,15 @@ async fn push_manifest( } /// List repositories catalog (GET /v2/_catalog) +#[onto_api( + method = "GET", + path = "/v2/_catalog", + description = "List all extension repositories (OCI v2)", + auth = "bearer", + actors = "developer, agent", + tags = "oci, catalog", + feature = "" +)] async fn list_catalog( State(state): State, ) -> Result, RegistryError> { @@ -186,6 +241,15 @@ async fn list_catalog( } /// List extension tags (GET /v2//tags/list) +#[onto_api( + method = "GET", + path = "/v2/{name}/tags/list", + description = "List extension tags (OCI v2)", + auth = "bearer", + actors = "developer, agent", + tags = "oci, tags", + feature = "" +)] async fn list_tags( Path(name): Path, State(state): State, @@ -209,6 +273,15 @@ async fn list_tags( } /// Register extension metadata (POST /extensions) +#[onto_api( + method = "POST", + path = "/extensions", + description = "Register extension metadata", + auth = "bearer", + actors = "developer", + tags = "extensions", + feature = "" +)] async fn register_extension( State(state): State, Json(metadata): Json, @@ -229,6 +302,15 @@ async fn register_extension( } /// Get extension metadata (GET /extensions/:name) +#[onto_api( + method = "GET", + path = "/extensions/{name}", + description = "Get extension metadata by name", + auth = "bearer", + actors = "developer, agent", + tags = "extensions", + feature = "" +)] async fn get_extension( Path(name): Path, State(state): State, @@ -248,6 +330,15 @@ async fn get_extension( } /// List all extensions (GET /extensions) +#[onto_api( + method = "GET", + path = "/extensions", + description = "List all registered extensions", + auth = "bearer", + actors = "developer, agent", + tags = "extensions", + feature = "" +)] async fn list_extensions( State(state): State, ) -> Result>, RegistryError> { @@ -260,6 +351,15 @@ async fn list_extensions( } /// Health check (GET /health) +#[onto_api( + method = "GET", + path = "/health", + description = "Service health check", + auth = "none", + actors = "developer, agent, ci", + tags = "health", + feature = "" +)] async fn health(State(state): State) -> Result { state .registry @@ -274,19 +374,24 @@ async fn health(State(state): State) -> Result Router { Router::new() // OCI v2 API - .route("/v2/:name/blobs/:digest", head(blob_exists)) - .route("/v2/:name/blobs/:digest", get(pull_blob)) - .route("/v2/:name/blobs/uploads", post(push_blob)) - .route("/v2/:name/manifests/:reference", get(pull_manifest)) - .route("/v2/:name/manifests/:reference", put(push_manifest)) + .route("/v2/{name}/blobs/{digest}", head(blob_exists)) + .route("/v2/{name}/blobs/{digest}", get(pull_blob)) + .route("/v2/{name}/blobs/uploads", post(push_blob)) + .route("/v2/{name}/manifests/{reference}", get(pull_manifest)) + .route("/v2/{name}/manifests/{reference}", put(push_manifest)) .route("/v2/_catalog", get(list_catalog)) - .route("/v2/:name/tags/list", get(list_tags)) + .route("/v2/{name}/tags/list", get(list_tags)) // Extensions API .route("/extensions", post(register_extension)) .route("/extensions", get(list_extensions)) - .route("/extensions/:name", get(get_extension)) + .route("/extensions/{name}", get(get_extension)) // Health .route("/health", get(health)) .route("/api/v1/health", get(health)) + // API catalog + .route( + "/api/catalog", + get(crate::api_catalog::api_catalog), + ) .with_state(state) } diff --git a/crates/extension-registry/src/main.rs b/crates/extension-registry/src/main.rs index a8af8d6..57d89ca 100644 --- a/crates/extension-registry/src/main.rs +++ b/crates/extension-registry/src/main.rs @@ -6,6 +6,7 @@ use clap::Parser; use extension_registry::{config::Config, ExtensionRegistry, API_VERSION, DEFAULT_PORT}; use tokio::net::TcpListener; +mod api_catalog; mod handlers; use handlers::{routes, AppState}; @@ -32,6 +33,11 @@ struct Cli { /// Port to bind to #[arg(long, default_value_t = DEFAULT_PORT)] port: u16, + + /// Print all #[onto_api] registered routes as JSON and exit. + /// Pipe to api-catalog-extension-registry.json: `just export-api-catalog` + #[arg(long)] + dump_api_catalog: bool, } #[tokio::main] @@ -39,6 +45,11 @@ async fn main() -> anyhow::Result<()> { // Parse CLI arguments FIRST (so --help works before any other processing) let cli = Cli::parse(); + if cli.dump_api_catalog { + println!("{}", ontoref_ontology::api::dump_catalog_json()); + return Ok(()); + } + // Initialize centralized observability (logging, metrics, health checks) let _guard = observability::init_from_env("extension-registry", env!("CARGO_PKG_VERSION"))?; diff --git a/crates/mcp-server/Cargo.toml b/crates/mcp-server/Cargo.toml index 5be91ca..d097275 100644 --- a/crates/mcp-server/Cargo.toml +++ b/crates/mcp-server/Cargo.toml @@ -5,7 +5,7 @@ description = "Rust-native MCP server for Infrastructure Automation system" edition.workspace = true keywords = ["mcp", "rust", "infrastructure", "provisioning", "ai"] license.workspace = true -name = "mcp-server" +name = "provisioning-mcp" repository.workspace = true version.workspace = true @@ -13,6 +13,10 @@ version.workspace = true name = "provisioning-mcp-server" path = "src/simple_main.rs" +[[bin]] +name = "prov-mcp" +path = "src/bin/prov_mcp.rs" + [dependencies] # ============================================================================ # WORKSPACE DEPENDENCIES @@ -30,7 +34,7 @@ toml = { workspace = true } platform-config = { workspace = true } # Centralized observability (logging, metrics, health, tracing) -observability = { workspace = true, features = ["logging", "metrics-prometheus", "health"] } +platform-observability = { workspace = true, features = ["logging", "metrics-prometheus", "health"] } # Error handling anyhow = { workspace = true } @@ -70,7 +74,11 @@ walkdir = { workspace = true } # rust-mcp-sdk = "0.7.0" # RAG System (from provisioning-rag crate) -rag = { path = "../rag", features = [] } +platform-rag = { path = "../rag", features = [] } + +# provisioning-core — Registry-backed MCP server (D1) +provisioning-core = { path = "../provisioning-core" } +async-trait = { workspace = true } # Date/time utilities chrono = { workspace = true } diff --git a/crates/mcp-server/src/bin/prov_mcp.rs b/crates/mcp-server/src/bin/prov_mcp.rs new file mode 100644 index 0000000..1845d72 --- /dev/null +++ b/crates/mcp-server/src/bin/prov_mcp.rs @@ -0,0 +1,48 @@ +//! `prov-mcp` — MCP JSON-RPC 2.0 server backed by the provisioning-core Registry. +//! +//! Reads `ORCHESTRATOR_URL`, `PROVISIONING_WORKSPACES_ROOT` from the environment. +//! Protocol transport: newline-delimited JSON on stdin/stdout. + +use anyhow::Result; +use provisioning_core::{ + Environment, + providers::hcloud::HcloudClient, + sources::{NclCache, OrchestratorClient}, + Registry, +}; +use provisioning_mcp_server::registry_server::McpServer; +use std::sync::Arc; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive(tracing::Level::WARN.into()), + ) + .with_writer(std::io::stderr) + .init(); + + let orchestrator_url = std::env::var("ORCHESTRATOR_URL") + .unwrap_or_else(|_| "http://localhost:9011".into()); + + let cache = Arc::new(NclCache::new()); + let orch = Arc::new(OrchestratorClient::new(&orchestrator_url)); + let hcloud = Arc::new(HcloudClient::new()); + let registry = Registry::with_all_tools(cache, orch, hcloud, None)?; + + let mut env = Environment::default(); + env.orchestrator_url = orchestrator_url; + if let Ok(root) = std::env::var("PROVISIONING_WORKSPACES_ROOT") { + env.workspaces_root = root.into(); + } + + let server = McpServer::new( + registry, + env, + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_VERSION"), + ); + + server.run_stdio().await +} diff --git a/crates/mcp-server/src/lib.rs b/crates/mcp-server/src/lib.rs index b200853..4ad8ef8 100644 --- a/crates/mcp-server/src/lib.rs +++ b/crates/mcp-server/src/lib.rs @@ -20,6 +20,7 @@ pub mod config; pub mod errors; pub mod performance_test; pub mod provisioning; +pub mod registry_server; pub mod tools; pub use config::Config; diff --git a/crates/mcp-server/src/registry_server.rs b/crates/mcp-server/src/registry_server.rs new file mode 100644 index 0000000..f0a34cb --- /dev/null +++ b/crates/mcp-server/src/registry_server.rs @@ -0,0 +1,348 @@ +//! MCP JSON-RPC 2.0 server backed by `provisioning-core::Registry`. +//! +//! Transport: newline-delimited JSON on stdin/stdout (same as the legacy +//! `simple_main.rs`). Each line is one JSON-RPC 2.0 message. +//! +//! Supported methods: +//! initialize — MCP handshake → server capabilities +//! tools/list — enumerate all registered tools +//! tools/call — invoke a tool by name +//! ping — no-op health probe +//! +//! Notifications (no `id` field) are silently dropped. + +use provisioning_core::{Environment, Registry, ToolError, tool::Context}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::io::{BufRead, Write}; +use std::sync::Arc; + +const PROTOCOL_VERSION: &str = "2024-11-05"; + +// ── JSON-RPC types ──────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct RpcRequest { + #[allow(dead_code)] + jsonrpc: Option, + id: Option, + method: String, + params: Option, +} + +#[derive(Debug, Serialize)] +struct RpcResponse { + jsonrpc: &'static str, + id: Value, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Debug, Serialize)] +struct RpcError { + code: i32, + message: String, +} + +impl RpcResponse { + fn ok(id: Value, result: Value) -> Self { + Self { jsonrpc: "2.0", id, result: Some(result), error: None } + } + + fn err(id: Value, code: i32, message: impl Into) -> Self { + Self { + jsonrpc: "2.0", + id, + result: None, + error: Some(RpcError { code, message: message.into() }), + } + } +} + +// ── McpServer ───────────────────────────────────────────────────────────────── + +pub struct McpServer { + registry: Arc, + env: Arc, + pub server_name: String, + pub server_version: String, +} + +impl McpServer { + pub fn new( + registry: Registry, + env: Environment, + server_name: impl Into, + server_version: impl Into, + ) -> Self { + Self { + registry: Arc::new(registry), + env: Arc::new(env), + server_name: server_name.into(), + server_version: server_version.into(), + } + } + + /// Run the server loop on stdin/stdout until EOF. + pub async fn run_stdio(&self) -> anyhow::Result<()> { + let stdin = std::io::stdin(); + let mut stdout = std::io::stdout(); + + for line in stdin.lock().lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + + let req: RpcRequest = match serde_json::from_str(&line) { + Ok(r) => r, + Err(e) => { + let resp = RpcResponse::err(Value::Null, -32700, format!("parse error: {e}")); + writeln!(stdout, "{}", serde_json::to_string(&resp)?)?; + stdout.flush()?; + continue; + } + }; + + // Notifications (no id) → silently drop + if req.id.is_none() { + continue; + } + + let id = req.id.unwrap(); + let resp = self.dispatch(id, &req.method, req.params).await; + writeln!(stdout, "{}", serde_json::to_string(&resp)?)?; + stdout.flush()?; + } + + Ok(()) + } + + /// In-process entry point for contract tests — accepts a raw JSON-RPC + /// envelope, returns the response as a `Value`. No stdio involvement. + /// + /// Used by the G3 contract test to invoke the MCP surface without spawning + /// a subprocess or touching stdin/stdout. + pub async fn handle_request(&self, request: Value) -> Value { + let req: RpcRequest = match serde_json::from_value(request.clone()) { + Ok(r) => r, + Err(e) => { + let resp = RpcResponse::err(Value::Null, -32700, format!("parse error: {e}")); + return serde_json::to_value(resp).unwrap_or(Value::Null); + } + }; + let id = req.id.unwrap_or(Value::Null); + let resp = self.dispatch(id, &req.method, req.params).await; + serde_json::to_value(resp).unwrap_or(Value::Null) + } + + async fn dispatch(&self, id: Value, method: &str, params: Option) -> RpcResponse { + match method { + "initialize" => self.handle_initialize(id), + "tools/list" => self.handle_tools_list(id), + "tools/call" => self.handle_tools_call(id, params).await, + "ping" => RpcResponse::ok(id, json!({})), + other => RpcResponse::err(id, -32601, format!("method not found: {other}")), + } + } + + fn handle_initialize(&self, id: Value) -> RpcResponse { + RpcResponse::ok(id, json!({ + "protocolVersion": PROTOCOL_VERSION, + "capabilities": { + "tools": { "listChanged": false } + }, + "serverInfo": { + "name": self.server_name, + "version": self.server_version, + } + })) + } + + fn handle_tools_list(&self, id: Value) -> RpcResponse { + let tools: Vec = self + .registry + .list() + .into_iter() + .map(|m| json!({ + "name": m.name, + "description": m.description, + "inputSchema": *m.schema, + })) + .collect(); + + RpcResponse::ok(id, json!({ "tools": tools })) + } + + async fn handle_tools_call(&self, id: Value, params: Option) -> RpcResponse { + let params = params.unwrap_or(Value::Null); + let name = match params["name"].as_str() { + Some(n) => n, + None => return RpcResponse::err(id, -32602, "params.name is required"), + }; + let args = params["arguments"].clone(); + + let ctx = Context::new(Arc::clone(&self.env)); + match self.registry.invoke(name, args, &ctx).await { + Ok(result) => { + let text = serde_json::to_string_pretty(&result) + .unwrap_or_else(|_| result.to_string()); + RpcResponse::ok(id, json!({ + "content": [{ "type": "text", "text": text }] + })) + } + Err(e) => { + let (code, msg) = tool_error_to_rpc(&e); + RpcResponse::err(id, code, msg) + } + } + } +} + +// ── Error mapping ───────────────────────────────────────────────────────────── + +fn tool_error_to_rpc(e: &ToolError) -> (i32, String) { + match e { + ToolError::NotFound(_) => (-32001, e.to_string()), + ToolError::InvalidParam { .. } => (-32602, e.to_string()), + ToolError::Unauthorized(_) => (-32003, e.to_string()), + _ => (-32000, e.to_string()), + } +} + +// ── Tool catalog helper (used by D2 contract test) ──────────────────────────── + +/// Generate the `tools/list` result payload without running the server loop. +pub fn tools_list_payload(registry: &Registry) -> Value { + let tools: Vec = registry + .list() + .into_iter() + .map(|m| json!({ + "name": m.name, + "description": m.description, + "inputSchema": *m.schema, + })) + .collect(); + json!({ "tools": tools }) +} + +#[cfg(test)] +mod tests { + use super::*; + use provisioning_core::{ + sources::{NclCache, OrchestratorClient}, + providers::hcloud::HcloudClient, + }; + + fn make_registry() -> Registry { + let cache = Arc::new(NclCache::new()); + let orch = Arc::new(OrchestratorClient::new("http://localhost:19999")); + let hcloud = Arc::new(HcloudClient::new()); + Registry::with_all_tools(cache, orch, hcloud, None).unwrap() + } + + #[test] + fn tools_list_payload_contains_all_registry_tools() { + let reg = make_registry(); + let expected_count = reg.len(); + let payload = tools_list_payload(®); + + let tools = payload["tools"].as_array().expect("tools must be an array"); + assert_eq!( + tools.len(), + expected_count, + "MCP tools/list count ({}) != registry count ({})", + tools.len(), + expected_count + ); + } + + #[test] + fn tools_list_each_tool_has_name_description_schema() { + let reg = make_registry(); + let payload = tools_list_payload(®); + let tools = payload["tools"].as_array().unwrap(); + + for tool in tools { + assert!(tool["name"].is_string(), "tool missing name: {tool}"); + assert!(tool["description"].is_string(), "tool missing description: {tool}"); + assert_eq!(tool["inputSchema"]["type"], "object", "tool schema must be object: {}", tool["name"]); + } + } + + #[test] + fn tools_list_names_match_registry_names() { + let reg = make_registry(); + let payload = tools_list_payload(®); + let tools = payload["tools"].as_array().unwrap(); + + let registry_names: std::collections::HashSet<&str> = + reg.list().iter().map(|m| m.name).collect(); + let mcp_names: std::collections::HashSet<&str> = tools + .iter() + .filter_map(|t| t["name"].as_str()) + .collect(); + + assert_eq!(registry_names, mcp_names, "MCP names differ from registry names"); + } + + #[test] + fn handle_initialize_returns_protocol_version() { + let reg = make_registry(); + let server = McpServer::new( + reg, + Environment::default(), + "test-server", + "1.0.0", + ); + let resp = server.handle_initialize(json!(1)); + assert_eq!(resp.result.unwrap()["protocolVersion"], PROTOCOL_VERSION); + } + + #[tokio::test] + async fn handle_tools_call_unknown_tool_returns_not_found_error() { + let reg = make_registry(); + let server = McpServer::new(reg, Environment::default(), "test", "0.0.1"); + let resp = server + .dispatch(json!(1), "tools/call", Some(json!({"name": "no_such_tool", "arguments": {}}))) + .await; + let err = resp.error.unwrap(); + assert_eq!(err.code, -32001); + } + + #[tokio::test] + async fn handle_tools_call_valid_tool_returns_content() { + let reg = make_registry(); + let server = McpServer::new(reg, Environment::default(), "test", "0.0.1"); + let resp = server + .dispatch( + json!(1), + "tools/call", + Some(json!({"name": "installer_settings_defaults", "arguments": {"mode": "solo"}})), + ) + .await; + assert!(resp.error.is_none(), "unexpected error: {:?}", resp.error); + let content = resp.result.unwrap(); + assert!(content["content"].is_array()); + assert_eq!(content["content"][0]["type"], "text"); + } + + #[tokio::test] + async fn ping_returns_empty_object() { + let reg = make_registry(); + let server = McpServer::new(reg, Environment::default(), "test", "0.0.1"); + let resp = server.dispatch(json!(99), "ping", None).await; + assert!(resp.error.is_none()); + assert_eq!(resp.result.unwrap(), json!({})); + } + + #[tokio::test] + async fn unknown_method_returns_method_not_found() { + let reg = make_registry(); + let server = McpServer::new(reg, Environment::default(), "test", "0.0.1"); + let resp = server.dispatch(json!(1), "nonexistent/method", None).await; + assert_eq!(resp.error.unwrap().code, -32601); + } +} diff --git a/crates/mcp-server/src/simple_main.rs b/crates/mcp-server/src/simple_main.rs index 90d9d0e..992b685 100644 --- a/crates/mcp-server/src/simple_main.rs +++ b/crates/mcp-server/src/simple_main.rs @@ -209,16 +209,27 @@ impl McpServer { )) } - "provision_deploy_taskserv" => { - let svc = req_str!("service_name"); - let infra = req_str!("infra_name"); + "provision_component_op" => { + let component = req_str!("component"); + let operation = req_str!("operation"); + let workspace = req_str!("workspace"); let check = args .get("check_mode") .and_then(|v| v.as_bool()) - .unwrap_or(true); + .unwrap_or(false); Ok(format!( - "Service '{}' deployment to '{}' (check_mode={}) queued.", - svc, infra, check + "Component '{}' op='{}' workspace='{}' check_mode={} — submitted to orchestrator /api/v1/workflows/component/{}.", + component, operation, workspace, check, operation + )) + } + + "provision_deploy_taskserv" => { + // Deprecated — directs callers to provision_component_op. + let svc = req_str!("service_name"); + Ok(format!( + "[DEPRECATED] provision_deploy_taskserv is superseded by provision_component_op.\n\ + Use: provision_component_op {{ component: \"{}\", operation: \"install\", workspace: \"\" }}", + svc )) } @@ -786,12 +797,21 @@ impl McpServer { "infra_name": {"type": "string"}, "output_format": {"type": "string", "enum": ["human", "json", "yaml"], "default": "human"} }, "required": ["query"] } }, + { "name": "provision_component_op", + "description": "Unified component lifecycle operation — install, delete, update, reinstall, restart, backup, restore, or check-updates", + "inputSchema": { "type": "object", "properties": { + "component": {"type": "string", "description": "Component name (e.g. postgresql, forgejo)"}, + "operation": {"type": "string", "enum": ["install","delete","update","reinstall","restart","backup","restore","check-updates"]}, + "workspace": {"type": "string", "description": "Workspace name or absolute path"}, + "infra": {"type": "string", "description": "Infra subdirectory (auto-detected if omitted)"}, + "server": {"type": "string", "description": "Target server hostname"}, + "check_mode": {"type": "boolean", "default": false} + }, "required": ["component", "operation", "workspace"] } }, { "name": "provision_deploy_taskserv", - "description": "Deploy specific infrastructure services", + "description": "[DEPRECATED — use provision_component_op] Deploy specific infrastructure services", "inputSchema": { "type": "object", "properties": { "service_name": {"type": "string"}, "infra_name": {"type": "string"}, - "configuration": {"type": "object"}, "check_mode": {"type": "boolean", "default": true} }, "required": ["service_name", "infra_name"] } }, { "name": "provision_cluster_create", diff --git a/crates/ncl-sync/Cargo.toml b/crates/ncl-sync/Cargo.toml new file mode 100644 index 0000000..c5ee588 --- /dev/null +++ b/crates/ncl-sync/Cargo.toml @@ -0,0 +1,37 @@ +[package] +authors.workspace = true +edition.workspace = true +license.workspace = true +name = "ncl-sync" +repository.workspace = true +version.workspace = true +description = "Nickel configuration sync daemon — compiles NCL to JSON, keeps cache fresh for Nu processes" + +[[bin]] +name = "provisioning-ncl-sync" +path = "src/main.rs" + +[features] +default = ["nats"] +nats = ["dep:platform-nats", "dep:async-nats", "dep:bytes"] + +[dependencies] +tokio = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +clap = { workspace = true } +notify = { workspace = true } +sha2 = { workspace = true } +walkdir = { workspace = true } +dirs = { workspace = true } +futures = { workspace = true } +platform-config = { workspace = true } + +# NATS subscriber (optional — enables event-driven cache invalidation) +platform-nats = { workspace = true, optional = true } +async-nats = { workspace = true, optional = true } +bytes = { workspace = true, optional = true } diff --git a/crates/ncl-sync/README.md b/crates/ncl-sync/README.md new file mode 100644 index 0000000..0b01596 --- /dev/null +++ b/crates/ncl-sync/README.md @@ -0,0 +1,263 @@ +# ncl-sync + +Nickel configuration sync daemon for the provisioning platform. + +Watches workspace NCL source files, compiles them to JSON via `nickel export`, and maintains a shared file-based cache used by all Nu processes and the `nu_plugin_nickel` plugin. + +--- + +## What Problem It Solves + +Every `prvng` command that reads configuration calls `nickel export --format json` — 124 call sites across the Nu codebase, each taking 2–5 seconds. With ncl-sync, those exports happen once (proactively, in the background) and subsequent calls read from a JSON file in ~3 ms. + +| Command | Without ncl-sync | With warm cache | +|---------|-----------------|-----------------| +| `prvng component list` | 3–7 s | ~1.5 s | +| `prvng workflow list` | 3–5 s | ~1.5 s | +| `prvng deploy` | 15–30 s | ~3–5 s | +| `prvng component show` (multi-export) | 12–30 s | ~1.5 s | + +The 1.5 s floor is Nu module parse startup — a separate problem. + +--- + +## Architecture + +### Workspace-local cache (single writer) + +``` +ncl-sync daemon (--workspace /ws/libre-daoshi) + │ + ├── notify watcher → NCL file changed + │ ↓ + ├── nickel export --format json $path --import-path ... + │ ↓ + └── write /ws/libre-daoshi/.ncl-cache/.json ← atomic (tmp → rename) + +nu_plugin_nickel (nickel-eval command) + │ + ├── compute_cache_key(file_content, sorted_imports, "json") + │ ↓ + ├── resolve cache dir: + │ 1. $NCL_CACHE_DIR (explicit override) + │ 2. walk up from CWD → /.ncl-cache/ (workspace-local) + │ 3. ~/.cache/provisioning/config-cache/ (global fallback) + │ ↓ + ├── does /.json exist? + │ ├── yes → open file, return Nu record (~3 ms) + │ └── no → nickel export, write to cache, return Nu record (~100 ms) + │ + └── Nu script receives typed record — no | from json needed + +Nu processes (after C1/C2/C3 migration) + │ + └── ncl-eval $path [$ws $prov] → nickel-eval plugin → cache lookup +``` + +### Cache location policy + +The cache lives **inside the workspace** by default. Three resolution levels, in order: + +| Priority | Source | When | +|----------|--------|------| +| 1 | `$NCL_CACHE_DIR` env var | Explicit override (CI, tests, custom setups) | +| 2 | `/.ncl-cache/` | Workspace is active (primary case) | +| 3 | `~/.cache/provisioning/config-cache/` | No workspace — shouldn't happen in normal use | + +**Why workspace-local:** +- Cache is a derived artifact of the workspace — deleting the workspace deletes the cache +- No cross-workspace pollution when the same NCL filename exists in two workspaces with different import paths +- Per-workspace disk usage is easy to measure +- `.ncl-cache/` is gitignored (derived, regenerable) + +**Three components stay aligned on the resolution order:** +- `ncl-sync/src/manifest.rs::resolve_cache_dir()` +- `nu_plugin_nickel/src/helpers.rs::get_cache_dir()` +- `lib_provisioning/config/cache/core.nu::get-cache-base-dir` + +### Key derivation (shared between daemon and plugin) + +Both ncl-sync and `nu_plugin_nickel` compute the cache key identically: + +``` +key = SHA256(file_content + sorted_import_paths.join(":") + format) +``` + +- **Content-based**: same file at a different path → same key. Move/rename don't invalidate the cache. +- **Import-path-aware**: same file with different import paths → different key. Correct when schemas resolve differently. +- **Sort-stable**: `--import-path [$a $b]` and `--import-path [$b $a]` produce the same key. + +Verify parity between the two implementations: + +```bash +nu tests/cache/test_key_parity.nu +``` + +### Manifest + +The daemon maintains `/manifest.json` — a map of `canonical_path → { cache_key, source_mtime, import_paths, cached_at }`. Used to detect stale entries on warm-up and after file changes. Each workspace has its own manifest at `/.ncl-cache/manifest.json`. + +### Sync requests + +After a mutating operation, Nu writes `.sync-.json` (atomic rename from `.sync-.tmp`) to the cache dir. The daemon drains these sidecars every 500 ms: + +```nushell +# Nu side (lib_provisioning/config/cache/core.nu) +write-sync-request [{ path: $file, import_paths: [$ws $prov] }] +``` + +--- + +## CLI + +``` +ncl-sync + +Subcommands: + daemon Start background daemon (watch + warm + serve sync requests) + warm One-shot: export all NCL in workspace, then exit + invalidate Remove a single file from cache + key Print cache key for (file, import_paths, format) + stats Print cache statistics +``` + +### daemon + +```bash +ncl-sync daemon [--workspace PATH] [--idle-timeout SECS] +``` + +- Reads config from `PROVISIONING_CONFIG_DIR/ncl-sync.ncl` (via `platform-config`) +- Cache dir: `/.ncl-cache/` (or `$NCL_CACHE_DIR` if set) +- Writes PID to `/ncl-sync.pid` +- Auto-shuts down after `idle_timeout_secs` (default 600) with no file events or sync requests +- Env override: `NCL_SYNC_LOG=debug ncl-sync daemon` for verbose output + +### warm + +```bash +ncl-sync warm /path/to/workspace +``` + +Exports all `.ncl` files under the workspace path, up to `warm_concurrency` parallel processes. Updates manifest. Exits when done. + +```bash +# Warm libre-daoshi before a work session +ncl-sync warm ~/workspaces/libre-daoshi +``` + +### invalidate + +```bash +ncl-sync invalidate /path/to/file.ncl [--workspace PATH] +``` + +Removes the manifest entry and the corresponding `.json` cache file. `--workspace` resolves the cache location; if omitted, walks up from CWD looking for a workspace root. + +### key + +```bash +ncl-sync key /path/to/file.ncl [--import-path PATH]... +``` + +Prints the SHA256 key. Use to verify daemon–plugin parity: + +```bash +sync_key=$(ncl-sync key settings.ncl --import-path $ws --import-path $prov) +nu_key=$(nu -c "use nu_plugin_nickel; nickel-cache-key settings.ncl --import-path [$ws $prov]") +[ "$sync_key" = "$nu_key" ] && echo "parity OK" || echo "PARITY MISMATCH" +``` + +### stats + +```bash +ncl-sync stats [--workspace PATH] +# entries: 47 +# stale: 0 +# cache_dir: /Users/me/workspaces/libre-daoshi/.ncl-cache/ +# size: 2.3 MB +``` + +`--workspace` selects which workspace's cache to inspect; if omitted, walks up from CWD. + +--- + +## Configuration + +Config file: `provisioning/platform/config/ncl-sync.ncl` + +Deploy to `PROVISIONING_CONFIG_DIR` (macOS: `~/Library/Application Support/provisioning/platform/`): + +```bash +cp provisioning/platform/config/ncl-sync.ncl \ + ~/Library/Application\ Support/provisioning/platform/ncl-sync.ncl +``` + +| Field | Default | Description | +|-------|---------|-------------| +| `cache_dir` | `/.ncl-cache` | Override cache location (absolute path; bypasses workspace resolution) | +| `idle_timeout_secs` | `600` | Daemon auto-shutdown after N seconds idle | +| `sync_poll_interval_ms` | `500` | How often to check for `.sync-*.json` sidecars | +| `warm_concurrency` | `4` | Max parallel `nickel export` during warm-up | +| `extra_import_paths` | `[]` | Additional import paths beyond workspace + `$PROVISIONING` | + +Env overrides: `NCL_CACHE_DIR` (path), `NCL_SYNC_IDLE_TIMEOUT`, `NCL_SYNC_CONCURRENCY`. + +--- + +## Integration with nu_plugin_nickel + +The daemon and plugin are complementary: + +| Concern | ncl-sync | nu_plugin_nickel | +|---------|----------|-----------------| +| Cache writer | ✅ primary | ✅ on miss | +| Cache reader | — | ✅ always | +| File watching | ✅ | — | +| Proactive warm-up | ✅ | — | +| Nu value conversion | — | ✅ | +| Fallback on no daemon | — | ✅ (runs nickel directly) | + +**Without ncl-sync running**: `nickel-eval` still works — it runs `nickel export` on miss and caches the result itself. Performance degrades to ~100 ms per call (first invocation per file per session). + +**With ncl-sync running**: `nickel-eval` hits the daemon-written cache. First call of the session is already ~3 ms because the daemon warmed the cache on `prvng platform start`. + +--- + +## Platform Lifecycle + +ncl-sync is managed by the platform service layer: + +```nushell +# service-manager.nu +prvng platform start # → ncl-sync-start → nohup ncl-sync daemon & +prvng platform stop # → ncl-sync-stop → kill $(cat ncl-sync.pid) +prvng platform status # → ncl-sync-status → { running, pid } +``` + +To start manually: + +```bash +ncl-sync daemon --workspace ~/workspaces/libre-daoshi & +``` + +--- + +## Build & Install + +```bash +cd provisioning/platform +cargo build --release --package ncl-sync +install -m 0755 target/release/provisioning-ncl-sync ~/.local/bin/provisioning-ncl-sync +``` + +--- + +## Design Constraints + +- **No platform service dependencies**: no NATS, no SurrealDB, no orchestrator. The daemon configures those services — it cannot depend on them. +- **Single writer**: only ncl-sync writes `.json` cache files. Nu processes write only `.sync-*.json` sidecar signals. +- **Cache miss is safe**: if the daemon is down, `nickel-eval` falls back to direct `nickel export`. No hard dependency. +- **No HTTP/socket**: reads happen via the filesystem. Zero latency overhead, zero coupling. + +See [ADR-022](../../adrs/adr-022-ncl-sync-daemon.ncl) for full design rationale. diff --git a/crates/ncl-sync/src/config.rs b/crates/ncl-sync/src/config.rs new file mode 100644 index 0000000..8470fab --- /dev/null +++ b/crates/ncl-sync/src/config.rs @@ -0,0 +1,150 @@ +use platform_config::ConfigLoader; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Inner settings — matches the `ncl_sync` key in the NCL export. +#[derive(Debug, Serialize, Deserialize)] +pub struct NclSyncSettings { + #[serde(default)] + pub cache_dir: Option, + + #[serde(default = "default_idle_timeout")] + pub idle_timeout_secs: u64, + + #[serde(default = "default_sync_poll_ms")] + pub sync_poll_interval_ms: u64, + + #[serde(default = "default_concurrency")] + pub warm_concurrency: usize, + + #[serde(default)] + pub extra_import_paths: Vec, + + /// Additional directories to warm alongside the primary workspace. + /// Useful for `$PROVISIONING/extensions/` and other shared NCL trees that live + /// outside the active workspace but are frequently read by commands. + /// Default: empty. Env `NCL_SYNC_EXTRA_WARM` (colon-separated) or config value. + /// `~` is expanded. + #[serde(default)] + pub extra_warm_paths: Vec, + + /// Filename suffixes that identify non-exportable NCL files (schemas, contracts, lib files). + /// Files matching these suffixes are skipped during warm-up and watcher events. + /// Default: `["-schema.ncl", "-defaults.ncl", "-constraints.ncl"]` + #[serde(default = "default_skip_patterns")] + pub skip_patterns: Vec, + + /// Directory basenames that indicate non-exportable NCL files. + /// Files in any parent directory whose basename matches are skipped. + /// Default: `["schemas", "defaults", "constraints"]` + #[serde(default = "default_skip_dirs")] + pub skip_dirs: Vec, + + /// NATS-driven event subscriber (opt-in, requires `nats` Cargo feature). + #[serde(default)] + pub nats: NclSyncNatsSettings, +} + +/// NATS subscription settings for event-driven cache invalidation. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct NclSyncNatsSettings { + /// When true, connect to NATS and subscribe to `provisioning.workspace.ncl.*`. + #[serde(default)] + pub enabled: bool, + /// NATS URL. Empty → uses platform-nats default (`nats://127.0.0.1:4222`). + #[serde(default)] + pub url: String, +} + +impl Default for NclSyncSettings { + fn default() -> Self { + Self { + cache_dir: None, + idle_timeout_secs: default_idle_timeout(), + sync_poll_interval_ms: default_sync_poll_ms(), + warm_concurrency: default_concurrency(), + extra_import_paths: Vec::new(), + extra_warm_paths: Vec::new(), + skip_patterns: default_skip_patterns(), + skip_dirs: default_skip_dirs(), + nats: NclSyncNatsSettings::default(), + } + } +} + +/// Root config struct — outer key `ncl_sync` mirrors the NCL file shape. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct NclSyncConfig { + #[serde(default)] + pub ncl_sync: NclSyncSettings, +} + +impl NclSyncConfig { + pub fn load_or_default() -> Self { + Self::load().unwrap_or_default() + } + + pub fn settings(&self) -> &NclSyncSettings { + &self.ncl_sync + } + + /// Resolve cache dir. Priority: + /// 1. Explicit `cache_dir` in config file + /// 2. Workspace-local (`/.ncl-cache/`) if workspace provided + /// 3. Global fallback (`~/.cache/provisioning/config-cache/`) + pub fn resolved_cache_dir(&self, workspace: Option<&std::path::Path>) -> PathBuf { + if let Some(dir) = &self.ncl_sync.cache_dir { + return PathBuf::from(dir); + } + crate::manifest::resolve_cache_dir(workspace) + } +} + +impl ConfigLoader for NclSyncConfig { + fn service_name() -> &'static str { + "ncl-sync" + } + + fn collect_env_overrides() -> serde_json::Value { + let mut inner = serde_json::Map::new(); + + if let Ok(v) = std::env::var("NCL_SYNC_DIR") { + inner.insert("cache_dir".into(), serde_json::Value::String(v)); + } + if let Ok(v) = std::env::var("NCL_SYNC_IDLE_TIMEOUT") { + if let Ok(n) = v.parse::() { + inner.insert("idle_timeout_secs".into(), serde_json::Value::Number(n.into())); + } + } + if let Ok(v) = std::env::var("NCL_SYNC_CONCURRENCY") { + if let Ok(n) = v.parse::() { + inner.insert("warm_concurrency".into(), serde_json::Value::Number(n.into())); + } + } + + // When no env overrides are set, return Null to skip the merge path entirely. + // Returning {"ncl_sync": {}} triggers a Nickel merge conflict with the contract- + // annotated record in the NCL config file. + if inner.is_empty() { + return serde_json::Value::Null; + } + + let mut root = serde_json::Map::new(); + root.insert("ncl_sync".into(), serde_json::Value::Object(inner)); + serde_json::Value::Object(root) + } +} + +fn default_idle_timeout() -> u64 { 600 } +fn default_sync_poll_ms() -> u64 { 500 } +fn default_concurrency() -> usize { 4 } +fn default_skip_patterns() -> Vec { + vec![ + "-schema.ncl".to_string(), + "-defaults.ncl".to_string(), + "-constraints.ncl".to_string(), + ] +} +fn default_skip_dirs() -> Vec { + vec!["schemas".to_string(), "defaults".to_string(), "constraints".to_string()] +} diff --git a/crates/ncl-sync/src/exporter.rs b/crates/ncl-sync/src/exporter.rs new file mode 100644 index 0000000..50082ab --- /dev/null +++ b/crates/ncl-sync/src/exporter.rs @@ -0,0 +1,288 @@ +use crate::manifest::{derive_cache_key, Manifest}; +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; +use tokio::process::Command; +use tracing::{debug, info, warn}; + +pub struct ExportRequest { + pub path: PathBuf, + /// Import paths passed to `nickel export --import-path`. + pub import_paths: Vec, +} + +/// Export a single NCL file and write raw JSON to the cache dir. +/// Skips if the manifest shows the file is still fresh (mtime unchanged). +pub async fn export_ncl( + req: &ExportRequest, + cache_dir: &Path, + manifest: &mut Manifest, +) -> Result<()> { + let canonical = req + .path + .canonicalize() + .with_context(|| format!("canonicalize {}", req.path.display()))?; + + if !manifest.is_stale(&canonical) { + debug!("cache fresh: {}", canonical.display()); + return Ok(()); + } + + info!("exporting: {}", canonical.display()); + + let cache_key = derive_cache_key(&canonical, &req.import_paths, "json")?; + + let mut cmd = Command::new("nickel"); + cmd.arg("export").arg("--format").arg("json"); + for p in &req.import_paths { + cmd.arg("--import-path").arg(p); + } + cmd.arg(&canonical); + + let output = cmd + .output() + .await + .with_context(|| format!("spawn nickel for {}", canonical.display()))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + warn!("nickel export failed {}: {}", canonical.display(), stderr.trim()); + return Err(anyhow::anyhow!( + "nickel export failed for {}: {}", + canonical.display(), + stderr.trim() + )); + } + + std::fs::create_dir_all(cache_dir) + .with_context(|| format!("creating cache dir {}", cache_dir.display()))?; + + let cache_file = cache_dir.join(format!("{}.json", cache_key)); + let tmp = cache_file.with_extension("tmp"); + std::fs::write(&tmp, &output.stdout) + .with_context(|| format!("writing cache tmp {}", tmp.display()))?; + std::fs::rename(&tmp, &cache_file) + .with_context(|| format!("rename to {}", cache_file.display()))?; + + manifest.update(&canonical, &req.import_paths, &cache_key); + debug!("cached: {} → {}", canonical.display(), cache_key); + + Ok(()) +} + +/// Remove a file's cache entry and its JSON output. +pub async fn evict(path: &Path, cache_dir: &Path, manifest: &mut Manifest) { + let canonical = path.canonicalize().unwrap_or_else(|_| path.to_owned()); + + if let Some(entry) = manifest.entry(&canonical) { + let cache_file = cache_dir.join(format!("{}.json", entry.cache_key)); + if let Err(e) = std::fs::remove_file(&cache_file) { + debug!("evict remove {}: {}", cache_file.display(), e); + } + } + + manifest.evict(&canonical); +} + +/// Warm the cache for all NCL files under `workspace_path` up to `concurrency` parallel exports. +/// Files matching `skip_patterns` or under `skip_dirs` are excluded (schemas, defaults, etc.). +pub async fn warm_workspace( + workspace_path: &Path, + cache_dir: &Path, + manifest: &mut Manifest, + import_paths: &[String], + concurrency: usize, + skip_patterns: &[String], + skip_dirs: &[String], +) -> Result { + use std::sync::Arc; + use tokio::sync::Semaphore; + use walkdir::WalkDir; + + let paths: Vec = WalkDir::new(workspace_path) + .follow_links(false) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "ncl")) + .map(|e| e.path().to_owned()) + .filter(|p| !is_skipped(p, skip_patterns, skip_dirs)) + .collect(); + + let total = paths.len(); + let semaphore = Arc::new(Semaphore::new(concurrency.max(1))); + std::fs::create_dir_all(cache_dir)?; + + let mut handles = Vec::with_capacity(total); + + for path in &paths { + let sem = Arc::clone(&semaphore); + let path = path.clone(); + let import_paths = import_paths.to_vec(); + let cache_dir = cache_dir.to_owned(); + + handles.push(tokio::spawn(async move { + let _permit = sem.acquire().await; + run_and_write(&path, &import_paths, &cache_dir).await + })); + } + + let mut count = 0usize; + for (i, handle) in handles.into_iter().enumerate() { + match handle.await { + Ok(Ok((canonical, cache_key))) => { + manifest.update(&canonical, import_paths, &cache_key); + count += 1; + } + Ok(Err(e)) => warn!("warm skip {}: {}", paths[i].display(), e), + Err(e) => warn!("warm task panic {}: {}", paths[i].display(), e), + } + } + + info!("warm-up: {}/{} files cached", count, total); + Ok(count) +} + +/// Run `nickel export`, write raw JSON to cache. Returns (canonical, cache_key). +async fn run_and_write( + path: &Path, + import_paths: &[String], + cache_dir: &Path, +) -> Result<(PathBuf, String)> { + let canonical = path + .canonicalize() + .with_context(|| format!("canonicalize {}", path.display()))?; + + let cache_key = derive_cache_key(&canonical, import_paths, "json")?; + + // Skip if mtime matches what's already on disk for this key + let cache_file = cache_dir.join(format!("{}.json", cache_key)); + if cache_file.exists() { + // File content hasn't changed (same key), no need to re-export + debug!("warm skip (content unchanged): {}", canonical.display()); + return Ok((canonical, cache_key)); + } + + let mut cmd = Command::new("nickel"); + cmd.arg("export").arg("--format").arg("json"); + for p in import_paths { + cmd.arg("--import-path").arg(p); + } + cmd.arg(&canonical); + + let output = cmd + .output() + .await + .with_context(|| format!("spawn nickel for {}", canonical.display()))?; + + if !output.status.success() { + return Err(anyhow::anyhow!( + "nickel export failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + let tmp = cache_file.with_extension("tmp"); + std::fs::write(&tmp, &output.stdout)?; + std::fs::rename(&tmp, &cache_file)?; + + Ok((canonical, cache_key)) +} + +/// Build the import path list for `nickel export` invocations. +/// +/// Matches the ontoref convention — see `reflection/nulib/shared.nu::nickel-import-path`. +/// +/// Priority (duplicates removed, preserving first occurrence): +/// 1. workspace paths: `/.ontology`, `/adrs`, `/.ontoref/ontology/schemas`, +/// `/.ontoref/adrs`, `/.onref`, `` +/// 2. `$PROVISIONING` +/// 3. `$NICKEL_IMPORT_PATH` entries (colon-separated) +/// 4. `$ONTOREF_ROOT` paths: `$ONTOREF_ROOT/ontology`, `/ontology/schemas`, +/// `/reflection`, `/reflection/schemas`, `/adrs`, root +/// 5. explicit `extra_import_paths` from config (with `~` expansion) +pub fn default_import_paths(workspace_path: &Path, extra: &[String]) -> Vec { + let mut paths: Vec = Vec::new(); + let ws = workspace_path.to_path_buf(); + + // Workspace-scoped paths (same order as ontoref) + paths.push(ws.join(".ontology").to_string_lossy().into_owned()); + paths.push(ws.join("adrs").to_string_lossy().into_owned()); + paths.push(ws.join(".ontoref/ontology/schemas").to_string_lossy().into_owned()); + paths.push(ws.join(".ontoref/adrs").to_string_lossy().into_owned()); + paths.push(ws.join(".onref").to_string_lossy().into_owned()); + paths.push(ws.to_string_lossy().into_owned()); + + if let Ok(prov) = std::env::var("PROVISIONING") { + paths.push(prov); + } + + // $NICKEL_IMPORT_PATH — colon-separated, matches bash wrapper convention + if let Ok(nip) = std::env::var("NICKEL_IMPORT_PATH") { + for entry in nip.split(':').filter(|s| !s.is_empty()) { + paths.push(entry.to_string()); + } + } + + // $ONTOREF_ROOT — auto-discover if set or default location exists + let ontoref_root = std::env::var("ONTOREF_ROOT").ok().or_else(|| { + dirs::home_dir() + .map(|h| h.join("Library/Application Support/ontoref")) + .filter(|p| p.exists()) + .map(|p| p.to_string_lossy().into_owned()) + .or_else(|| { + dirs::home_dir() + .map(|h| h.join(".local/share/ontoref")) + .filter(|p| p.exists()) + .map(|p| p.to_string_lossy().into_owned()) + }) + }); + if let Some(root) = ontoref_root { + let root = std::path::PathBuf::from(root); + paths.push(root.join("ontology").to_string_lossy().into_owned()); + paths.push(root.join("ontology/schemas").to_string_lossy().into_owned()); + paths.push(root.join("reflection").to_string_lossy().into_owned()); + paths.push(root.join("reflection/schemas").to_string_lossy().into_owned()); + paths.push(root.join("adrs").to_string_lossy().into_owned()); + paths.push(root.to_string_lossy().into_owned()); + } + + for p in extra { + paths.push(expand_home(p)); + } + + // Keep only existing paths (nickel happily accepts non-existent, but noisier). + // De-duplicate while preserving order. + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + paths.into_iter().filter(|p| seen.insert(p.clone())).collect() +} + +/// Expand `~` and `~/` to `$HOME` in a path string. Leaves other paths untouched. +fn expand_home(path: &str) -> String { + if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest).to_string_lossy().into_owned(); + } + } + if path == "~" { + if let Some(home) = dirs::home_dir() { + return home.to_string_lossy().into_owned(); + } + } + path.to_string() +} + +/// Return true if the file should be skipped (non-exportable library file). +/// Matches filename suffix against `skip_patterns` and any parent dir basename against `skip_dirs`. +pub fn is_skipped(path: &Path, skip_patterns: &[String], skip_dirs: &[String]) -> bool { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if skip_patterns.iter().any(|p| name.ends_with(p)) { + return true; + } + for ancestor in path.ancestors() { + let basename = ancestor.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if skip_dirs.iter().any(|d| d == basename) { + return true; + } + } + false +} + diff --git a/crates/ncl-sync/src/main.rs b/crates/ncl-sync/src/main.rs new file mode 100644 index 0000000..3606cc2 --- /dev/null +++ b/crates/ncl-sync/src/main.rs @@ -0,0 +1,482 @@ +mod config; +mod exporter; +mod manifest; +#[cfg(feature = "nats")] +mod nats_subscriber; +mod sync_request; +mod watcher; + +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use config::NclSyncConfig; +use exporter::{default_import_paths, evict, export_ncl, warm_workspace, ExportRequest}; +use manifest::{derive_cache_key, Manifest}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::time::interval; +use tracing::{error, info}; +use watcher::WatchEvent; + +#[derive(Parser)] +#[command(name = "provisioning-ncl-sync", about = "Nickel configuration sync daemon")] +struct Cli { + #[command(subcommand)] + command: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Start as background daemon: watch NCL files, maintain workspace cache + Daemon { + #[arg(long)] + workspace: Option, + #[arg(long, default_value = "600")] + idle_timeout: u64, + }, + /// One-shot warm-up for all NCL files in a workspace, then exit + Warm { workspace: PathBuf }, + /// Evict a single NCL file from cache + Invalidate { + path: PathBuf, + #[arg(long)] + workspace: Option, + }, + /// Print the cache key for a file — for parity testing with the plugin + Key { + path: PathBuf, + #[arg(long = "import-path", value_name = "PATH")] + import_paths: Vec, + #[arg(long, default_value = "json")] + format: String, + }, + /// Print cache statistics for a workspace + Stats { + #[arg(long)] + workspace: Option, + }, +} + +fn pid_file(cache_dir: &Path) -> PathBuf { + cache_dir.join("ncl-sync.pid") +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Resolve cache_dir from (1) config file override, (2) workspace-local, (3) global. +fn cache_dir_for(cfg: &NclSyncConfig, workspace: Option<&Path>) -> PathBuf { + cfg.resolved_cache_dir(workspace) +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_env("NCL_SYNC_LOG") + .add_directive(tracing::Level::INFO.into()), + ) + .compact() + .init(); + + let cli = Cli::parse(); + let cfg = NclSyncConfig::load_or_default(); + + match cli.command { + Cmd::Daemon { workspace, idle_timeout } => { + let ws = workspace + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + let cache_dir = cache_dir_for(&cfg, Some(&ws)); + std::fs::create_dir_all(&cache_dir)?; + let timeout = if idle_timeout != 600 { + idle_timeout + } else { + cfg.settings().idle_timeout_secs + }; + run_daemon(ws, cache_dir, timeout, cfg).await + } + Cmd::Warm { workspace } => { + let ws_cache = cache_dir_for(&cfg, Some(&workspace)); + std::fs::create_dir_all(&ws_cache)?; + let global_cache = manifest::global_cache_dir(); + std::fs::create_dir_all(&global_cache)?; + let import_paths = default_import_paths(&workspace, &cfg.settings().extra_import_paths); + let extra_warm = resolve_extra_warm_paths(&cfg); + + // Workspace files → workspace cache + let mut ws_mani = Manifest::load(&ws_cache)?; + let ws_count = warm_workspace( + &workspace, + &ws_cache, + &mut ws_mani, + &import_paths, + cfg.settings().warm_concurrency, + &cfg.settings().skip_patterns, + &cfg.settings().skip_dirs, + ) + .await?; + ws_mani.save(&ws_cache)?; + + // Extensions/shared files → global cache (shared across workspaces) + let mut global_count = 0usize; + if !extra_warm.is_empty() { + let mut g_mani = Manifest::load(&global_cache)?; + for extra in &extra_warm { + println!("warming (global): {}", extra.display()); + global_count += warm_workspace( + extra, + &global_cache, + &mut g_mani, + &import_paths, + cfg.settings().warm_concurrency, + &cfg.settings().skip_patterns, + &cfg.settings().skip_dirs, + ) + .await + .unwrap_or(0); + } + g_mani.save(&global_cache)?; + } + + println!( + "warmed {} ws files at {} + {} global files at {}", + ws_count, + ws_cache.display(), + global_count, + global_cache.display() + ); + Ok(()) + } + Cmd::Invalidate { path, workspace } => { + let cache_dir = resolve_with_workspace(&cfg, workspace.as_deref()); + let mut manifest = Manifest::load(&cache_dir)?; + evict(&path, &cache_dir, &mut manifest).await; + manifest.save(&cache_dir)?; + println!("evicted: {}", path.display()); + Ok(()) + } + Cmd::Key { path, import_paths, format } => { + let canonical = path + .canonicalize() + .with_context(|| format!("canonicalize {}", path.display()))?; + println!("{}", derive_cache_key(&canonical, &import_paths, &format)?); + Ok(()) + } + Cmd::Stats { workspace } => { + let cache_dir = resolve_with_workspace(&cfg, workspace.as_deref()); + print_stats(&cache_dir) + } + } +} + +/// Resolve cache dir for subcommands that may be run outside the workspace. +/// Priority: explicit --workspace → walk up from CWD → global fallback. +fn resolve_with_workspace(cfg: &NclSyncConfig, workspace: Option<&Path>) -> PathBuf { + if let Some(ws) = workspace { + return cache_dir_for(cfg, Some(ws)); + } + if let Ok(cwd) = std::env::current_dir() { + if let Some(ws) = find_workspace_up(&cwd) { + return cache_dir_for(cfg, Some(&ws)); + } + } + cache_dir_for(cfg, None) +} + +/// Walk up from `start` until a workspace root is found (has infra/ or .ontology/). +fn find_workspace_up(start: &Path) -> Option { + let mut cur = start.to_path_buf(); + loop { + if cur.join("infra").is_dir() + || cur.join("config/provisioning.ncl").exists() + || cur.join(".ontology").is_dir() + { + return Some(cur); + } + let parent = cur.parent()?.to_path_buf(); + if parent == cur { + return None; + } + cur = parent; + } +} + +async fn run_daemon( + workspace: PathBuf, + cache_dir: PathBuf, + idle_timeout_secs: u64, + cfg: NclSyncConfig, +) -> Result<()> { + let pid = std::process::id(); + std::fs::write(pid_file(&cache_dir), pid.to_string()).context("writing PID file")?; + info!( + "ncl-sync daemon pid={} workspace={} cache={}", + pid, + workspace.display(), + cache_dir.display() + ); + + let import_paths = default_import_paths(&workspace, &cfg.settings().extra_import_paths); + let extra_warm = resolve_extra_warm_paths(&cfg); + let global_cache = manifest::global_cache_dir(); + std::fs::create_dir_all(&global_cache)?; + + info!( + "caches: workspace={} global={}", + cache_dir.display(), + global_cache.display() + ); + + // Warm workspace files → workspace cache + { + let mut ws_manifest = Manifest::load(&cache_dir)?; + warm_workspace( + &workspace, + &cache_dir, + &mut ws_manifest, + &import_paths, + cfg.settings().warm_concurrency, + &cfg.settings().skip_patterns, + &cfg.settings().skip_dirs, + ) + .await?; + ws_manifest.save(&cache_dir)?; + } + + // Warm extension/provisioning files → global cache (shared across workspaces) + if !extra_warm.is_empty() { + let mut global_manifest = Manifest::load(&global_cache)?; + for extra in &extra_warm { + info!("warming (global) {}", extra.display()); + warm_workspace( + extra, + &global_cache, + &mut global_manifest, + &import_paths, + cfg.settings().warm_concurrency, + &cfg.settings().skip_patterns, + &cfg.settings().skip_dirs, + ) + .await + .unwrap_or_else(|e| { + tracing::warn!("extra warm {} failed: {}", extra.display(), e); + 0 + }); + } + global_manifest.save(&global_cache)?; + } + let last_activity = Arc::new(AtomicU64::new(now_secs())); + // Watch workspace + extra warm paths in a single notify watcher. + let mut all_watch_paths = vec![workspace.clone()]; + all_watch_paths.extend(extra_warm.iter().cloned()); + let (_watcher, mut watch_rx) = watcher::start_many(&all_watch_paths)?; + let mut sync_tick = interval(Duration::from_millis(cfg.settings().sync_poll_interval_ms)); + let mut idle_tick = interval(Duration::from_secs(10)); + + // Optional NATS subscriber for event-driven cache invalidation. + #[cfg(feature = "nats")] + start_nats_subscriber(&cfg, &workspace, &cache_dir, &import_paths).await; + + loop { + tokio::select! { + Some(event) = watch_rx.recv() => { + last_activity.store(now_secs(), Ordering::Relaxed); + handle_watch_event( + event, + &workspace, + &import_paths, + &cfg.settings().skip_patterns, + &cfg.settings().skip_dirs, + ).await; + } + _ = sync_tick.tick() => { + // Drain sidecars from BOTH caches — Nu writes to whichever is resolved. + if let Err(e) = sync_request::drain_and_process(&cache_dir, &workspace, &import_paths).await { + error!("sync-request drain (workspace): {}", e); + } + if let Err(e) = sync_request::drain_and_process(&global_cache, &workspace, &import_paths).await { + error!("sync-request drain (global): {}", e); + } + } + _ = idle_tick.tick() => { + if idle_timeout_secs > 0 { + let idle = now_secs().saturating_sub(last_activity.load(Ordering::Relaxed)); + if idle >= idle_timeout_secs { + info!("idle timeout ({}s), shutting down", idle_timeout_secs); + break; + } + } + } + } + } + + let _ = std::fs::remove_file(pid_file(&cache_dir)); + Ok(()) +} + +async fn handle_watch_event( + event: WatchEvent, + workspace: &Path, + import_paths: &[String], + skip_patterns: &[String], + skip_dirs: &[String], +) { + // Drop events for non-exportable files (schemas, defaults, etc.) — same filter as warm-up. + let path = match &event { + WatchEvent::Changed(p) | WatchEvent::Removed(p) => p, + }; + if exporter::is_skipped(path, skip_patterns, skip_dirs) { + tracing::debug!("skip watch event for library file: {}", path.display()); + return; + } + + // Route to correct cache dir based on the file's location (global for extensions, + // workspace-local for workspace files). + let cache_dir = manifest::resolve_cache_dir_for_file(path, Some(workspace)); + + let mut manifest = match Manifest::load(&cache_dir) { + Ok(m) => m, + Err(e) => { + error!("load manifest on watch event: {}", e); + return; + } + }; + + match event { + WatchEvent::Changed(path) => { + let req = ExportRequest { path, import_paths: import_paths.to_vec() }; + if let Err(e) = export_ncl(&req, &cache_dir, &mut manifest).await { + error!("watch export: {}", e); + } + } + WatchEvent::Removed(path) => { + evict(&path, &cache_dir, &mut manifest).await; + } + } + + if let Err(e) = manifest.save(&cache_dir) { + error!("manifest save after watch event: {}", e); + } +} + +fn print_stats(cache_dir: &Path) -> Result<()> { + let manifest = Manifest::load(cache_dir)?; + + let mut total = 0usize; + let mut stale = 0usize; + + for (path_str, entry) in manifest.all_entries() { + total += 1; + let path = PathBuf::from(path_str); + if manifest::file_mtime(&path).unwrap_or(0) != entry.source_mtime { + stale += 1; + } + } + + let size_bytes: u64 = std::fs::read_dir(cache_dir) + .into_iter() + .flatten() + .filter_map(|e| e.ok()) + .filter_map(|e| e.metadata().ok()) + .map(|m| m.len()) + .sum(); + + println!("entries: {total}"); + println!("stale: {stale}"); + println!("cache_dir: {}", cache_dir.display()); + println!("size: {:.1} MB", size_bytes as f64 / 1_048_576.0); + Ok(()) +} + +#[cfg(feature = "nats")] +async fn start_nats_subscriber( + cfg: &NclSyncConfig, + workspace: &Path, + cache_dir: &Path, + import_paths: &[String], +) { + if !cfg.settings().nats.enabled { + return; + } + + let url = if cfg.settings().nats.url.is_empty() { + "nats://127.0.0.1:4222".to_string() + } else { + cfg.settings().nats.url.clone() + }; + + let nats_cfg = platform_nats::NatsConfig { + url: url.clone(), + ..Default::default() + }; + + let bridge = match platform_nats::NatsBridge::connect(&nats_cfg).await { + Ok(b) => b, + Err(e) => { + tracing::warn!("NATS subscriber disabled — connect failed: {}", e); + return; + } + }; + + let sub_cfg = nats_subscriber::SubscriberConfig { + workspace: workspace.to_path_buf(), + cache_dir: cache_dir.to_path_buf(), + default_import_paths: import_paths.to_vec(), + }; + + if let Err(e) = nats_subscriber::spawn(Arc::new(bridge), sub_cfg).await { + tracing::warn!("NATS subscriber start failed: {}", e); + } else { + tracing::info!("NATS subscriber active on {}", url); + } +} + +/// Resolve extra warm paths from config + env. Expands `~` and discovers +/// $PROVISIONING/extensions automatically if it exists and not already listed. +fn resolve_extra_warm_paths(cfg: &NclSyncConfig) -> Vec { + let mut paths: Vec = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + + // Config entries (with ~ expansion) + for p in &cfg.settings().extra_warm_paths { + let expanded = expand_tilde(p); + let pb = PathBuf::from(expanded); + if pb.exists() && seen.insert(pb.clone()) { + paths.push(pb); + } + } + + // Env var NCL_SYNC_EXTRA_WARM (colon-separated) + if let Ok(v) = std::env::var("NCL_SYNC_EXTRA_WARM") { + for entry in v.split(':').filter(|s| !s.is_empty()) { + let pb = PathBuf::from(expand_tilde(entry)); + if pb.exists() && seen.insert(pb.clone()) { + paths.push(pb); + } + } + } + + // Auto-discover $PROVISIONING/extensions — shared read-only catalog, almost always needed + if let Ok(prov) = std::env::var("PROVISIONING") { + let ext = PathBuf::from(prov).join("extensions"); + if ext.exists() && seen.insert(ext.clone()) { + paths.push(ext); + } + } + + paths +} + +fn expand_tilde(s: &str) -> String { + if let Some(rest) = s.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest).to_string_lossy().into_owned(); + } + } + s.to_string() +} diff --git a/crates/ncl-sync/src/manifest.rs b/crates/ncl-sync/src/manifest.rs new file mode 100644 index 0000000..66b8d8c --- /dev/null +++ b/crates/ncl-sync/src/manifest.rs @@ -0,0 +1,178 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManifestEntry { + /// SHA256(file_content + sorted_import_paths + format) — identical to plugin key. + pub cache_key: String, + pub source_mtime: u64, + pub import_paths: Vec, + pub cached_at: u64, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct Manifest { + entries: HashMap, +} + +impl Manifest { + pub fn load(cache_dir: &Path) -> Result { + let path = manifest_path(cache_dir); + if !path.exists() { + return Ok(Self::default()); + } + let content = fs::read_to_string(&path) + .with_context(|| format!("reading manifest {}", path.display()))?; + serde_json::from_str(&content).with_context(|| "parsing manifest JSON") + } + + pub fn save(&self, cache_dir: &Path) -> Result<()> { + let path = manifest_path(cache_dir); + let tmp = path.with_extension("tmp"); + fs::write(&tmp, serde_json::to_string_pretty(self)?) + .with_context(|| format!("writing manifest tmp {}", tmp.display()))?; + fs::rename(&tmp, &path).with_context(|| "renaming manifest") + } + + pub fn is_stale(&self, canonical: &Path) -> bool { + let key = canonical.to_string_lossy().into_owned(); + let Some(entry) = self.entries.get(&key) else { + return true; + }; + file_mtime(canonical).unwrap_or(0) != entry.source_mtime + } + + pub fn update(&mut self, canonical: &Path, import_paths: &[String], cache_key: &str) { + let key = canonical.to_string_lossy().into_owned(); + self.entries.insert( + key, + ManifestEntry { + cache_key: cache_key.to_owned(), + source_mtime: file_mtime(canonical).unwrap_or(0), + import_paths: import_paths.to_vec(), + cached_at: now_secs(), + }, + ); + } + + pub fn evict(&mut self, canonical: &Path) { + self.entries.remove(&canonical.to_string_lossy().into_owned()); + } + + pub fn entry(&self, canonical: &Path) -> Option<&ManifestEntry> { + self.entries.get(&canonical.to_string_lossy().into_owned()) + } + + pub fn all_entries(&self) -> impl Iterator { + self.entries.iter().map(|(k, v)| (k.as_str(), v)) + } + + #[allow(dead_code)] + pub fn stale_paths(&self) -> Vec { + self.entries + .iter() + .filter_map(|(key, entry)| { + let path = PathBuf::from(key); + if file_mtime(&path).unwrap_or(0) != entry.source_mtime { + Some(path) + } else { + None + } + }) + .collect() + } +} + +/// Derive the cache key — must match the plugin's `compute_cache_key` exactly. +/// +/// Key = SHA256(file_content + format). +/// Import paths are NOT part of the key — see plugin's helpers.rs for rationale. +pub fn derive_cache_key(canonical: &Path, _import_paths: &[String], format: &str) -> Result { + let content = fs::read_to_string(canonical) + .with_context(|| format!("reading {} for cache key", canonical.display()))?; + + let mut hasher = Sha256::new(); + hasher.update(content.as_bytes()); + hasher.update(format.as_bytes()); + + Ok(format!("{:x}", hasher.finalize())) +} + +/// Resolve the cache directory FOR A FILE. +/// +/// Two caches coexist: +/// - **Global** (`~/.cache/provisioning/config-cache/`): files under `$PROVISIONING` +/// (extensions, schemas) — shared across workspaces. +/// - **Workspace** (`/.ncl-cache/`): files under the workspace +/// (state, components, settings) — scoped to the workspace. +/// +/// Priority: +/// 1. `$NCL_CACHE_DIR` — explicit override (e.g. CI) +/// 2. If file is under `$PROVISIONING` → global cache +/// 3. If `workspace` is given and file is under it → workspace cache +/// 4. Fallback: global cache +/// +/// Must match `get_cache_dir_for_file()` in the plugin's helpers.rs. +pub fn resolve_cache_dir_for_file(file_path: &Path, workspace: Option<&Path>) -> PathBuf { + if let Ok(dir) = std::env::var("NCL_CACHE_DIR") { + return PathBuf::from(dir); + } + if let Ok(prov) = std::env::var("PROVISIONING") { + let prov = PathBuf::from(prov); + if file_path.starts_with(&prov) { + return global_cache_dir(); + } + } + if let Some(ws) = workspace { + if file_path.starts_with(ws) { + return ws.join(".ncl-cache"); + } + } + global_cache_dir() +} + +/// Cache dir for "generic" operations (stats, invalidate by path) without a file context. +/// Prefers workspace-local if a workspace is given; falls back to global. +pub fn resolve_cache_dir(workspace: Option<&Path>) -> PathBuf { + if let Ok(dir) = std::env::var("NCL_CACHE_DIR") { + return PathBuf::from(dir); + } + if let Some(ws) = workspace { + return ws.join(".ncl-cache"); + } + global_cache_dir() +} + +/// Global cache — shared across workspaces for files under `$PROVISIONING`. +pub fn global_cache_dir() -> PathBuf { + dirs::cache_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("provisioning") + .join("config-cache") +} + +pub fn file_mtime(path: &Path) -> Option { + fs::metadata(path) + .ok()? + .modified() + .ok()? + .duration_since(UNIX_EPOCH) + .ok() + .map(|d| d.as_secs()) +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn manifest_path(cache_dir: &Path) -> PathBuf { + cache_dir.join("manifest.json") +} diff --git a/crates/ncl-sync/src/nats_subscriber.rs b/crates/ncl-sync/src/nats_subscriber.rs new file mode 100644 index 0000000..be3c020 --- /dev/null +++ b/crates/ncl-sync/src/nats_subscriber.rs @@ -0,0 +1,154 @@ +//! Event-driven cache invalidation via NATS. +//! +//! Subscribes to `provisioning.workspace.ncl.*` subjects. When the orchestrator +//! (or any other writer) publishes a file-change event, ncl-sync re-exports +//! the affected NCL file — no filesystem polling, no file-watcher debounce delay. +//! +//! Contract for publishers: +//! +//! Subject: `provisioning.workspace.ncl.changed` | `provisioning.workspace.ncl.removed` +//! +//! Payload: +//! ```json +//! { +//! "workspace": "/path/to/workspace", +//! "path": "/path/to/workspace/infra/.../file.ncl", +//! "import_paths": ["/path/to/workspace", "/provisioning"], +//! "source": "orchestrator" | "cli" | "..." +//! } +//! ``` +//! +//! The subscriber ignores events whose `workspace` does not match the daemon's +//! watched workspace — each workspace runs its own ncl-sync daemon. + +use crate::exporter::{evict, export_ncl, ExportRequest}; +use crate::manifest::Manifest; +use anyhow::Result; +use futures::StreamExt; +use platform_nats::NatsBridge; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::sync::Mutex; +use tracing::{debug, error, info, warn}; + +/// Event payload published by writers (orchestrator, future publishers). +#[derive(Debug, Serialize, Deserialize)] +pub struct NclChangeEvent { + /// Absolute path to the workspace root that contains this file. + pub workspace: String, + /// Absolute path to the NCL file that changed. + pub path: String, + /// Import paths the writer wants ncl-sync to use for re-export (optional). + #[serde(default)] + pub import_paths: Vec, + /// Origin of the event (orchestrator, cli, etc.) — for logging only. + #[serde(default)] + pub source: String, +} + +pub struct SubscriberConfig { + pub workspace: PathBuf, + pub cache_dir: PathBuf, + pub default_import_paths: Vec, +} + +/// Spawn a background task that subscribes to ncl change events. +/// Returns immediately — subscriber runs until the process exits or the bridge disconnects. +pub async fn spawn(bridge: Arc, cfg: SubscriberConfig) -> Result<()> { + let changed = bridge.subscribe("provisioning.workspace.ncl.changed").await?; + let removed = bridge.subscribe("provisioning.workspace.ncl.removed").await?; + + info!( + workspace = %cfg.workspace.display(), + "NATS subscriber started on provisioning.workspace.ncl.{{changed,removed}}" + ); + + let cfg = Arc::new(cfg); + + let cfg_a = Arc::clone(&cfg); + tokio::spawn(handle_subject(changed, cfg_a, SubjectKind::Changed)); + + let cfg_b = Arc::clone(&cfg); + tokio::spawn(handle_subject(removed, cfg_b, SubjectKind::Removed)); + + Ok(()) +} + +#[derive(Clone, Copy)] +enum SubjectKind { + Changed, + Removed, +} + +async fn handle_subject( + mut sub: async_nats::Subscriber, + cfg: Arc, + kind: SubjectKind, +) { + let manifest_lock: Arc> = Arc::new(Mutex::new(())); + + while let Some(msg) = sub.next().await { + let event: NclChangeEvent = match serde_json::from_slice(&msg.payload) { + Ok(e) => e, + Err(e) => { + warn!("parse NATS payload on {}: {}", msg.subject, e); + continue; + } + }; + + if !workspace_matches(&cfg.workspace, &event.workspace) { + debug!( + "skip event for foreign workspace {} (watching {})", + event.workspace, + cfg.workspace.display() + ); + continue; + } + + let path = PathBuf::from(&event.path); + let import_paths = if event.import_paths.is_empty() { + cfg.default_import_paths.clone() + } else { + event.import_paths.clone() + }; + + // Serialize manifest access — concurrent events could race on save(). + let _guard = manifest_lock.lock().await; + let mut manifest = match Manifest::load(&cfg.cache_dir) { + Ok(m) => m, + Err(e) => { + error!("load manifest on NATS event: {}", e); + continue; + } + }; + + match kind { + SubjectKind::Changed => { + let req = ExportRequest { path, import_paths }; + if let Err(e) = export_ncl(&req, &cfg.cache_dir, &mut manifest).await { + error!("NATS-driven export failed for {}: {}", req.path.display(), e); + continue; + } + debug!(source = %event.source, "NATS: cache refreshed"); + } + SubjectKind::Removed => { + evict(&path, &cfg.cache_dir, &mut manifest).await; + debug!(source = %event.source, "NATS: cache evicted"); + } + } + + if let Err(e) = manifest.save(&cfg.cache_dir) { + error!("save manifest after NATS event: {}", e); + } + } + + warn!("NATS subscription ended — reconnect not implemented"); +} + +fn workspace_matches(watched: &Path, event_ws: &str) -> bool { + let event_ws_pb = PathBuf::from(event_ws); + let canon_watched = watched.canonicalize().unwrap_or_else(|_| watched.to_path_buf()); + let canon_event = event_ws_pb.canonicalize().unwrap_or(event_ws_pb); + canon_watched == canon_event +} diff --git a/crates/ncl-sync/src/sync_request.rs b/crates/ncl-sync/src/sync_request.rs new file mode 100644 index 0000000..b3c4dd0 --- /dev/null +++ b/crates/ncl-sync/src/sync_request.rs @@ -0,0 +1,89 @@ +use crate::exporter::{export_ncl, ExportRequest}; +use crate::manifest::{self, Manifest}; +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use tracing::{debug, error, info}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct SyncRequest { + pub path: String, + /// Import paths to use for this file's re-export. If empty, daemon falls back to defaults. + pub import_paths: Vec, +} + +/// Drain sidecars from `cache_dir`, then route each request to the correct cache +/// (global or workspace) based on the file's path. +/// +/// Sidecar files `.sync-.json` may be written by Nu processes in either cache. +/// The daemon is called repeatedly with both dirs during its event loop. +pub async fn drain_and_process( + sidecar_dir: &Path, + workspace: &Path, + default_import_paths: &[String], +) -> Result<()> { + let mut entries = tokio::fs::read_dir(sidecar_dir).await?; + let mut requests: Vec = Vec::new(); + let mut claimed: Vec = Vec::new(); + + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with(".sync-") && name.ends_with(".json") { + let orig = entry.path(); + let processing = sidecar_dir.join(format!(".processing-{}", &name[6..])); + if tokio::fs::rename(&orig, &processing).await.is_ok() { + claimed.push(processing); + } + } + } + + for path in &claimed { + match tokio::fs::read_to_string(path).await { + Ok(content) => match serde_json::from_str::>(&content) { + Ok(reqs) => requests.extend(reqs), + Err(e) => debug!("parse sync-request {}: {}", path.display(), e), + }, + Err(e) => debug!("read sync-request {}: {}", path.display(), e), + } + tokio::fs::remove_file(path).await.ok(); + } + + if requests.is_empty() { + return Ok(()); + } + + info!("processing {} sync request(s)", requests.len()); + + for req in requests { + let import_paths = if req.import_paths.is_empty() { + default_import_paths.to_vec() + } else { + req.import_paths + }; + let file = PathBuf::from(&req.path); + + // Route per-file: extensions → global cache, workspace files → workspace cache. + let target_cache = manifest::resolve_cache_dir_for_file(&file, Some(workspace)); + + let mut mani = match Manifest::load(&target_cache) { + Ok(m) => m, + Err(e) => { + error!("load manifest for {}: {}", target_cache.display(), e); + continue; + } + }; + + let export_req = ExportRequest { path: file, import_paths }; + if let Err(e) = export_ncl(&export_req, &target_cache, &mut mani).await { + error!("sync-request export {}: {}", req.path, e); + continue; + } + + if let Err(e) = mani.save(&target_cache) { + error!("save manifest after sync export: {}", e); + } + } + + Ok(()) +} diff --git a/crates/ncl-sync/src/watcher.rs b/crates/ncl-sync/src/watcher.rs new file mode 100644 index 0000000..725eebc --- /dev/null +++ b/crates/ncl-sync/src/watcher.rs @@ -0,0 +1,47 @@ +use anyhow::Result; +use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use std::path::{Path, PathBuf}; +use tokio::sync::mpsc; +use tracing::debug; + +pub enum WatchEvent { + Changed(PathBuf), + Removed(PathBuf), +} + +/// Start a single notify watcher for one or more paths. All events route to the same channel. +pub fn start_many(paths: &[PathBuf]) -> Result<(RecommendedWatcher, mpsc::Receiver)> { + let (tx, rx) = mpsc::channel::(128); + + let mut watcher = notify::recommended_watcher(move |res: notify::Result| { + let Ok(event) = res else { return }; + let kind = event.kind; + for path in event.paths { + if path.extension().is_none_or(|e| e != "ncl") { + continue; + } + let evt = match &kind { + EventKind::Remove(_) => WatchEvent::Removed(path), + _ => WatchEvent::Changed(path), + }; + let _ = tx.try_send(evt); + } + })?; + + for p in paths { + if p.exists() { + watcher.watch(p, RecursiveMode::Recursive)?; + debug!("watching: {}", p.display()); + } else { + debug!("skip watch (not found): {}", p.display()); + } + } + + Ok((watcher, rx)) +} + +/// Convenience wrapper for single-path watching (preserves old API for tests/tools). +#[allow(dead_code)] +pub fn start(watch_path: &Path) -> Result<(RecommendedWatcher, mpsc::Receiver)> { + start_many(&[watch_path.to_path_buf()]) +} diff --git a/crates/nu-daemon/.cargo/config.toml b/crates/nu-daemon/.cargo/config.toml new file mode 100644 index 0000000..0558cda --- /dev/null +++ b/crates/nu-daemon/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target-dir = "/Volumes/Devel/provisioning/platform/crates/nu-daemon/target" diff --git a/crates/nu-daemon/Cargo.toml b/crates/nu-daemon/Cargo.toml new file mode 100644 index 0000000..9358b4a --- /dev/null +++ b/crates/nu-daemon/Cargo.toml @@ -0,0 +1,53 @@ +[package] +authors = ["Jesus Perez "] +edition = "2021" +license = "MIT" +name = "nu-daemon" +repository = "https://github.com/jesusperezlorenzo/provisioning" +version = "1.0.11" + +[[bin]] +name = "provisioning-nu-daemon" +path = "src/main.rs" + +[dependencies] +# Core daemon library — nushell-persistent required for Nushell script execution +# (excluded from daemon-cli default due to rustls conflict with surrealdb workspace crates) +daemon-cli = { path = "../../prov-ecosystem/crates/daemon-cli", features = ["nushell-persistent"] } + +# Async runtime and networking +axum = { version = "0.8", features = ["ws", "macros"] } +tokio = { version = "1.49", features = ["full"] } +tower = { version = "0.5", features = ["full"] } +tower-http = { version = "0.6", features = ["cors", "trace", "fs", "compression-gzip", "timeout"] } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +toml = "0.9" + +# Platform configuration +platform-config = { path = "../platform-config" } + +# NATS — component lifecycle subscriber +async-nats = "0.46" + +# Centralized observability (logging, metrics, health, tracing) +observability = { path = "../../prov-ecosystem/crates/observability", package = "platform-observability", features = ["logging", "metrics-prometheus", "health"] } + +# Error handling +anyhow = "1.0" +thiserror = "2.0" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# CLI +clap = { version = "4.5", features = ["derive", "env"] } + +# Utilities +chrono = { version = "0.4", features = ["serde"] } +dirs = "6.0" +uuid = { version = "1.20", features = ["v4", "serde"] } +futures = "0.3.32" diff --git a/crates/daemon/Dockerfile.runtime b/crates/nu-daemon/Dockerfile.runtime similarity index 70% rename from crates/daemon/Dockerfile.runtime rename to crates/nu-daemon/Dockerfile.runtime index 2480ae6..3f60001 100644 --- a/crates/daemon/Dockerfile.runtime +++ b/crates/nu-daemon/Dockerfile.runtime @@ -8,15 +8,15 @@ RUN apt-get update && apt-get install -y \ # Create user RUN useradd -m -u 1000 provisioning && \ - mkdir -p /data /var/log/provisioning-daemon /etc/provisioning && \ - chown -R provisioning:provisioning /data /var/log/provisioning-daemon /etc/provisioning + mkdir -p /data /var/log/provisioning-nu-daemon /etc/provisioning && \ + chown -R provisioning:provisioning /data /var/log/provisioning-nu-daemon /etc/provisioning # Copy pre-built binary -COPY target/release/provisioning-daemon /usr/local/bin/provisioning-daemon -RUN chmod +x /usr/local/bin/provisioning-daemon +COPY target/release/provisioning-nu-daemon /usr/local/bin/provisioning-nu-daemon +RUN chmod +x /usr/local/bin/provisioning-nu-daemon # Copy default configuration files (assumes they're available at build time) -COPY provisioning/platform/config/runtime/generated/provisioning-daemon.*.toml /etc/provisioning/ +COPY provisioning/platform/config/runtime/generated/nu-daemon.*.toml /etc/provisioning/ USER provisioning WORKDIR /app @@ -35,4 +35,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ # 1. PROVISIONING_DAEMON_CONFIG (explicit path) # 2. PROVISIONING_DAEMON_MODE (mode-specific file) # 3. Default fallback -CMD ["provisioning-daemon"] +CMD ["provisioning-nu-daemon"] diff --git a/crates/daemon/src/config.rs b/crates/nu-daemon/src/config.rs similarity index 92% rename from crates/daemon/src/config.rs rename to crates/nu-daemon/src/config.rs index d395d19..c10c16d 100644 --- a/crates/daemon/src/config.rs +++ b/crates/nu-daemon/src/config.rs @@ -71,15 +71,12 @@ fn default_log_level() -> String { impl ProvisioningDaemonConfig { /// Load configuration from provisioning-daemon.ncl via platform-config pub fn load() -> Result> { - let config_json = platform_config::load_service_config_from_ncl("provisioning-daemon")?; + let config_json = platform_config::load_service_config_from_ncl("nu-daemon")?; - // The Nickel file returns { provisioning_daemon: { ... } } - // Extract the inner config object - let config_value = if let Some(inner) = config_json.get("provisioning_daemon") { - inner.clone() - } else { - config_json - }; + let config_value = config_json + .get("nu_daemon") + .cloned() + .unwrap_or(config_json); let config: ProvisioningDaemonConfig = serde_json::from_value(config_value)?; Ok(config) diff --git a/crates/daemon/src/main.rs b/crates/nu-daemon/src/main.rs similarity index 62% rename from crates/daemon/src/main.rs rename to crates/nu-daemon/src/main.rs index a8b379a..c416a42 100644 --- a/crates/daemon/src/main.rs +++ b/crates/nu-daemon/src/main.rs @@ -5,6 +5,7 @@ //! i18n) is provided by daemon-cli. mod config; +mod nats_subscriber; use std::path::PathBuf; use std::sync::Arc; @@ -14,6 +15,7 @@ use clap::Parser; use daemon_cli::{ api::api_routes, core::{DaemonConfig, HierarchicalCache, Result}, + nushell::{CommandExecutor, NushellEnvironment}, orchestration::{ AuditOperation, EncryptOperation, InitServsOperation, OperationRegistry, RuntimeOperation, ValidaOperation, @@ -24,8 +26,8 @@ use tokio::net::TcpListener; /// Provisioning daemon - Nushell execution and configuration rendering service #[derive(Parser, Debug)] -#[command(name = "provisioning-daemon")] -#[command(about = "Provisioning platform daemon with Nushell execution and config rendering")] +#[command(name = "provisioning-nu-daemon")] +#[command(about = "Provisioning Nushell execution daemon (persistent script runner)")] #[command(version = env!("CARGO_PKG_VERSION"))] struct Args { /// Configuration file path (highest priority) @@ -164,6 +166,35 @@ async fn main() -> Result<()> { let cache = HierarchicalCache::new()?; let cache_arc = Arc::new(cache); + // Initialize Nushell environment so /api/v1/execute uses the same runtime + // path as provisioning-daemon-cli. + let nushell_env = match initialize_nushell_environment().await { + Ok(env) => { + tracing::info!("✓ Nushell environment initialized successfully"); + Some(env) + } + Err(e) => { + tracing::warn!("Failed to initialize Nushell environment: {}", e); + tracing::warn!("Continuing without Nushell support"); + None + } + }; + + let nushell_executor = if let Some(env) = nushell_env { + match create_nushell_executor(env).await { + Ok(executor) => { + tracing::info!("✓ Nushell command executor ready"); + Some(Arc::new(executor)) + } + Err(e) => { + tracing::warn!("Failed to create Nushell executor: {}", e); + None + } + } + } else { + None + }; + // Create operation registry let mut registry = OperationRegistry::default(); registry.register("valida", Arc::new(ValidaOperation::new(cache_arc.clone()))); @@ -187,12 +218,21 @@ async fn main() -> Result<()> { registry.list().join(", ") ); - // Create app state - let state = AppState::new(config.clone(), cache_arc, registry); + // Create app state with Nushell executor when available. + let state = if let Some(executor) = nushell_executor { + AppState::with_nushell(config.clone(), cache_arc, registry, Some(executor)) + } else { + AppState::new(config.clone(), cache_arc, registry) + }; // Create router let app = Router::new().nest("/api/v1", api_routes(state.clone())); + // Spawn NATS component lifecycle subscriber (best-effort: NATS may be absent in solo mode) + let prov_root_for_nats = resolve_provisioning_root() + .unwrap_or_else(|_| PathBuf::from("/usr/local/provisioning")); + tokio::spawn(nats_subscriber::run(prov_root_for_nats)); + // Start server using the configured bind address let addr = config.bind_addr()?; let listener = TcpListener::bind(addr).await?; @@ -207,3 +247,81 @@ async fn main() -> Result<()> { Ok(()) } + +async fn initialize_nushell_environment() -> Result> { + let nu_path = find_nushell_binary()?; + let provisioning_path = resolve_provisioning_root()?; + + tracing::debug!("Nushell binary: {}", nu_path.display()); + tracing::debug!("Provisioning path: {}", provisioning_path.display()); + + let env = Arc::new(NushellEnvironment::new(nu_path, provisioning_path)); + env.load().await?; + Ok(env) +} + +fn resolve_provisioning_root() -> Result { + if let Ok(explicit) = std::env::var("PROVISIONING") { + let path = PathBuf::from(explicit); + if path.join("core").join("cli").join("provisioning").exists() { + return Ok(path); + } + } + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let repo_provisioning = manifest_dir + .ancestors() + .nth(3) + .map(PathBuf::from) + .ok_or_else(|| daemon_cli::core::DaemonError::nushell_process_error("Failed to resolve provisioning root"))?; + + if repo_provisioning + .join("core") + .join("cli") + .join("provisioning") + .exists() + { + Ok(repo_provisioning) + } else { + Err(daemon_cli::core::DaemonError::nushell_process_error( + "Provisioning root not found from CARGO_MANIFEST_DIR", + )) + } +} + +fn find_nushell_binary() -> Result { + let candidates = ["/usr/local/bin/nu", "/usr/bin/nu", "/opt/homebrew/bin/nu"]; + + for candidate in &candidates { + let path = PathBuf::from(candidate); + if path.exists() { + return Ok(path); + } + } + + if let Ok(output) = std::process::Command::new("which").arg("nu").output() { + if output.status.success() { + let path_str = String::from_utf8(output.stdout) + .ok() + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + if !path_str.is_empty() { + return Ok(PathBuf::from(path_str)); + } + } + } + + Err(daemon_cli::core::DaemonError::nushell_process_error( + "Nushell binary not found in standard locations", + )) +} + +async fn create_nushell_executor(env: Arc) -> Result { + let executor = CommandExecutor::new(env); + + if !executor.is_environment_healthy().await { + tracing::warn!("Nushell environment is not healthy"); + } + + Ok(executor) +} diff --git a/crates/nu-daemon/src/nats_subscriber.rs b/crates/nu-daemon/src/nats_subscriber.rs new file mode 100644 index 0000000..d3673ae --- /dev/null +++ b/crates/nu-daemon/src/nats_subscriber.rs @@ -0,0 +1,125 @@ +use std::path::{Path, PathBuf}; + +use futures::StreamExt as _; +use serde::{Deserialize, Serialize}; + +const SUBJECT: &str = "provisioning.component.lifecycle"; + +#[derive(Debug, Deserialize, Serialize)] +struct ComponentLifecycleEvent { + task_id: String, + component: String, + server: String, + workspace: String, + operation: String, + status: String, +} + +/// Background task: subscribe to `provisioning.component.lifecycle` and +/// update `.provisioning-state.ncl` for each terminal event. +/// Exits silently when NATS is unreachable — daemon continues normally. +pub async fn run(prov_root: PathBuf) { + let url = std::env::var("NATS_URL").unwrap_or_else(|_| "nats://localhost:4222".to_string()); + + let client = match async_nats::connect(&url).await { + Ok(c) => c, + Err(e) => { + tracing::info!("NATS unavailable ({e}) — component lifecycle subscriber inactive"); + return; + } + }; + + let mut sub = match client.subscribe(SUBJECT).await { + Ok(s) => s, + Err(e) => { + tracing::error!("NATS subscribe to {SUBJECT} failed: {e}"); + return; + } + }; + + tracing::info!("NATS component lifecycle subscriber active on {SUBJECT}"); + + while let Some(msg) = sub.next().await { + let event: ComponentLifecycleEvent = match serde_json::from_slice(&msg.payload) { + Ok(e) => e, + Err(e) => { + tracing::warn!("Malformed component lifecycle event: {e}"); + continue; + } + }; + apply_state(&event, &prov_root).await; + } +} + +async fn apply_state(event: &ComponentLifecycleEvent, prov_root: &Path) { + let nu_bin = find_nu_binary(); + let nulib = prov_root.join("core/nulib"); + + // Map (status, operation) → Nu function call + let nu_call = match (event.status.as_str(), event.operation.as_str()) { + ("completed", "delete") => format!( + "use domain/workspace/state.nu [state-node-delete]; \ + state-node-delete '{}' '{}' '{}'", + event.workspace, event.server, event.component, + ), + ("completed", "reinstall") => format!( + "use domain/workspace/state.nu [state-node-reset]; \ + state-node-reset '{}' '{}' '{}' --source orchestrator", + event.workspace, event.server, event.component, + ), + ("completed", _) => format!( + "use domain/workspace/state.nu [state-node-finish]; \ + state-node-finish '{}' '{}' '{}' --success --source orchestrator", + event.workspace, event.server, event.component, + ), + _ => format!( + "use domain/workspace/state.nu [state-node-finish]; \ + state-node-finish '{}' '{}' '{}' --source orchestrator", + event.workspace, event.server, event.component, + ), + }; + + let result = tokio::process::Command::new(&nu_bin) + .env("PROVISIONING", prov_root) + .arg("--include-path") + .arg(&nulib) + .arg("-c") + .arg(&nu_call) + .output() + .await; + + match result { + Ok(out) if out.status.success() => tracing::info!( + component = %event.component, + server = %event.server, + operation = %event.operation, + status = %event.status, + "DAG state updated from NATS lifecycle event" + ), + Ok(out) => tracing::warn!( + component = %event.component, + server = %event.server, + "DAG state update failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + ), + Err(e) => tracing::error!("Failed to spawn nu for DAG state update: {e}"), + } +} + +fn find_nu_binary() -> PathBuf { + for candidate in ["/usr/local/bin/nu", "/opt/homebrew/bin/nu", "/usr/bin/nu"] { + let p = PathBuf::from(candidate); + if p.exists() { + return p; + } + } + if let Ok(out) = std::process::Command::new("which").arg("nu").output() { + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !s.is_empty() { + return PathBuf::from(s); + } + } + } + PathBuf::from("nu") +} diff --git a/crates/ops-controller/Cargo.toml b/crates/ops-controller/Cargo.toml new file mode 100644 index 0000000..ef699fb --- /dev/null +++ b/crates/ops-controller/Cargo.toml @@ -0,0 +1,32 @@ +[package] +authors.workspace = true +edition.workspace = true +license.workspace = true +name = "ops-controller" +version.workspace = true +description = "Single WorkQueue subscriber for ops.cmd.* — validates JWT, persists jti, applies via orchestrator API (ADR-037)" + +[[bin]] +name = "ops-controller" +path = "src/main.rs" + +[dependencies] +async-nats = { workspace = true } +tokio = { workspace = true, features = ["full"] } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +clap = { workspace = true } +jsonwebtoken = { workspace = true } +reqwest = { workspace = true } +futures = { workspace = true } +toml = { workspace = true } +base64 = { workspace = true } +surrealdb = { workspace = true } +bytes = { workspace = true } +ops-keeper = { path = "../ops-keeper" } diff --git a/crates/ops-controller/src/audit_emit.rs b/crates/ops-controller/src/audit_emit.rs new file mode 100644 index 0000000..d0dd322 --- /dev/null +++ b/crates/ops-controller/src/audit_emit.rs @@ -0,0 +1,65 @@ +use async_nats::jetstream::Context; +use bytes::Bytes; +use chrono::Utc; + +use crate::{ + error::ControllerError, + types::{AckPayload, AckResult}, +}; +use ops_keeper::types::PendingOp; + +/// Emit to ops.ack.. (for the original requester). +pub async fn emit_ack( + js: &Context, + workspace: &str, + op: &PendingOp, + jti: &str, + result: AckResult, + error: Option, +) -> Result<(), ControllerError> { + let payload = AckPayload { + jti: jti.to_string(), + workspace: workspace.to_string(), + op_type: op.op_type.clone(), + target: op.target.clone(), + result, + error, + timestamp: Utc::now(), + }; + let bytes = Bytes::from(serde_json::to_vec(&payload)?); + let subject = format!("ops.ack.{}.{}", workspace, op.op_type); + js.publish(subject.clone(), bytes) + .await + .map_err(|e| ControllerError::Nats(format!("publish ack to {subject}: {e}")))? + .await + .map_err(|e| ControllerError::Nats(format!("ack confirm for {subject}: {e}")))?; + Ok(()) +} + +/// Emit to ops.audit. (consumed by audit-mirror → Radicle). +pub async fn emit_audit( + js: &Context, + workspace: &str, + op: &PendingOp, + jti: &str, + outcome: &str, +) -> Result<(), ControllerError> { + let payload = serde_json::json!({ + "jti": jti, + "workspace": workspace, + "op_type": op.op_type, + "target": op.target, + "sub": op.sub, + "expected_state_version": op.expected_state_version, + "outcome": outcome, + "timestamp": Utc::now().to_rfc3339(), + }); + let bytes = Bytes::from(serde_json::to_vec(&payload)?); + let subject = format!("ops.audit.{workspace}"); + js.publish(subject.clone(), bytes) + .await + .map_err(|e| ControllerError::Nats(format!("publish audit to {subject}: {e}")))? + .await + .map_err(|e| ControllerError::Nats(format!("audit confirm for {subject}: {e}")))?; + Ok(()) +} diff --git a/crates/ops-controller/src/auth.rs b/crates/ops-controller/src/auth.rs new file mode 100644 index 0000000..82fbdee --- /dev/null +++ b/crates/ops-controller/src/auth.rs @@ -0,0 +1,122 @@ +use base64::Engine; +use jsonwebtoken::{Algorithm, DecodingKey, Validation}; +use ops_keeper::types::OpsClaims; + +use crate::error::ControllerError; + +pub struct JwtAuth { + decoding_key: DecodingKey, + workspace: String, +} + +impl JwtAuth { + pub fn from_public_pem(public_pem: &[u8], workspace: String) -> Result { + let der = pem_to_der(public_pem)?; + Ok(Self { + decoding_key: DecodingKey::from_ed_der(&der), + workspace, + }) + } + + /// Validate JWT signature, audience, nbf/exp, and scope. + /// Returns the decoded claims if all checks pass. + pub fn validate(&self, token: &str) -> Result { + let mut validation = Validation::new(Algorithm::EdDSA); + validation.set_audience(&[&self.workspace]); + validation.validate_nbf = true; + + let claims = jsonwebtoken::decode::(token, &self.decoding_key, &validation) + .map(|td| td.claims) + .map_err(|e| ControllerError::Auth(e.to_string()))?; + + validate_scopes(&claims)?; + Ok(claims) + } +} + +/// Verify that the op embedded in claims is covered by at least one declared scope. +fn validate_scopes(claims: &OpsClaims) -> Result<(), ControllerError> { + let op = &claims.op; + let covered = claims.scopes.iter().any(|scope| { + let op_type_ok = scope.op_type == "*" || scope.op_type == op.op_type; + let target_ok = scope.target_pattern == "*" || op.target.contains(&scope.target_pattern); + op_type_ok && target_ok + }); + if !covered { + return Err(ControllerError::Auth(format!( + "op '{}/{}' not covered by any declared scope", + op.op_type, op.target + ))); + } + Ok(()) +} + +fn pem_to_der(pem: &[u8]) -> Result, ControllerError> { + let pem_str = std::str::from_utf8(pem) + .map_err(|e| ControllerError::Auth(format!("PEM is not UTF-8: {e}")))?; + let body: String = pem_str + .lines() + .filter(|l| !l.starts_with("-----")) + .collect(); + base64::engine::general_purpose::STANDARD + .decode(body.as_bytes()) + .map_err(|e| ControllerError::Auth(format!("PEM base64 decode: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use ops_keeper::types::{OpsClaims, PendingOp, ScopeEntry, SingleOrVec}; + + fn make_claims(op_type: &str, target: &str, scopes: Vec) -> OpsClaims { + OpsClaims { + iss: "test-keeper".into(), + sub: "test-sub".into(), + aud: SingleOrVec::single("test-ws"), + scopes, + seq: 1, + jti: "test-jti".into(), + expected_state_version: "v1".into(), + exp: u64::MAX, + nbf: 0, + op: PendingOp { + op_type: op_type.into(), + target: target.into(), + sub: "sub".into(), + expected_state_version: "v1".into(), + image: String::new(), + params: serde_json::Value::Null, + }, + } + } + + #[test] + fn scope_covers_exact_match() { + let claims = make_claims( + "deploy", + "staging-vapora", + vec![ScopeEntry { op_type: "deploy".into(), target_pattern: "staging-vapora".into() }], + ); + assert!(validate_scopes(&claims).is_ok()); + } + + #[test] + fn scope_wildcard_op_type_covers_any() { + let claims = make_claims( + "scale", + "staging-vapora", + vec![ScopeEntry { op_type: "*".into(), target_pattern: "staging-vapora".into() }], + ); + assert!(validate_scopes(&claims).is_ok()); + } + + #[test] + fn scope_mismatch_returns_error() { + let claims = make_claims( + "deploy", + "prod-cluster", + vec![ScopeEntry { op_type: "scale".into(), target_pattern: "staging-vapora".into() }], + ); + assert!(validate_scopes(&claims).is_err()); + } +} diff --git a/crates/ops-controller/src/config.rs b/crates/ops-controller/src/config.rs new file mode 100644 index 0000000..fd850f1 --- /dev/null +++ b/crates/ops-controller/src/config.rs @@ -0,0 +1,30 @@ +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +use crate::error::ControllerError; + +#[derive(Debug, Clone, Deserialize)] +pub struct ControllerConfig { + pub nats_url: String, + pub workspace: String, + /// Path to the Ed25519 public key PEM used to verify keeper-signed JWTs. + pub keeper_public_key_path: PathBuf, + /// HTTP base URL of the orchestrator API. + pub orchestrator_url: String, + /// SurrealDB connection URL. Use `surrealkv://path/to/dir` for embedded, + /// or `ws://host:port/rpc` for remote. + pub surrealdb_url: String, + #[serde(default)] + pub nats_auth_token: Option, + /// Orchestrator HTTP auth token (Bearer). + #[serde(default)] + pub orchestrator_auth_token: Option, +} + +impl ControllerConfig { + pub fn from_toml(path: &Path) -> Result { + let raw = std::fs::read_to_string(path)?; + toml::from_str(&raw).map_err(|e| ControllerError::Io(std::io::Error::other(e.to_string()))) + } +} diff --git a/crates/ops-controller/src/error.rs b/crates/ops-controller/src/error.rs new file mode 100644 index 0000000..cbd1c99 --- /dev/null +++ b/crates/ops-controller/src/error.rs @@ -0,0 +1,19 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ControllerError { + #[error("NATS error: {0}")] + Nats(String), + #[error("JWT validation failed: {0}")] + Auth(String), + #[error("persistence error: {0}")] + Db(String), + #[error("orchestrator API error: {0}")] + Orchestrator(String), + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("duplicate jti '{0}' — already applied")] + DuplicateJti(String), +} diff --git a/crates/ops-controller/src/handlers/deploy.rs b/crates/ops-controller/src/handlers/deploy.rs new file mode 100644 index 0000000..72440a7 --- /dev/null +++ b/crates/ops-controller/src/handlers/deploy.rs @@ -0,0 +1,7 @@ +use ops_keeper::types::PendingOp; + +use crate::{error::ControllerError, orchestrator::OrchestratorClient}; + +pub async fn run(op: &PendingOp, orc: &OrchestratorClient) -> Result<(), ControllerError> { + orc.deploy(&op.target, &op.image, &op.params).await +} diff --git a/crates/ops-controller/src/handlers/drain.rs b/crates/ops-controller/src/handlers/drain.rs new file mode 100644 index 0000000..96e9e70 --- /dev/null +++ b/crates/ops-controller/src/handlers/drain.rs @@ -0,0 +1,7 @@ +use ops_keeper::types::PendingOp; + +use crate::{error::ControllerError, orchestrator::OrchestratorClient}; + +pub async fn run(op: &PendingOp, orc: &OrchestratorClient) -> Result<(), ControllerError> { + orc.drain(&op.target).await +} diff --git a/crates/ops-controller/src/handlers/mod.rs b/crates/ops-controller/src/handlers/mod.rs new file mode 100644 index 0000000..c1e50b2 --- /dev/null +++ b/crates/ops-controller/src/handlers/mod.rs @@ -0,0 +1,26 @@ +use ops_keeper::types::PendingOp; + +use crate::{error::ControllerError, orchestrator::OrchestratorClient}; + +pub mod deploy; +pub mod drain; +pub mod restart; +pub mod rollback; +pub mod scale; +pub mod secret_update; + +/// Dispatch a validated op to the appropriate handler. +pub async fn dispatch( + op: &PendingOp, + orc: &OrchestratorClient, +) -> Result<(), ControllerError> { + match op.op_type.as_str() { + "deploy" => deploy::run(op, orc).await, + "scale" => scale::run(op, orc).await, + "restart" => restart::run(op, orc).await, + "secret_update" => secret_update::run(op, orc).await, + "drain" => drain::run(op, orc).await, + "rollback" => rollback::run(op, orc).await, + other => Err(ControllerError::Orchestrator(format!("unknown op_type '{other}'"))), + } +} diff --git a/crates/ops-controller/src/handlers/restart.rs b/crates/ops-controller/src/handlers/restart.rs new file mode 100644 index 0000000..7fa3b6f --- /dev/null +++ b/crates/ops-controller/src/handlers/restart.rs @@ -0,0 +1,7 @@ +use ops_keeper::types::PendingOp; + +use crate::{error::ControllerError, orchestrator::OrchestratorClient}; + +pub async fn run(op: &PendingOp, orc: &OrchestratorClient) -> Result<(), ControllerError> { + orc.restart(&op.target).await +} diff --git a/crates/ops-controller/src/handlers/rollback.rs b/crates/ops-controller/src/handlers/rollback.rs new file mode 100644 index 0000000..274dfb9 --- /dev/null +++ b/crates/ops-controller/src/handlers/rollback.rs @@ -0,0 +1,7 @@ +use ops_keeper::types::PendingOp; + +use crate::{error::ControllerError, orchestrator::OrchestratorClient}; + +pub async fn run(op: &PendingOp, orc: &OrchestratorClient) -> Result<(), ControllerError> { + orc.rollback(&op.target, &op.params).await +} diff --git a/crates/ops-controller/src/handlers/scale.rs b/crates/ops-controller/src/handlers/scale.rs new file mode 100644 index 0000000..26ab2ff --- /dev/null +++ b/crates/ops-controller/src/handlers/scale.rs @@ -0,0 +1,7 @@ +use ops_keeper::types::PendingOp; + +use crate::{error::ControllerError, orchestrator::OrchestratorClient}; + +pub async fn run(op: &PendingOp, orc: &OrchestratorClient) -> Result<(), ControllerError> { + orc.scale(&op.target, &op.params).await +} diff --git a/crates/ops-controller/src/handlers/secret_update.rs b/crates/ops-controller/src/handlers/secret_update.rs new file mode 100644 index 0000000..55568b6 --- /dev/null +++ b/crates/ops-controller/src/handlers/secret_update.rs @@ -0,0 +1,7 @@ +use ops_keeper::types::PendingOp; + +use crate::{error::ControllerError, orchestrator::OrchestratorClient}; + +pub async fn run(op: &PendingOp, orc: &OrchestratorClient) -> Result<(), ControllerError> { + orc.secret_update(&op.target, &op.params).await +} diff --git a/crates/ops-controller/src/main.rs b/crates/ops-controller/src/main.rs new file mode 100644 index 0000000..b29d3dd --- /dev/null +++ b/crates/ops-controller/src/main.rs @@ -0,0 +1,218 @@ +mod audit_emit; +mod auth; +mod config; +mod error; +mod handlers; +mod orchestrator; +mod persistence; +mod recovery; +mod types; + +use std::{path::PathBuf, sync::Arc}; + +use async_nats::{ + jetstream::{self, consumer::pull, AckKind}, + ConnectOptions, +}; +use clap::Parser; +use futures::StreamExt; +use surrealdb::{engine::any, Surreal}; +use tracing::{error, info, warn}; + +use audit_emit::{emit_ack, emit_audit}; +use auth::JwtAuth; +use config::ControllerConfig; +use error::ControllerError; +use orchestrator::OrchestratorClient; +use persistence::Persistence; +use recovery::{check_idempotency, reconcile_pending}; +use types::AckResult; + +#[derive(Parser)] +#[command(name = "ops-controller", about = "WorkQueue subscriber for ops.cmd.* (ADR-037)")] +struct Cli { + #[arg(short, long, default_value = "ops-controller.toml")] + config: PathBuf, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let cli = Cli::parse(); + let cfg = ControllerConfig::from_toml(&cli.config)?; + + // Load public key for JWT verification + let public_pem = std::fs::read(&cfg.keeper_public_key_path)?; + let jwt_auth = Arc::new(JwtAuth::from_public_pem(&public_pem, cfg.workspace.clone())?); + + // Connect SurrealDB + let db: Surreal = any::connect(&cfg.surrealdb_url).await + .map_err(|e| format!("SurrealDB connect to {}: {e}", cfg.surrealdb_url))?; + let persistence = Arc::new(Persistence::new(db, cfg.workspace.clone()).await?); + + // Connect orchestrator HTTP client + let orc = Arc::new(OrchestratorClient::new( + cfg.orchestrator_url.clone(), + cfg.orchestrator_auth_token.clone(), + )?); + + // Connect NATS JetStream + let nats_opts = match &cfg.nats_auth_token { + Some(token) => ConnectOptions::new().token(token.clone()), + None => ConnectOptions::new(), + }; + let nats = nats_opts.connect(cfg.nats_url.as_str()).await + .map_err(|e| format!("NATS connect: {e}"))?; + let js = Arc::new(jetstream::new(nats)); + + // Recovery pass on startup + reconcile_pending( + Arc::clone(&persistence), + Arc::clone(&orc), + Arc::clone(&js), + &cfg.workspace, + ).await; + + // Subscribe to OPS_CMD_ as a single durable WorkQueue consumer + let stream_name = format!("OPS_CMD_{}", cfg.workspace.to_uppercase()); + let consumer_name = format!("{}-ops-controller", cfg.workspace); + let stream = js.get_stream(&stream_name).await + .map_err(|e| format!("get stream {stream_name}: {e}"))?; + let consumer = stream + .get_or_create_consumer( + &consumer_name, + pull::Config { + durable_name: Some(consumer_name.clone()), + filter_subject: format!("ops.cmd.{}.>", cfg.workspace), + ack_policy: async_nats::jetstream::consumer::AckPolicy::Explicit, + ..Default::default() + }, + ) + .await + .map_err(|e| format!("create consumer {consumer_name}: {e}"))?; + + info!(stream = stream_name, consumer = consumer_name, "ops-controller ready"); + + let mut messages = consumer.messages().await + .map_err(|e| format!("messages: {e}"))?; + + while let Some(msg_result) = messages.next().await { + let msg = match msg_result { + Ok(m) => m, + Err(e) => { error!("NATS error: {e}"); continue; } + }; + + let token = match std::str::from_utf8(&msg.payload) { + Ok(t) => t.trim().to_string(), + Err(e) => { + error!(error = %e, "payload is not UTF-8; acking and skipping"); + let _ = msg.ack().await; + continue; + } + }; + + let claims = match jwt_auth.validate(&token) { + Ok(c) => c, + Err(e) => { + error!(error = %e, "JWT validation failed; acking and skipping (bad message)"); + let _ = msg.ack().await; + continue; + } + }; + + let jti = &claims.jti; + let op = &claims.op; + + // Idempotency check + match check_idempotency(&persistence, jti).await { + Ok(true) => { + info!(jti, "duplicate jti — idempotent skip"); + let _ = msg.ack().await; + continue; + } + Ok(false) => {} + Err(e) => { + warn!(jti, error = %e, "idempotency check inconclusive; NACKing for retry"); + let _ = msg.ack_with(AckKind::Nak(None)).await; + continue; + } + } + + // Version check + match persistence.current_version().await { + Ok(current) if current != claims.expected_state_version => { + let reason = format!( + "state version mismatch: expected '{}', current '{current}'", + claims.expected_state_version + ); + error!(jti, reason, "version mismatch — rejecting op"); + if let Err(e) = emit_ack(&js, &cfg.workspace, op, jti, AckResult::Rejected, Some(reason.clone())).await { + warn!(jti, error = %e, "ack emit failed"); + } + let _ = msg.ack().await; + continue; + } + Err(e) => { + warn!(jti, error = %e, "version lookup failed; NACKing for retry"); + let _ = msg.ack_with(AckKind::Nak(None)).await; + continue; + } + Ok(_) => {} + } + + // Persist as Pending before applying (crash-safe) + if let Err(e) = persistence.insert_pending(jti, &op.op_type, &op.target).await { + match e { + ControllerError::DuplicateJti(_) => { + info!(jti, "duplicate jti on insert — idempotent skip"); + let _ = msg.ack().await; + continue; + } + other => { + error!(jti, error = %other, "DB insert failed; NACKing for retry"); + let _ = msg.ack_with(AckKind::Nak(None)).await; + continue; + } + } + } + + // Apply via orchestrator + match handlers::dispatch(op, &orc).await { + Ok(()) => { + info!(jti, op_type = %op.op_type, target = %op.target, "op applied"); + let version = persistence.increment_version().await.unwrap_or_else(|_| "unknown".into()); + if let Err(e) = persistence.mark_applied(jti).await { + warn!(jti, error = %e, "mark_applied failed"); + } + if let Err(e) = emit_audit(&js, &cfg.workspace, op, jti, "applied").await { + warn!(jti, error = %e, "audit emit failed (non-fatal)"); + } + if let Err(e) = emit_ack(&js, &cfg.workspace, op, jti, AckResult::Applied, None).await { + warn!(jti, error = %e, "ack emit failed (non-fatal)"); + } + info!(jti, new_version = version, "state version incremented"); + let _ = msg.ack().await; + } + Err(e) => { + let reason = e.to_string(); + error!(jti, error = reason, "orchestrator apply failed"); + if let Err(db_e) = persistence.mark_rejected(jti, &reason).await { + warn!(jti, error = %db_e, "mark_rejected failed"); + } + if let Err(e) = emit_audit(&js, &cfg.workspace, op, jti, "rejected").await { + warn!(jti, error = %e, "audit emit failed"); + } + if let Err(e) = emit_ack(&js, &cfg.workspace, op, jti, AckResult::Rejected, Some(reason)).await { + warn!(jti, error = %e, "ack emit failed"); + } + let _ = msg.ack().await; + } + } + } + + warn!("NATS message stream ended; ops-controller exiting"); + Ok(()) +} diff --git a/crates/ops-controller/src/orchestrator.rs b/crates/ops-controller/src/orchestrator.rs new file mode 100644 index 0000000..1349dd1 --- /dev/null +++ b/crates/ops-controller/src/orchestrator.rs @@ -0,0 +1,97 @@ +use reqwest::Client; +use serde_json::Value; + +use crate::error::ControllerError; + +/// Thin HTTP client for the orchestrator API at port 9011. +pub struct OrchestratorClient { + client: Client, + base_url: String, + auth_header: Option, +} + +impl OrchestratorClient { + pub fn new(base_url: String, auth_token: Option) -> Result { + let client = Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .build() + .map_err(|e| ControllerError::Orchestrator(format!("HTTP client init: {e}")))?; + let auth_header = auth_token.map(|t| format!("Bearer {t}")); + Ok(Self { client, base_url, auth_header }) + } + + pub async fn deploy(&self, target: &str, image: &str, params: &Value) -> Result<(), ControllerError> { + self.post( + "/api/v1/workloads", + serde_json::json!({ "target": target, "image": image, "params": params }), + ).await + } + + pub async fn scale(&self, target: &str, params: &Value) -> Result<(), ControllerError> { + self.patch( + &format!("/api/v1/workloads/{target}/scale"), + serde_json::json!({ "params": params }), + ).await + } + + pub async fn restart(&self, target: &str) -> Result<(), ControllerError> { + self.post( + &format!("/api/v1/workloads/{target}/restart"), + serde_json::json!({}), + ).await + } + + pub async fn secret_update(&self, target: &str, params: &Value) -> Result<(), ControllerError> { + self.patch( + &format!("/api/v1/workloads/{target}/secrets"), + serde_json::json!({ "params": params }), + ).await + } + + pub async fn drain(&self, target: &str) -> Result<(), ControllerError> { + self.post( + &format!("/api/v1/workloads/{target}/drain"), + serde_json::json!({}), + ).await + } + + pub async fn rollback(&self, target: &str, params: &Value) -> Result<(), ControllerError> { + self.post( + &format!("/api/v1/workloads/{target}/rollback"), + serde_json::json!({ "params": params }), + ).await + } + + async fn post(&self, path: &str, body: Value) -> Result<(), ControllerError> { + let url = format!("{}{path}", self.base_url); + let mut req = self.client.post(&url).json(&body); + if let Some(auth) = &self.auth_header { + req = req.header("Authorization", auth); + } + let resp = req.send().await + .map_err(|e| ControllerError::Orchestrator(format!("POST {path}: {e}")))?; + check_status(path, resp).await + } + + async fn patch(&self, path: &str, body: Value) -> Result<(), ControllerError> { + let url = format!("{}{path}", self.base_url); + let mut req = self.client.patch(&url).json(&body); + if let Some(auth) = &self.auth_header { + req = req.header("Authorization", auth); + } + let resp = req.send().await + .map_err(|e| ControllerError::Orchestrator(format!("PATCH {path}: {e}")))?; + check_status(path, resp).await + } +} + +async fn check_status(path: &str, resp: reqwest::Response) -> Result<(), ControllerError> { + let status = resp.status(); + if status.is_success() { + return Ok(()); + } + let body = resp.text().await.unwrap_or_default(); + Err(ControllerError::Orchestrator(format!( + "{path} returned HTTP {status}: {body}" + ))) +} diff --git a/crates/ops-controller/src/persistence.rs b/crates/ops-controller/src/persistence.rs new file mode 100644 index 0000000..1ef54d9 --- /dev/null +++ b/crates/ops-controller/src/persistence.rs @@ -0,0 +1,171 @@ +use chrono::Utc; +use serde_json::Value; +use surrealdb::{engine::any::Any, Surreal}; + +use crate::{ + error::ControllerError, + types::InFlightOp, +}; + +pub struct Persistence { + db: Surreal, + workspace: String, +} + +const TABLE: &str = "ops_in_flight"; +const STATE_TABLE: &str = "workspace_state"; + +impl Persistence { + pub async fn new(db: Surreal, workspace: String) -> Result { + db.use_ns("ops") + .use_db("controller") + .await + .map_err(|e| ControllerError::Db(e.to_string()))?; + Ok(Self { db, workspace }) + } + + /// Insert a new in-flight op with state=Pending. + /// Returns `Err(DuplicateJti)` if the jti already exists. + pub async fn insert_pending( + &self, + jti: &str, + op_type: &str, + target: &str, + ) -> Result<(), ControllerError> { + let jti_owned = jti.to_string(); + let existing = self.get(jti).await?; + if let Some(row) = existing { + return Err(ControllerError::DuplicateJti(format!( + "{jti} (state={})", + row.state.as_str() + ))); + } + + let now = Utc::now(); + let _: Option = self + .db + .create((TABLE, jti_owned.as_str())) + .content(serde_json::json!({ + "jti": jti_owned, + "op_type": op_type, + "target": target, + "state": "pending", + "created_at": now, + "updated_at": now, + "error": null, + })) + .await + .map_err(|e| ControllerError::Db(e.to_string()))?; + + Ok(()) + } + + pub async fn mark_applied(&self, jti: &str) -> Result<(), ControllerError> { + let jti_owned = jti.to_string(); + let _: Option = self + .db + .update((TABLE, jti_owned.as_str())) + .merge(serde_json::json!({ + "state": "applied", + "updated_at": Utc::now(), + })) + .await + .map_err(|e| ControllerError::Db(format!("mark_applied {jti}: {e}")))?; + Ok(()) + } + + pub async fn mark_rejected(&self, jti: &str, reason: &str) -> Result<(), ControllerError> { + let jti_owned = jti.to_string(); + let reason_owned = reason.to_string(); + let _: Option = self + .db + .update((TABLE, jti_owned.as_str())) + .merge(serde_json::json!({ + "state": "rejected", + "error": reason_owned, + "updated_at": Utc::now(), + })) + .await + .map_err(|e| ControllerError::Db(format!("mark_rejected {jti}: {e}")))?; + Ok(()) + } + + pub async fn get(&self, jti: &str) -> Result, ControllerError> { + let mut resp = self + .db + .query("SELECT * FROM type::table($tb) WHERE jti = $jti LIMIT 1") + .bind(("tb", TABLE.to_string())) + .bind(("jti", jti.to_string())) + .await + .map_err(|e| ControllerError::Db(e.to_string()))?; + + let rows: Vec = resp + .take(0) + .map_err(|e| ControllerError::Db(e.to_string()))?; + let row = rows + .into_iter() + .next() + .and_then(|v| serde_json::from_value(v).ok()); + Ok(row) + } + + /// Return all rows with state=Pending for recovery on startup. + pub async fn list_pending(&self) -> Result, ControllerError> { + let mut resp = self + .db + .query("SELECT * FROM type::table($tb) WHERE state = 'pending'") + .bind(("tb", TABLE.to_string())) + .await + .map_err(|e| ControllerError::Db(e.to_string()))?; + + let raw: Vec = resp + .take(0) + .map_err(|e| ControllerError::Db(e.to_string()))?; + Ok(raw.into_iter().filter_map(|v| serde_json::from_value(v).ok()).collect()) + } + + /// Get the current state version for this workspace. Returns "v0" if none persisted. + pub async fn current_version(&self) -> Result { + let ws = self.workspace.clone(); + let mut resp = self + .db + .query("SELECT version FROM type::table($tb) WHERE workspace = $ws LIMIT 1") + .bind(("tb", STATE_TABLE.to_string())) + .bind(("ws", ws)) + .await + .map_err(|e| ControllerError::Db(e.to_string()))?; + + let rows: Vec = resp + .take(0) + .map_err(|e| ControllerError::Db(e.to_string()))?; + + let version = rows + .into_iter() + .next() + .and_then(|v| v.get("version").and_then(|s| s.as_str()).map(str::to_owned)) + .unwrap_or_else(|| "v0".to_string()); + + Ok(version) + } + + /// Increment the workspace state version after a successful apply. + pub async fn increment_version(&self) -> Result { + let current = self.current_version().await?; + let n: u64 = current.trim_start_matches('v').parse().unwrap_or(0); + let next = format!("v{}", n + 1); + let ws = self.workspace.clone(); + + let _: Option = self + .db + .upsert((STATE_TABLE, ws.as_str())) + .content(serde_json::json!({ + "workspace": ws, + "version": next, + "updated_at": Utc::now(), + })) + .await + .map_err(|e| ControllerError::Db(format!("increment_version: {e}")))?; + + Ok(next) + } +} diff --git a/crates/ops-controller/src/recovery.rs b/crates/ops-controller/src/recovery.rs new file mode 100644 index 0000000..24bb799 --- /dev/null +++ b/crates/ops-controller/src/recovery.rs @@ -0,0 +1,100 @@ +use std::sync::Arc; + +use tracing::{info, warn}; + +use crate::{ + audit_emit, + error::ControllerError, + handlers, + orchestrator::OrchestratorClient, + persistence::Persistence, + types::{AckResult, OpState}, +}; +use async_nats::jetstream::Context; +use ops_keeper::types::PendingOp; + +/// On startup, scan SurrealDB for ops with state=Pending and attempt to re-apply them. +/// +/// This handles the crash-recovery path: if ops-controller dies after INSERT but +/// before the orchestrator call, the row stays Pending. On restart we reconcile. +pub async fn reconcile_pending( + persistence: Arc, + orc: Arc, + js: Arc, + workspace: &str, +) { + let pending = match persistence.list_pending().await { + Ok(rows) => rows, + Err(e) => { + warn!(error = %e, "recovery: failed to list pending ops — skipping reconciliation"); + return; + } + }; + + if pending.is_empty() { + info!("recovery: no pending ops found"); + return; + } + + info!(count = pending.len(), "recovery: reconciling pending ops"); + + for row in &pending { + let op = PendingOp { + op_type: row.op_type.clone(), + target: row.target.clone(), + sub: "recovery".to_string(), + expected_state_version: "unknown".to_string(), + image: String::new(), + params: serde_json::Value::Null, + }; + + match handlers::dispatch(&op, &orc).await { + Ok(()) => { + info!(jti = %row.jti, "recovery: re-applied pending op"); + if let Err(e) = persistence.mark_applied(&row.jti).await { + warn!(jti = %row.jti, error = %e, "recovery: failed to mark as applied"); + } + if let Err(e) = audit_emit::emit_audit(&js, workspace, &op, &row.jti, "applied-on-recovery").await { + warn!(jti = %row.jti, error = %e, "recovery: audit emit failed"); + } + if let Err(e) = audit_emit::emit_ack(&js, workspace, &op, &row.jti, AckResult::Applied, None).await { + warn!(jti = %row.jti, error = %e, "recovery: ack emit failed"); + } + let _ = persistence.increment_version().await; + } + Err(e) => { + warn!(jti = %row.jti, error = %e, "recovery: op re-apply failed — marking rejected"); + let reason = e.to_string(); + if let Err(db_err) = persistence.mark_rejected(&row.jti, &reason).await { + warn!(jti = %row.jti, error = %db_err, "recovery: failed to mark as rejected"); + } + if let Err(e) = audit_emit::emit_ack(&js, workspace, &op, &row.jti, AckResult::Rejected, Some(reason)).await { + warn!(jti = %row.jti, error = %e, "recovery: ack emit failed for rejection"); + } + } + } + } +} + +/// Check if a jti has already been applied, returning true if we should skip this message. +/// If the row exists with state=Applied → skip (idempotent). +/// If the row exists with state=Pending → was mid-apply crash, reconcile handles it. +pub async fn check_idempotency( + persistence: &Persistence, + jti: &str, +) -> Result { + if let Some(row) = persistence.get(jti).await? { + match row.state { + OpState::Applied => return Ok(true), + OpState::Rejected => return Ok(true), + OpState::Pending => { + // In-flight crash: will be picked up by recovery on next restart. + // For now, leave it and let the message be NACKed for retry. + return Err(ControllerError::Db(format!( + "jti {jti} is in state=Pending from a previous attempt — recovery will handle it" + ))); + } + } + } + Ok(false) +} diff --git a/crates/ops-controller/src/types.rs b/crates/ops-controller/src/types.rs new file mode 100644 index 0000000..60a6feb --- /dev/null +++ b/crates/ops-controller/src/types.rs @@ -0,0 +1,52 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Persistent state for an in-flight op tracked in SurrealDB. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InFlightOp { + pub jti: String, + pub op_type: String, + pub target: String, + pub state: OpState, + pub created_at: DateTime, + pub updated_at: DateTime, + pub error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OpState { + Pending, + Applied, + Rejected, +} + +impl OpState { + pub fn as_str(&self) -> &'static str { + match self { + OpState::Pending => "pending", + OpState::Applied => "applied", + OpState::Rejected => "rejected", + } + } +} + +/// Payload emitted to ops.ack.* after each op completes or fails. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AckPayload { + pub jti: String, + pub workspace: String, + pub op_type: String, + pub target: String, + pub result: AckResult, + pub error: Option, + pub timestamp: DateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AckResult { + Applied, + Rejected, + Skipped, +} diff --git a/crates/ops-keeper/.build-spec.ncl b/crates/ops-keeper/.build-spec.ncl new file mode 100644 index 0000000..f4b208a --- /dev/null +++ b/crates/ops-keeper/.build-spec.ncl @@ -0,0 +1,10 @@ +let bs = import "schemas/lib/build_spec.ncl" in + +{ + cpu = 4, + memory_gb = 4, + disk_gb = 20, + time_budget_min = 15, + cache_keys = ["ops-keeper", "rust-cargo"], + oom_retry = true, +} | bs.BuildSpec diff --git a/crates/ops-keeper/.woodpecker.yaml b/crates/ops-keeper/.woodpecker.yaml new file mode 100644 index 0000000..c02a81e --- /dev/null +++ b/crates/ops-keeper/.woodpecker.yaml @@ -0,0 +1,26 @@ +--- +# Pipeline for building and pushing the ops-keeper OCI image. +# Uses buildkit-launcher: spawns an ephemeral ARM64 hcloud VM, runs buildctl +# on it, pushes to zot, then destroys the VM. +# Triggered on push to main and on tag (for release images). + +when: + - event: push + branch: main + - event: tag + +steps: + build-image: + image: reg.librecloud.online/images/buildkit-launcher:latest + settings: + workspace: ops-keeper + context: . + dockerfile: platform/crates/ops-keeper/Dockerfile + image: reg.librecloud.online/images/ops-keeper:${CI_COMMIT_SHA} + cache_from: type=registry,ref=reg.librecloud.online/cache/ops-keeper + cache_to: type=registry,ref=reg.librecloud.online/cache/ops-keeper,mode=max + language: rust + secrets: + - orchestrator_token + - ssh_key + - zot_credentials diff --git a/crates/ops-keeper/Cargo.toml b/crates/ops-keeper/Cargo.toml new file mode 100644 index 0000000..8b79a1c --- /dev/null +++ b/crates/ops-keeper/Cargo.toml @@ -0,0 +1,38 @@ +[package] +authors.workspace = true +edition.workspace = true +license.workspace = true +name = "ops-keeper" +version.workspace = true +description = "Keeper daemon and CLI for JWT-signing provisioning ops commands (ADR-037)" + +[[bin]] +name = "keeper-daemon" +path = "bin/keeper_daemon.rs" + +[[bin]] +name = "keeper-cli" +path = "bin/keeper_cli.rs" + +[lib] +name = "ops_keeper" +path = "src/lib.rs" + +[dependencies] +async-nats = { workspace = true } +tokio = { workspace = true, features = ["full"] } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +clap = { workspace = true } +jsonwebtoken = { workspace = true } +globset = { workspace = true } +bytes = { workspace = true } +toml = { workspace = true } +futures = { workspace = true } +base64 = { workspace = true } diff --git a/crates/ops-keeper/Cargo.workspace.toml b/crates/ops-keeper/Cargo.workspace.toml new file mode 100644 index 0000000..a09b7a2 --- /dev/null +++ b/crates/ops-keeper/Cargo.workspace.toml @@ -0,0 +1,27 @@ +[workspace] +members = ["crates/ops-keeper"] +resolver = "2" + +[workspace.package] +authors = ["Jesus Perez "] +edition = "2021" +version = "1.0.11" + +[workspace.dependencies] +anyhow = "1.0" +async-nats = "0.46" +base64 = "0.22" +bytes = "1.11" +chrono = { version = "0.4", features = ["serde"] } +clap = { version = "4.5", features = ["derive", "env"] } +futures = "0.3" +globset = "0.4" +jsonwebtoken = { version = "10.3", default-features = false, features = ["aws_lc_rs"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "2.0" +tokio = { version = "1.49", features = ["full"] } +toml = "0.9" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1.20", features = ["v4", "serde"] } diff --git a/crates/ops-keeper/Dockerfile b/crates/ops-keeper/Dockerfile new file mode 100644 index 0000000..7a71a05 --- /dev/null +++ b/crates/ops-keeper/Dockerfile @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1.7 +# Build context: provisioning/ (pass --local context=. from provisioning/ root) +# Dockerfile path (buildctl): platform/crates/ops-keeper/Dockerfile +# Runs natively on ARM64 ephemeral runner (CAX/CCX) — no cross-compilation. +# aws_lc_rs (jsonwebtoken dep) requires cmake + perl for the C build. + +FROM rust:bookworm AS builder + +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config libssl-dev ca-certificates cmake perl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace + +COPY platform/crates/ops-keeper/Cargo.workspace.toml Cargo.toml +COPY platform/crates/ops-keeper/ crates/ops-keeper/ + +RUN --mount=type=cache,target=/root/.cargo/registry,sharing=locked \ + --mount=type=cache,target=/root/.cargo/git,sharing=locked \ + --mount=type=cache,target=/workspace/target,sharing=locked \ + cargo build --release --package ops-keeper && \ + cp target/release/keeper-daemon /keeper-daemon && \ + cp target/release/keeper-cli /keeper-cli + +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /keeper-daemon /keeper-daemon +COPY --from=builder /keeper-cli /keeper-cli + +ENTRYPOINT ["/keeper-daemon"] diff --git a/crates/ops-keeper/bin/keeper_cli.rs b/crates/ops-keeper/bin/keeper_cli.rs new file mode 100644 index 0000000..2624f08 --- /dev/null +++ b/crates/ops-keeper/bin/keeper_cli.rs @@ -0,0 +1,192 @@ +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; +use ops_keeper::{ + config::KeeperConfig, + nats_client::{KeeperNats, PeekedMessage}, + pending::parse_pending, + policy::{load_policy, PolicyMatcher}, + signer::Signer, + PendingOp, +}; +use tracing::warn; + +#[derive(Parser)] +#[command(name = "keeper-cli", about = "Interactive keeper operations CLI (ADR-037)")] +struct Cli { + #[arg(short, long, default_value = "keeper-daemon.toml")] + config: PathBuf, + + #[command(subcommand)] + command: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// List pending ops waiting for a keeper signature + List { + #[arg(short, long, default_value_t = 20)] + max: usize, + }, + /// Show details of a specific pending op by jti + Describe { + /// JTI or subject prefix to match + jti: String, + }, + /// Sign a pending op (loads private key from config) + Sign { + /// op_type:target to match (e.g., "deploy:staging-vapora") + target: String, + }, + /// Show the compiled policy decision for a hypothetical op + Simulate { + op_type: String, + target: String, + }, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let cli = Cli::parse(); + let cfg = KeeperConfig::from_toml(&cli.config)?; + + match cli.command { + Cmd::List { max } => { + let nats = KeeperNats::connect(&cfg).await?; + let peeked = nats.peek_pending(max).await?; + if peeked.is_empty() { + println!("No pending ops in queue."); + return Ok(()); + } + for msg in &peeked { + print_list_row(msg); + } + println!("{} pending op(s) shown (max {})", peeked.len(), max); + } + + Cmd::Describe { jti } => { + let nats = KeeperNats::connect(&cfg).await?; + let peeked = nats.peek_pending(100).await?; + let mut found = false; + for msg in &peeked { + found |= describe_msg(msg, &jti); + } + if !found { + eprintln!("No pending op matching '{jti}' found."); + std::process::exit(1); + } + } + + Cmd::Sign { target } => { + let private_pem = std::fs::read(&cfg.private_key_path)?; + let public_pem = std::fs::read(&cfg.public_key_path)?; + let signer = Signer::from_pem_files( + &private_pem, + &public_pem, + cfg.issuer_id.clone(), + cfg.workspace.clone(), + cfg.token_validity_secs, + )?; + + let nats = KeeperNats::connect(&cfg).await?; + let peeked = nats.peek_pending(100).await?; + + let (op_type_filter, target_filter) = target + .split_once(':') + .map(|(a, b)| (a.to_string(), b.to_string())) + .unwrap_or_else(|| ("*".to_string(), target.clone())); + + let mut signed = 0usize; + for msg in &peeked { + let did_sign = sign_msg(msg, &signer, &nats, &op_type_filter, &target_filter).await; + match did_sign { + Ok(true) => signed += 1, + Ok(false) => {} + Err(e) => return Err(e), + } + } + println!("{signed} op(s) signed."); + } + + Cmd::Simulate { op_type, target } => { + let policy = load_policy(&cfg.policy_path)?; + let matcher = PolicyMatcher::from_policy(&policy)?; + let op = PendingOp { + op_type: op_type.clone(), + target: target.clone(), + sub: "simulate".to_string(), + expected_state_version: "v0".to_string(), + image: String::new(), + params: serde_json::Value::Null, + }; + let decision = matcher.decide(&op); + println!("simulate op_type={op_type} target={target}"); + println!("decision: {}", decision.as_str()); + } + } + + Ok(()) +} + +fn print_list_row(msg: &PeekedMessage) { + match parse_pending(&msg.payload) { + Ok(op) => println!( + "{:<30} op_type={:<15} target={:<25} sub={}", + msg.subject, op.op_type, op.target, op.sub + ), + Err(_) => println!("{:<30} ", msg.subject), + } +} + +fn describe_msg(msg: &PeekedMessage, jti: &str) -> bool { + if !msg.subject.contains(jti) { + return false; + } + match parse_pending(&msg.payload) { + Ok(op) => { + println!("subject: {}", msg.subject); + println!("op_type: {}", op.op_type); + println!("target: {}", op.target); + println!("sub: {}", op.sub); + println!("version: {}", op.expected_state_version); + println!("image: {}", op.image); + println!("params: {}", op.params); + true + } + Err(e) => { + eprintln!("parse error for {}: {e}", msg.subject); + false + } + } +} + +async fn sign_msg( + msg: &PeekedMessage, + signer: &Signer, + nats: &KeeperNats, + op_type_filter: &str, + target_filter: &str, +) -> Result> { + let op = match parse_pending(&msg.payload) { + Ok(op) => op, + Err(e) => { + warn!(error = %e, "skipping unparseable message"); + return Ok(false); + } + }; + + let op_match = op_type_filter == "*" || op_type_filter == op.op_type; + let target_match = target_filter == "*" || op.target.contains(target_filter); + if !op_match || !target_match { + return Ok(false); + } + + let jwt = signer.sign_op(&op)?; + nats.publish_signed(&op.op_type, &jwt).await?; + println!("signed: {} → {}", msg.subject, op.target); + Ok(true) +} diff --git a/crates/ops-keeper/bin/keeper_daemon.rs b/crates/ops-keeper/bin/keeper_daemon.rs new file mode 100644 index 0000000..9133c8a --- /dev/null +++ b/crates/ops-keeper/bin/keeper_daemon.rs @@ -0,0 +1,128 @@ +use std::{path::PathBuf, sync::Arc, time::Duration}; + +use clap::Parser; +use futures::StreamExt; +use ops_keeper::{ + audit::emit_audit, + config::KeeperConfig, + nats_client::KeeperNats, + pending::{extract_op_type, log_decision, parse_pending}, + policy::{load_policy, Decision, PolicyMatcher}, + signer::Signer, +}; +use tracing::{error, info, warn}; + +#[derive(Parser)] +#[command(name = "keeper-daemon", about = "Auto-signing keeper daemon (ADR-037)")] +struct Cli { + #[arg(short, long, default_value = "keeper-daemon.toml")] + config: PathBuf, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let cli = Cli::parse(); + let cfg = KeeperConfig::from_toml(&cli.config)?; + + // Load signing key + let private_pem = std::fs::read(&cfg.private_key_path)?; + let public_pem = std::fs::read(&cfg.public_key_path)?; + let signer = Arc::new(Signer::from_pem_files( + &private_pem, + &public_pem, + cfg.issuer_id.clone(), + cfg.workspace.clone(), + cfg.token_validity_secs, + )?); + + // Load and compile policy + let policy = load_policy(&cfg.policy_path)?; + info!(version = policy.version, "policy loaded"); + let matcher = Arc::new(PolicyMatcher::from_policy(&policy)?); + + // Connect to NATS + let nats = Arc::new(KeeperNats::connect(&cfg).await?); + + info!("keeper-daemon ready; subscribing to ops.pending.{}.>", cfg.workspace); + + let consumer_name = format!("{}-keeper-daemon", cfg.workspace); + let mut messages = nats.pending_consumer(&consumer_name).await?; + + while let Some(msg_result) = messages.next().await { + let msg = match msg_result { + Ok(m) => m, + Err(e) => { + error!("NATS message error: {e}"); + continue; + } + }; + + let subject = msg.subject.to_string(); + let op_type = match extract_op_type(&subject) { + Some(t) => t.to_string(), + None => { + warn!(subject, "cannot extract op_type from subject; acking and skipping"); + let _ = msg.ack().await; + continue; + } + }; + + let op = match parse_pending(&msg.payload) { + Ok(op) => op, + Err(e) => { + error!(subject, error = %e, "failed to parse pending op; acking and skipping"); + let _ = msg.ack().await; + continue; + } + }; + + let decision = matcher.decide(&op); + log_decision(&op, decision.as_str()); + + match decision { + Decision::AutoSign => { + let jwt = match signer.sign_op(&op) { + Ok(jwt) => jwt, + Err(e) => { + error!(error = %e, "JWT signing failed; leaving in pending queue"); + // Do NOT ack — leave for retry + continue; + } + }; + + if let Err(e) = nats.publish_signed(&op_type, &jwt).await { + error!(error = %e, "failed to publish signed command; leaving in pending queue"); + continue; + } + + if let Err(e) = emit_audit(&nats, &op, &uuid::Uuid::new_v4().to_string(), signer.issuer_id(), decision.as_str()).await { + warn!(error = %e, "audit emit failed (non-fatal; signed command already published)"); + } + + if let Err(e) = msg.ack().await { + warn!(error = %e, "failed to ack NATS message after signing (idempotent on retry)"); + } + } + + Decision::RequireManual => { + if let Err(e) = emit_audit(&nats, &op, "hold", signer.issuer_id(), decision.as_str()).await { + warn!(error = %e, "audit emit failed for require_manual decision"); + } + // Re-NAK with backoff so JetStream re-delivers after a delay + let _ = msg.ack_with(async_nats::jetstream::AckKind::Nak(Some(Duration::from_secs(30)))).await; + } + + Decision::HoldPending => { + // Do not ack — message stays in WorkQueue until manually signed via keeper-cli + let _ = msg.ack_with(async_nats::jetstream::AckKind::Nak(Some(Duration::from_secs(30)))).await; + } + } + } + + warn!("NATS message stream ended; keeper-daemon exiting"); + Ok(()) +} diff --git a/crates/ops-keeper/src/audit.rs b/crates/ops-keeper/src/audit.rs new file mode 100644 index 0000000..df3b60a --- /dev/null +++ b/crates/ops-keeper/src/audit.rs @@ -0,0 +1,33 @@ +use bytes::Bytes; +use chrono::Utc; + +use crate::{ + error::KeeperError, + nats_client::KeeperNats, + types::{AuditEvent, PendingOp}, +}; + +/// Publish an audit event to ops.audit. for every signing decision. +/// Called for all decisions (auto_sign, require_manual, hold_pending) so the +/// audit stream has a complete record of what the keeper saw and decided. +pub async fn emit_audit( + nats: &KeeperNats, + op: &PendingOp, + jti: &str, + issuer_id: &str, + decision: &str, +) -> Result<(), KeeperError> { + let event = AuditEvent { + jti: jti.to_string(), + workspace: nats.workspace().to_string(), + op_type: op.op_type.clone(), + target: op.target.clone(), + signer_iss: issuer_id.to_string(), + signed_at: Utc::now().timestamp(), + decision: decision.to_string(), + }; + + let payload = serde_json::to_vec(&event)?; + nats.publish_audit(Bytes::from(payload)).await?; + Ok(()) +} diff --git a/crates/ops-keeper/src/config.rs b/crates/ops-keeper/src/config.rs new file mode 100644 index 0000000..8575367 --- /dev/null +++ b/crates/ops-keeper/src/config.rs @@ -0,0 +1,51 @@ +use std::path::PathBuf; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeeperConfig { + /// NATS server URL (e.g., "nats://127.0.0.1:4222") + pub nats_url: String, + + /// Workspace name (e.g., "libre-wuji") — determines OPS_PENDING/OPS_CMD stream names + pub workspace: String, + + /// Path to the Ed25519 private key in PKCS#8 PEM format + pub private_key_path: PathBuf, + + /// Path to the Ed25519 public key in PKCS#8 PEM format + pub public_key_path: PathBuf, + + /// Signer identity embedded in JWT `iss` claim (e.g., "keeper-vm-primary") + pub issuer_id: String, + + /// Path to policy.ncl file evaluated by nickel export at startup + pub policy_path: PathBuf, + + /// JWT validity window in seconds from signing time + pub token_validity_secs: u64, + + /// Optional NATS authentication token + pub nats_auth_token: Option, +} + +impl Default for KeeperConfig { + fn default() -> Self { + Self { + nats_url: "nats://127.0.0.1:4222".to_string(), + workspace: String::new(), + private_key_path: PathBuf::from("keeper.pem"), + public_key_path: PathBuf::from("keeper.pub.pem"), + issuer_id: "keeper-vm-primary".to_string(), + policy_path: PathBuf::from("policy.ncl"), + token_validity_secs: 3600, + nats_auth_token: None, + } + } +} + +impl KeeperConfig { + pub fn from_toml(path: &std::path::Path) -> Result { + let s = std::fs::read_to_string(path)?; + toml::from_str(&s).map_err(|e| crate::error::KeeperError::Policy(format!("config parse: {e}"))) + } +} diff --git a/crates/ops-keeper/src/error.rs b/crates/ops-keeper/src/error.rs new file mode 100644 index 0000000..2232a52 --- /dev/null +++ b/crates/ops-keeper/src/error.rs @@ -0,0 +1,25 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum KeeperError { + #[error("key error: {0}")] + Key(String), + + #[error("signing failed: {0}")] + Sign(String), + + #[error("NATS error: {0}")] + Nats(String), + + #[error("policy error: {0}")] + Policy(String), + + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("glob pattern error: {0}")] + Glob(String), +} diff --git a/crates/ops-keeper/src/lib.rs b/crates/ops-keeper/src/lib.rs new file mode 100644 index 0000000..b1a45a1 --- /dev/null +++ b/crates/ops-keeper/src/lib.rs @@ -0,0 +1,15 @@ +pub mod audit; +pub mod config; +pub mod error; +pub mod nats_client; +pub mod pending; +pub mod policy; +pub mod signer; +pub mod types; + +pub use config::KeeperConfig; +pub use error::KeeperError; +pub use nats_client::KeeperNats; +pub use policy::{Decision, PolicyDef, PolicyMatcher}; +pub use signer::Signer; +pub use types::{AuditEvent, OpsClaims, PendingOp, ScopeEntry}; diff --git a/crates/ops-keeper/src/nats_client.rs b/crates/ops-keeper/src/nats_client.rs new file mode 100644 index 0000000..6ac0506 --- /dev/null +++ b/crates/ops-keeper/src/nats_client.rs @@ -0,0 +1,154 @@ +use async_nats::{ + jetstream::{self, consumer::pull, Context}, + ConnectOptions, +}; +use bytes::Bytes; +use tracing::{info, warn}; + +use crate::{config::KeeperConfig, error::KeeperError}; + +/// Thin NATS + JetStream client for the keeper crates. +/// Wraps async-nats with keeper-specific subject naming conventions. +#[derive(Clone)] +pub struct KeeperNats { + js: Context, + workspace: String, +} + +impl KeeperNats { + pub async fn connect(cfg: &KeeperConfig) -> Result { + let opts = match &cfg.nats_auth_token { + Some(token) => ConnectOptions::new().token(token.clone()), + None => ConnectOptions::new(), + }; + + let client = opts + .connect(cfg.nats_url.as_str()) + .await + .map_err(|e| KeeperError::Nats(format!("connect to {}: {e}", cfg.nats_url)))?; + + info!(url = %cfg.nats_url, workspace = %cfg.workspace, "NATS connected"); + Ok(Self { + js: jetstream::new(client), + workspace: cfg.workspace.clone(), + }) + } + + /// Subscribe to ops.pending..> as a durable pull consumer. + /// Uses WorkQueue semantics — only one active consumer per stream is expected. + pub async fn pending_consumer( + &self, + consumer_name: &str, + ) -> Result { + let stream_name = format!("OPS_PENDING_{}", self.workspace.to_uppercase()); + let stream = self + .js + .get_stream(&stream_name) + .await + .map_err(|e| KeeperError::Nats(format!("get stream {stream_name}: {e}")))?; + + let consumer = stream + .get_or_create_consumer( + consumer_name, + pull::Config { + durable_name: Some(consumer_name.to_string()), + filter_subject: format!("ops.pending.{}.>", self.workspace), + ack_policy: async_nats::jetstream::consumer::AckPolicy::Explicit, + ..Default::default() + }, + ) + .await + .map_err(|e| KeeperError::Nats(format!("create consumer {consumer_name}: {e}")))?; + + consumer + .messages() + .await + .map_err(|e| KeeperError::Nats(format!("messages for {consumer_name}: {e}"))) + } + + /// Publish a signed JWT to ops.cmd.. + pub async fn publish_signed(&self, op_type: &str, jwt: &str) -> Result<(), KeeperError> { + let subject = format!("ops.cmd.{}.{}", self.workspace, op_type); + self.js + .publish(subject.clone(), Bytes::from(jwt.to_string())) + .await + .map_err(|e| KeeperError::Nats(format!("publish to {subject}: {e}")))? + .await + .map_err(|e| KeeperError::Nats(format!("ack for {subject}: {e}")))?; + Ok(()) + } + + /// Publish an audit event JSON to ops.audit. + pub async fn publish_audit(&self, payload: Bytes) -> Result<(), KeeperError> { + let subject = format!("ops.audit.{}", self.workspace); + self.js + .publish(subject.clone(), payload) + .await + .map_err(|e| KeeperError::Nats(format!("publish audit to {subject}: {e}")))? + .await + .map_err(|e| KeeperError::Nats(format!("audit ack for {subject}: {e}")))?; + Ok(()) + } + + /// Fetch pending ops visible from the pending stream (used by keeper-cli list). + /// Returns up to `max` messages without acking them. + pub async fn peek_pending(&self, max: usize) -> Result, KeeperError> { + let stream_name = format!("OPS_PENDING_{}", self.workspace.to_uppercase()); + let stream = self + .js + .get_stream(&stream_name) + .await + .map_err(|e| KeeperError::Nats(format!("get stream {stream_name}: {e}")))?; + + let consumer = stream + .get_or_create_consumer( + "__keeper_cli_peek__", + pull::Config { + durable_name: Some("__keeper_cli_peek__".to_string()), + ack_policy: async_nats::jetstream::consumer::AckPolicy::None, + ..Default::default() + }, + ) + .await + .map_err(|e| KeeperError::Nats(format!("peek consumer: {e}")))?; + + let mut messages_stream = consumer + .messages() + .await + .map_err(|e| KeeperError::Nats(format!("peek messages: {e}")))?; + + let mut result = Vec::new(); + for _ in 0..max { + // Non-blocking: break if no message available within a short timeout + let msg = tokio::time::timeout( + std::time::Duration::from_millis(200), + futures::StreamExt::next(&mut messages_stream), + ) + .await; + match msg { + Ok(Some(Ok(m))) => { + result.push(PeekedMessage { + subject: m.subject.to_string(), + payload: m.payload.clone(), + }); + } + _ => break, + } + } + Ok(result) + } + + pub fn workspace(&self) -> &str { + &self.workspace + } + + /// Log a warning-level NATS state check. + pub fn warn_if_disconnected(&self) { + warn!("NATS connection state check requested"); + } +} + +pub struct PeekedMessage { + pub subject: String, + pub payload: Bytes, +} diff --git a/crates/ops-keeper/src/pending.rs b/crates/ops-keeper/src/pending.rs new file mode 100644 index 0000000..9ceaa27 --- /dev/null +++ b/crates/ops-keeper/src/pending.rs @@ -0,0 +1,87 @@ +use bytes::Bytes; +use tracing::{debug, error, info, warn}; + +use crate::{error::KeeperError, types::PendingOp}; + +/// Parse a raw NATS message payload into a PendingOp. +pub fn parse_pending(payload: &Bytes) -> Result { + serde_json::from_slice(payload).map_err(|e| { + KeeperError::Json(serde_json::Error::io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("invalid PendingOp JSON: {e}"), + ))) + }) +} + +/// Derive the expected ops.cmd.* subject for a signed op. +/// Maps the ops.pending.. subject to ops.cmd... +pub fn pending_subject_to_cmd(pending_subject: &str) -> Option { + let parts: Vec<&str> = pending_subject.splitn(4, '.').collect(); + // Expected: ops.pending.. + if parts.len() == 4 && parts[0] == "ops" && parts[1] == "pending" { + Some(format!("ops.cmd.{}.{}", parts[2], parts[3])) + } else { + None + } +} + +/// Extract op_type from an ops.pending.* subject. +pub fn extract_op_type(pending_subject: &str) -> Option<&str> { + pending_subject.rsplit('.').next() +} + +/// Log a decision result at the appropriate level. +pub fn log_decision(op: &PendingOp, decision: &str) { + match decision { + "auto_sign" => info!( + op_type = %op.op_type, + target = %op.target, + sub = %op.sub, + "keeper auto-signing op" + ), + "require_manual" => warn!( + op_type = %op.op_type, + target = %op.target, + sub = %op.sub, + "op requires manual signature via keeper-cli" + ), + "hold_pending" => debug!( + op_type = %op.op_type, + target = %op.target, + "no policy rule matched; op remains in pending queue" + ), + other => error!(decision = other, "unknown decision value"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_valid_pending_op() { + let json = r#"{"op_type":"scale","target":"staging-vapora","sub":"woodpecker-42","expected_state_version":"v7"}"#; + let op = parse_pending(&Bytes::from(json)).unwrap(); + assert_eq!(op.op_type, "scale"); + assert_eq!(op.target, "staging-vapora"); + } + + #[test] + fn subject_mapping_roundtrip() { + let pending = "ops.pending.libre-wuji.scale"; + let cmd = pending_subject_to_cmd(pending).unwrap(); + assert_eq!(cmd, "ops.cmd.libre-wuji.scale"); + } + + #[test] + fn subject_mapping_rejects_malformed() { + assert!(pending_subject_to_cmd("ops.cmd.libre-wuji.scale").is_none()); + assert!(pending_subject_to_cmd("malformed").is_none()); + } + + #[test] + fn extract_op_type_from_subject() { + assert_eq!(extract_op_type("ops.pending.libre-wuji.deploy"), Some("deploy")); + assert_eq!(extract_op_type("ops.pending.libre-wuji.scale"), Some("scale")); + } +} diff --git a/crates/ops-keeper/src/policy.rs b/crates/ops-keeper/src/policy.rs new file mode 100644 index 0000000..0328375 --- /dev/null +++ b/crates/ops-keeper/src/policy.rs @@ -0,0 +1,239 @@ +use globset::{Glob, GlobSet, GlobSetBuilder}; +use serde::{Deserialize, Serialize}; + +use crate::{error::KeeperError, types::PendingOp}; + +// -------------------------------------------------------------------------- +// Schema mirror — matches keeper_policy.ncl PolicyDef and MatchRule exactly +// -------------------------------------------------------------------------- + +fn default_wildcard() -> String { + "*".to_string() +} + +fn default_wildcard_vec() -> Vec { + vec!["*".to_string()] +} + +fn default_version() -> u32 { + 1 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MatchRule { + #[serde(default = "default_wildcard")] + pub op_type: String, + #[serde(default = "default_wildcard_vec")] + pub image_patterns: Vec, + #[serde(default = "default_wildcard_vec")] + pub target_patterns: Vec, + #[serde(default = "default_wildcard_vec")] + pub scope_patterns: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PolicyDef { + #[serde(default = "default_version")] + pub version: u32, + #[serde(default)] + pub auto_sign: Vec, + #[serde(default)] + pub require_manual: Vec, +} + +// -------------------------------------------------------------------------- +// Compiled rule — GlobSets pre-built for O(1) matching per op +// -------------------------------------------------------------------------- + +struct CompiledRule { + op_type: String, + target_set: GlobSet, + image_set: GlobSet, +} + +/// Policy decision for a given pending op. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Decision { + AutoSign, + RequireManual, + HoldPending, +} + +impl Decision { + pub fn as_str(self) -> &'static str { + match self { + Self::AutoSign => "auto_sign", + Self::RequireManual => "require_manual", + Self::HoldPending => "hold_pending", + } + } +} + +/// Compiled policy matcher. Create once per policy load; call `decide` per op. +pub struct PolicyMatcher { + auto_sign: Vec, + require_manual: Vec, +} + +impl PolicyMatcher { + pub fn from_policy(policy: &PolicyDef) -> Result { + Ok(Self { + auto_sign: compile_rules(&policy.auto_sign)?, + require_manual: compile_rules(&policy.require_manual)?, + }) + } + + /// Evaluate the policy against a pending op. + /// Rules are checked top-to-bottom; first matching rule wins. + /// auto_sign rules are evaluated before require_manual rules. + pub fn decide(&self, op: &PendingOp) -> Decision { + for rule in &self.auto_sign { + if rule_matches(rule, op) { + return Decision::AutoSign; + } + } + for rule in &self.require_manual { + if rule_matches(rule, op) { + return Decision::RequireManual; + } + } + Decision::HoldPending + } +} + +fn compile_rules(rules: &[MatchRule]) -> Result, KeeperError> { + rules.iter().map(compile_rule).collect() +} + +fn compile_rule(rule: &MatchRule) -> Result { + let target_set = build_glob_set(&rule.target_patterns)?; + let image_set = build_glob_set(&rule.image_patterns)?; + Ok(CompiledRule { + op_type: rule.op_type.clone(), + target_set, + image_set, + }) +} + +fn build_glob_set(patterns: &[String]) -> Result { + let mut builder = GlobSetBuilder::new(); + for p in patterns { + let glob = Glob::new(p) + .map_err(|e| KeeperError::Glob(format!("invalid glob '{p}': {e}")))?; + builder.add(glob); + } + builder + .build() + .map_err(|e| KeeperError::Glob(e.to_string())) +} + +fn rule_matches(rule: &CompiledRule, op: &PendingOp) -> bool { + let op_type_ok = rule.op_type == "*" || rule.op_type == op.op_type; + let target_ok = rule.target_set.is_match(&op.target); + let image_ok = op.image.is_empty() || rule.image_set.is_match(&op.image); + op_type_ok && target_ok && image_ok +} + +// -------------------------------------------------------------------------- +// Policy file loading +// -------------------------------------------------------------------------- + +/// Load PolicyDef by running `nickel export` on policy_path. +/// The policy file must conform to keeper_policy.ncl — no functions allowed. +pub fn load_policy(policy_path: &std::path::Path) -> Result { + let output = std::process::Command::new("nickel") + .arg("export") + .arg(policy_path) + .output() + .map_err(|e| KeeperError::Policy(format!("nickel not found in PATH: {e}")))?; + + if !output.status.success() { + return Err(KeeperError::Policy(format!( + "nickel export '{}' failed: {}", + policy_path.display(), + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + + let policy: PolicyDef = serde_json::from_slice(&output.stdout)?; + if policy.version != 1 { + return Err(KeeperError::Policy(format!( + "unsupported policy schema version {}; expected 1", + policy.version + ))); + } + Ok(policy) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::PendingOp; + + fn make_op(op_type: &str, target: &str) -> PendingOp { + PendingOp { + op_type: op_type.to_string(), + target: target.to_string(), + sub: "test".to_string(), + expected_state_version: "v1".to_string(), + image: String::new(), + params: serde_json::Value::Null, + } + } + + #[test] + fn auto_sign_wildcard_matches_any_op() { + let policy = PolicyDef { + version: 1, + auto_sign: vec![MatchRule { + op_type: "*".to_string(), + target_patterns: vec!["dev-*".to_string()], + image_patterns: vec!["*".to_string()], + scope_patterns: vec!["*".to_string()], + }], + require_manual: vec![], + }; + let matcher = PolicyMatcher::from_policy(&policy).unwrap(); + assert_eq!(matcher.decide(&make_op("scale", "dev-vapora")), Decision::AutoSign); + assert_eq!(matcher.decide(&make_op("deploy", "dev-api")), Decision::AutoSign); + assert_eq!(matcher.decide(&make_op("scale", "prod-vapora")), Decision::HoldPending); + } + + #[test] + fn require_manual_checked_after_auto_sign_miss() { + let policy = PolicyDef { + version: 1, + auto_sign: vec![MatchRule { + op_type: "scale".to_string(), + target_patterns: vec!["staging-*".to_string()], + image_patterns: vec!["*".to_string()], + scope_patterns: vec!["*".to_string()], + }], + require_manual: vec![MatchRule { + op_type: "deploy".to_string(), + target_patterns: vec!["staging-*".to_string()], + image_patterns: vec!["*".to_string()], + scope_patterns: vec!["*".to_string()], + }], + }; + let matcher = PolicyMatcher::from_policy(&policy).unwrap(); + assert_eq!(matcher.decide(&make_op("scale", "staging-x")), Decision::AutoSign); + assert_eq!(matcher.decide(&make_op("deploy", "staging-x")), Decision::RequireManual); + assert_eq!(matcher.decide(&make_op("restart", "staging-x")), Decision::HoldPending); + } + + #[test] + fn invalid_glob_returns_error() { + let policy = PolicyDef { + version: 1, + auto_sign: vec![MatchRule { + op_type: "*".to_string(), + target_patterns: vec!["[invalid".to_string()], + image_patterns: vec!["*".to_string()], + scope_patterns: vec!["*".to_string()], + }], + require_manual: vec![], + }; + assert!(PolicyMatcher::from_policy(&policy).is_err()); + } +} diff --git a/crates/ops-keeper/src/signer.rs b/crates/ops-keeper/src/signer.rs new file mode 100644 index 0000000..9920a69 --- /dev/null +++ b/crates/ops-keeper/src/signer.rs @@ -0,0 +1,160 @@ +use std::{ + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use base64::Engine; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header}; +use uuid::Uuid; + +use crate::{ + error::KeeperError, + types::{OpsClaims, PendingOp, ScopeEntry, SingleOrVec}, +}; + +/// Stateful JWT signer: holds the loaded Ed25519 key pair and a monotonic sequence counter. +/// The `seq` counter is per-process; across restarts it resets, which is acceptable because +/// the ops-controller validates `jti` uniqueness (not `seq` ordering) as its idempotency key. +pub struct Signer { + encoding_key: EncodingKey, + decoding_key: DecodingKey, + pub issuer_id: String, + workspace: String, + token_validity_secs: u64, + seq: AtomicU64, +} + +impl Signer { + /// Load an Ed25519 signer from PKCS#8 PEM files. + /// The private key must be in PKCS#8 PEM format (e.g., output of + /// `openssl genpkey -algorithm ED25519 -out keeper.pem`). + pub fn from_pem_files( + private_pem: &[u8], + public_pem: &[u8], + issuer_id: String, + workspace: String, + token_validity_secs: u64, + ) -> Result { + let priv_der = pem_to_der(private_pem)?; + let pub_der = pem_to_der(public_pem)?; + let encoding_key = EncodingKey::from_ed_der(&priv_der); + let decoding_key = DecodingKey::from_ed_der(&pub_der); + Ok(Self { + encoding_key, + decoding_key, + issuer_id, + workspace, + token_validity_secs, + seq: AtomicU64::new(1), + }) + } + + /// Build and sign a JWT for the given pending op. + /// Returns the compact JWT string ready for publication to ops.cmd.*. + pub fn sign_op(&self, op: &PendingOp) -> Result { + let now = unix_now(); + let claims = OpsClaims { + iss: self.issuer_id.clone(), + sub: op.sub.clone(), + aud: SingleOrVec::single(&self.workspace), + scopes: vec![ScopeEntry { + op_type: op.op_type.clone(), + target_pattern: op.target.clone(), + }], + seq: self.seq.fetch_add(1, Ordering::SeqCst), + jti: Uuid::new_v4().to_string(), + expected_state_version: op.expected_state_version.clone(), + exp: now + self.token_validity_secs, + nbf: now, + op: op.clone(), + }; + jsonwebtoken::encode(&Header::new(Algorithm::EdDSA), &claims, &self.encoding_key) + .map_err(|e| KeeperError::Sign(e.to_string())) + } + + /// Verify a JWT signed by this signer. Used in tests and CLI describe command. + pub fn verify(&self, token: &str) -> Result { + let mut validation = jsonwebtoken::Validation::new(Algorithm::EdDSA); + validation.set_audience(&[&self.workspace]); + validation.validate_nbf = true; + jsonwebtoken::decode::(token, &self.decoding_key, &validation) + .map(|td| td.claims) + .map_err(|e| KeeperError::Sign(format!("JWT verification failed: {e}"))) + } + + pub fn issuer_id(&self) -> &str { + &self.issuer_id + } + + pub fn workspace(&self) -> &str { + &self.workspace + } +} + +/// Strip PEM headers/footers and base64-decode to DER bytes. +fn pem_to_der(pem: &[u8]) -> Result, KeeperError> { + let pem_str = std::str::from_utf8(pem) + .map_err(|e| KeeperError::Key(format!("PEM is not UTF-8: {e}")))?; + let body: String = pem_str + .lines() + .filter(|l| !l.starts_with("-----")) + .collect(); + base64::engine::general_purpose::STANDARD + .decode(body.as_bytes()) + .map_err(|e| KeeperError::Key(format!("PEM base64 decode: {e}"))) +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock is before Unix epoch") + .as_secs() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_test_signer() -> Signer { + // Minimal HMAC-secret signer for structural tests — not EdDSA, only used to + // verify seq counter and struct layout without requiring real Ed25519 keys. + Signer { + encoding_key: EncodingKey::from_secret(b"test-secret-at-least-32-bytes-long!!"), + decoding_key: DecodingKey::from_secret(b"test-secret-at-least-32-bytes-long!!"), + issuer_id: "test-keeper".to_string(), + workspace: "test-ws".to_string(), + token_validity_secs: 3600, + seq: AtomicU64::new(1), + } + } + + #[test] + fn seq_increments_on_each_sign_attempt() { + let signer = make_test_signer(); + let initial = signer.seq.load(Ordering::SeqCst); + let op = PendingOp { + op_type: "scale".into(), + target: "test".into(), + sub: "test-principal".into(), + expected_state_version: "v1".into(), + image: String::new(), + params: serde_json::Value::Null, + }; + // sign_op will fail (HMAC key + EdDSA header) but seq is incremented before encode + let _ = signer.sign_op(&op); + assert!(signer.seq.load(Ordering::SeqCst) > initial); + } + + #[test] + fn pem_to_der_strips_headers() { + let pem = b"-----BEGIN PUBLIC KEY-----\nYWJj\n-----END PUBLIC KEY-----\n"; + // "YWJj" decodes to b"abc" + assert_eq!(pem_to_der(pem).unwrap(), b"abc"); + } + + #[test] + fn pem_to_der_rejects_invalid_base64() { + let pem = b"-----BEGIN PUBLIC KEY-----\n!!!notbase64!!!\n-----END PUBLIC KEY-----\n"; + assert!(pem_to_der(pem).is_err()); + } +} diff --git a/crates/ops-keeper/src/types.rs b/crates/ops-keeper/src/types.rs new file mode 100644 index 0000000..75a4ba5 --- /dev/null +++ b/crates/ops-keeper/src/types.rs @@ -0,0 +1,80 @@ +use serde::{Deserialize, Serialize}; + +/// An unsigned ops proposal — arrives on ops.pending.. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PendingOp { + /// Operation type: deploy | scale | restart | secret_update | drain + pub op_type: String, + /// Deployment target name (e.g., "staging-vapora", "libre-wuji-api") + pub target: String, + /// Requesting principal (e.g., "woodpecker-job-42", "manual-jpl") + pub sub: String, + /// Optimistic concurrency token — workspace state version the emitter read + pub expected_state_version: String, + /// Image reference (for deploy ops; empty for others) + #[serde(default)] + pub image: String, + /// Additional op-specific parameters + #[serde(default)] + pub params: serde_json::Value, +} + +/// One scope tuple embedded in the JWT claims +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScopeEntry { + pub op_type: String, + pub target_pattern: String, +} + +/// JWT claims payload for a signed ops command (ops.cmd.*) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpsClaims { + /// Signer identity: keeper-vm-primary | operator- | gh-actions- + pub iss: String, + /// Requesting principal (copied from PendingOp.sub) + pub sub: String, + /// Target workspace name + pub aud: SingleOrVec, + /// Scope tuples authorised for this signer + pub scopes: Vec, + /// Per-issuer monotonic counter — anti-replay + pub seq: u64, + /// UUIDv4 idempotency key + pub jti: String, + /// Optimistic concurrency token (copied from PendingOp) + pub expected_state_version: String, + /// Unix timestamp: token expiry + pub exp: u64, + /// Unix timestamp: token not-valid-before + pub nbf: u64, + /// The pending op being authorised + pub op: PendingOp, +} + +/// Allows `aud` to be either a single string or an array — jsonwebtoken normalises this +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum SingleOrVec { + Single(String), + Many(Vec), +} + +impl SingleOrVec { + pub fn single(s: impl Into) -> Self { + Self::Single(s.into()) + } +} + +/// An audit event published to ops.audit. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuditEvent { + pub jti: String, + pub workspace: String, + pub op_type: String, + pub target: String, + pub signer_iss: String, + /// Unix timestamp (seconds) + pub signed_at: i64, + /// "auto_sign" | "require_manual" | "hold_pending" + pub decision: String, +} diff --git a/crates/orchestrator/Cargo.toml b/crates/orchestrator/Cargo.toml index cb593cf..80d6d90 100644 --- a/crates/orchestrator/Cargo.toml +++ b/crates/orchestrator/Cargo.toml @@ -38,6 +38,11 @@ tracing = { workspace = true } axum = { workspace = true } tower-http = { workspace = true, features = ["cors", "trace"] } +# Ontoref API catalog +ontoref-ontology = { workspace = true } +ontoref-derive = { workspace = true } +inventory = { workspace = true } + # CLI interface clap = { workspace = true } @@ -56,13 +61,13 @@ git2 = { workspace = true, optional = true } parking_lot = { workspace = true } # HTTP service clients (machines, init, AI) - enables remote service calls -service-clients = { workspace = true } +platform-clients = { workspace = true } # Platform configuration management platform-config = { workspace = true } # Centralized observability (logging, metrics, health, tracing) -observability = { workspace = true, features = ["logging", "metrics-prometheus", "health"] } +platform-observability = { workspace = true, features = ["logging", "metrics-prometheus", "health"] } # LRU cache for OCI manifests lru = { workspace = true } @@ -95,6 +100,9 @@ russh-keys = { workspace = true } # Path expansion for tilde (~) handling shellexpand = { workspace = true } +# Concurrent lease store for vm_pool (ADR-039) +dashmap = { workspace = true } + # ============================================================================ # FEATURE-GATED OPTIONAL DEPENDENCIES # ============================================================================ @@ -154,7 +162,7 @@ http-api = ["core"] surrealdb = ["dep:surrealdb"] # NATS event bus integration -nats = ["dep:platform-nats", "dep:platform-db"] +nats = ["dep:platform-nats", "dep:platform-db", "platform-db/embedded-surrealkv"] # GitOps webhook handler (requires git2) gitops = ["dep:git2"] diff --git a/crates/orchestrator/src/api_catalog.rs b/crates/orchestrator/src/api_catalog.rs new file mode 100644 index 0000000..261ea84 --- /dev/null +++ b/crates/orchestrator/src/api_catalog.rs @@ -0,0 +1,11 @@ +use axum::{extract::State, response::IntoResponse, Json}; +use ontoref_ontology::api::ApiRouteEntry; +use serde_json::json; + +use provisioning_orchestrator::SharedState; + +pub async fn api_catalog(State(_state): State) -> impl IntoResponse { + let mut routes: Vec<&'static ApiRouteEntry> = inventory::iter::().collect(); + routes.sort_by(|a, b| a.path.cmp(b.path).then(a.method.cmp(b.method))); + Json(json!({ "service": "orchestrator", "routes": routes })) +} diff --git a/crates/orchestrator/src/app_state_builder.rs b/crates/orchestrator/src/app_state_builder.rs index 6a9ae6b..14e73f8 100644 --- a/crates/orchestrator/src/app_state_builder.rs +++ b/crates/orchestrator/src/app_state_builder.rs @@ -100,7 +100,7 @@ mod tests { config: None, config_dir: None, mode: None, - port: 9090, + port: Some(9090), data_dir: "./data".to_string(), storage_type: "filesystem".to_string(), surrealdb_url: None, @@ -110,6 +110,7 @@ mod tests { surrealdb_password: None, nu_path: "./nu".to_string(), provisioning_path: "./provisioning".to_string(), + dump_api_catalog: false, }; let builder = DefaultOrchestratorAppStateBuilder::new(args); assert_eq!(builder.name(), "default-orchestrator-app-state"); diff --git a/crates/orchestrator/src/audit/collector.rs b/crates/orchestrator/src/audit/collector.rs index d6a5c22..d17b2de 100644 --- a/crates/orchestrator/src/audit/collector.rs +++ b/crates/orchestrator/src/audit/collector.rs @@ -88,18 +88,27 @@ pub async fn run_audit_collector(nats: Arc, db: Arc) { async fn flush_batch(events: Vec, db: Arc) { let surreal = db.db(); - if let Err(e) = surreal.query("USE NS audit DB provisioning").await { - error!("audit flush: USE NS failed — {e}"); - return; - } - let count = events.len(); + for event in events { - if let Err(e) = surreal - .create::>("events") - .content(event) + let id = uuid::Uuid::new_v4().to_string(); + let record = match serde_json::to_value(&event) { + Ok(v) => v, + Err(e) => { + error!("audit flush: serialize failed — {e}"); + continue; + } + }; + + let result = surreal + .query("USE NS audit DB provisioning; CREATE type::thing('events', $id) CONTENT $record") + .bind(("id", id)) + .bind(("record", record)) .await - { + .map_err(|e| format!("transport: {e}")) + .and_then(|r| r.check().map_err(|e| format!("statement: {e}"))); + + if let Err(e) = result { error!("audit flush: INSERT failed — {e}"); } } diff --git a/crates/orchestrator/src/batch.rs b/crates/orchestrator/src/batch.rs index 96d3596..06c49fc 100644 --- a/crates/orchestrator/src/batch.rs +++ b/crates/orchestrator/src/batch.rs @@ -16,6 +16,7 @@ use tracing::info; use uuid::Uuid; use crate::{ + formula::{Formula, FormulaWorkflowConfig}, storage::TaskStorage, workflow::{ BatchWorkflowEngine, WorkflowConfig, WorkflowDefinition, WorkflowExecutionState, @@ -140,7 +141,7 @@ impl Default for BatchDefaults { Self { check_mode: false, wait: true, - provisioning_path: "./core/nulib/provisioning".to_string(), + provisioning_path: "provisioning".to_string(), nu_path: "nu".to_string(), environment, } @@ -306,8 +307,12 @@ impl BatchCoordinator { request.config, )?; - // Execute workflow - let execution_state = self.workflow_engine.execute_workflow(workflow_def).await?; + // Execute workflow — workspace derived from the infrastructure key so formula events + // carry it for SurrealDB dag_run auto-creation. + let execution_state = self + .workflow_engine + .execute_workflow(workflow_def, Some(&request.infrastructure)) + .await?; // Collect task results let task_results = self.collect_task_results(&execution_state).await?; @@ -646,6 +651,79 @@ impl BatchCoordinator { Ok(tasks) } + /// Execute a batch operation from a pre-validated Formula DAG JSON string. + /// + /// The JSON string is the output of `nickel export --format json `. + /// Returns a `BatchOperationResult` using the real DAG dependency graph instead + /// of the positional linear chain produced by `build_taskservice_tasks`. + pub async fn execute_formula_operation( + &self, + formula_json: &str, + operation: TaskServiceOperation, + infrastructure: &str, + ) -> Result { + let operation_id = Uuid::new_v4().to_string(); + let formula = Formula::from_json(formula_json)?; + + let infra_config = self + .config + .infrastructure + .get(infrastructure) + .ok_or_else(|| { + anyhow::anyhow!("Infrastructure '{}' not found in configuration", infrastructure) + })? + .clone(); + + let provider_config = self + .config + .providers + .get(&infra_config.provider) + .ok_or_else(|| { + anyhow::anyhow!("Provider '{}' not found", infra_config.provider) + })?; + + let operation_cmd = match operation { + TaskServiceOperation::Create => "taskserv create", + TaskServiceOperation::Delete => "taskserv delete", + TaskServiceOperation::Update => "taskserv update", + TaskServiceOperation::Generate => "taskserv generate", + TaskServiceOperation::CheckUpdates => "taskserv check-updates", + TaskServiceOperation::ListVersions => "taskserv versions", + }; + + let mut environment = self.config.defaults.environment.clone(); + environment.insert("PROVISIONING_ENV".to_string(), infra_config.environment.clone()); + + let workflow_def = formula.into_workflow(FormulaWorkflowConfig { + operation_cmd, + infra_name: &infra_config.name, + settings_path: &infra_config.settings_path, + provisioning_path: &self.config.defaults.provisioning_path, + environment, + check_mode: self.config.defaults.check_mode, + wait: self.config.defaults.wait, + })?; + + info!( + "Executing formula DAG '{}' as workflow '{}' (op: {})", + infrastructure, operation_id, operation_cmd + ); + + let execution_state = self + .workflow_engine + .execute_workflow(workflow_def, Some(infrastructure)) + .await?; + let task_results = self.collect_task_results(&execution_state).await?; + let summary = self.generate_operation_summary(&execution_state, &task_results); + + Ok(BatchOperationResult { + operation_id, + execution_state, + task_results, + summary, + }) + } + /// Order task services by dependencies fn order_taskservices(&self, services: &[String]) -> Result> { // Predefined task service dependency order diff --git a/crates/orchestrator/src/config.rs b/crates/orchestrator/src/config.rs index 542cc3c..3b9d129 100644 --- a/crates/orchestrator/src/config.rs +++ b/crates/orchestrator/src/config.rs @@ -38,6 +38,25 @@ pub struct OrchestratorSettings { pub performance: Option, #[serde(default)] pub build: Option, + #[serde(default)] + pub features: FeaturesConfig, +} + +/// Feature flags for gradual endpoint rollout. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FeaturesConfig { + /// Enable the unified `/api/v1/workflows/component/{op}` endpoint. + /// Set to false to return 404 and preserve legacy `/workflows/taskserv/create` routing. + #[serde(default = "default_true")] + pub enable_component_endpoint: bool, +} + +impl Default for FeaturesConfig { + fn default() -> Self { + Self { + enable_component_endpoint: true, + } + } } /// Workspace configuration (from workspace.ncl schema) @@ -512,56 +531,97 @@ impl Default for OrchestratorSettings { security: None, performance: None, build: None, + features: FeaturesConfig::default(), } } } impl OrchestratorConfig { - /// Load configuration from Nickel orchestrator.ncl + /// Load configuration from Nickel orchestrator.ncl with env overrides + /// validated by Nickel contracts. Invalid overrides (e.g., port out of + /// range) are rejected before deserialization. pub fn load() -> Result { - let config_json = platform_config::load_service_config_from_ncl("orchestrator") - .context("Failed to load orchestrator configuration from Nickel")?; + let overrides = Self::build_env_overrides(); + let config_json = + platform_config::load_service_config_from_ncl_with_overrides("orchestrator", &overrides) + .context("Failed to load orchestrator configuration from Nickel")?; - let config: OrchestratorConfig = serde_json::from_value(config_json) - .context("Failed to deserialize orchestrator configuration")?; - - let mut config = config; - Self::apply_env_overrides(&mut config)?; - Ok(config) + serde_json::from_value(config_json) + .context("Failed to deserialize orchestrator configuration") } - /// Apply environment variable overrides to configuration - /// Environment variables use format: ORCHESTRATOR_{SECTION}_{KEY}=value - fn apply_env_overrides(config: &mut Self) -> Result<()> { - // Server overrides + /// Load with both env and CLI overrides, all validated by Nickel contracts + pub fn load_with_cli_args(args: &crate::Args) -> Result { + let mut overrides = Self::build_env_overrides(); + Self::merge_cli_overrides(&mut overrides, args); + + let config_json = + platform_config::load_service_config_from_ncl_with_overrides("orchestrator", &overrides) + .context("Failed to load orchestrator configuration from Nickel")?; + + serde_json::from_value(config_json) + .context("Failed to deserialize orchestrator configuration") + } + + /// Collect environment variable overrides as a JSON Value matching + /// the NCL schema shape. These are passed to Nickel for validation. + fn build_env_overrides() -> serde_json::Value { + let mut server = serde_json::Map::new(); + if let Ok(host) = std::env::var("ORCHESTRATOR_SERVER_HOST") { - config.orchestrator.server.host = host; + server.insert("host".into(), serde_json::Value::String(host)); } if let Ok(port) = std::env::var("ORCHESTRATOR_SERVER_PORT") { - config.orchestrator.server.port = port - .parse() - .context("ORCHESTRATOR_SERVER_PORT must be a valid port number")?; + if let Ok(p) = port.parse::() { + server.insert("port".into(), serde_json::Value::Number(p.into())); + } } if let Ok(workers) = std::env::var("ORCHESTRATOR_SERVER_WORKERS") { - config.orchestrator.server.workers = workers - .parse() - .context("ORCHESTRATOR_SERVER_WORKERS must be a valid number")?; - } - - // Logging overrides - if let Ok(level) = std::env::var("ORCHESTRATOR_LOG_LEVEL") { - if let Some(ref mut logging) = config.orchestrator.logging { - logging.level = level; + if let Ok(w) = workers.parse::() { + server.insert("workers".into(), serde_json::Value::Number(w.into())); } } - Ok(()) + let mut orchestrator = serde_json::Map::new(); + + if !server.is_empty() { + orchestrator.insert("server".into(), serde_json::Value::Object(server)); + } + + if let Ok(level) = std::env::var("ORCHESTRATOR_LOG_LEVEL") { + orchestrator.insert( + "logging".into(), + serde_json::json!({ "level": level }), + ); + } + + if orchestrator.is_empty() { + serde_json::Value::Object(serde_json::Map::new()) + } else { + serde_json::json!({ "orchestrator": serde_json::Value::Object(orchestrator) }) + } } - /// Apply CLI argument overrides to the configuration - pub fn apply_cli_overrides(&mut self, args: &crate::Args) { + /// Merge CLI argument overrides into the overrides JSON Value + fn merge_cli_overrides(overrides: &mut serde_json::Value, args: &crate::Args) { if let Some(port) = args.port { - self.orchestrator.server.port = port; + let port_val = serde_json::Value::Number(serde_json::Number::from(port)); + if let Some(orch) = overrides + .as_object_mut() + .and_then(|o| o.entry("orchestrator").or_insert_with(|| serde_json::json!({})).as_object_mut()) + { + if let Some(srv) = orch + .entry("server") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + { + srv.insert("port".into(), port_val); + } + } else { + *overrides = serde_json::json!({ + "orchestrator": { "server": { "port": port } } + }); + } } } @@ -606,34 +666,20 @@ impl ConfigLoader for OrchestratorConfig { "orchestrator" } - fn load_from_hierarchy() -> std::result::Result> - { - let service = Self::service_name(); - - if let Some(path) = platform_config::resolve_config_path(service) { - return Self::from_path(&path); - } - - Ok(Self::default()) - } - - fn apply_env_overrides( - &mut self, - ) -> std::result::Result<(), Box> { - Self::apply_env_overrides(self).map_err(|e| { - Box::new(std::io::Error::other(e.to_string())) - as Box - }) + fn collect_env_overrides() -> serde_json::Value { + Self::build_env_overrides() } fn from_path>( path: P, ) -> std::result::Result> { let path = path.as_ref(); - let json_value = platform_config::format::load_config(path).map_err(|e| { - let err: Box = Box::new(e); - err - })?; + let overrides = Self::collect_env_overrides(); + let json_value = + platform_config::format::load_config_with_overrides(path, &overrides).map_err(|e| { + let err: Box = Box::new(e); + err + })?; serde_json::from_value(json_value).map_err(|e| { let err_msg = format!( diff --git a/crates/orchestrator/src/formula.rs b/crates/orchestrator/src/formula.rs new file mode 100644 index 0000000..5b0a5b2 --- /dev/null +++ b/crates/orchestrator/src/formula.rs @@ -0,0 +1,1025 @@ +//! Formula DAG — typed workspace taskserv execution graph. +//! +//! A `Formula` is the Rust-side representation of a `schemas/lib/formula.ncl` export. +//! The Orchestrator uses it to build a real `DependencyGraph` instead of the linear +//! positional chain that `batch.rs` previously created. +//! +//! Pipeline: +//! `nickel export formula.ncl --format json` → `Formula::from_json` → `WorkflowDefinition` + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::workflow::{WorkflowConfig, WorkflowDefinition, WorkflowTaskDefinition}; + +/// How a node responds to its own failure. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub enum OnError { + #[default] + Stop, + Continue, + Retry, +} + +/// Dependency edge kind inside a FormulaNode's `depends_on` list. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub enum DepKind { + Always, + #[default] + OnSuccess, + OnFailure, +} + +/// Intra-node dependency declaration: `node_id` of the dependency and when to trigger. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FormulaDep { + pub node_id: String, + #[serde(default)] + pub kind: DepKind, +} + +/// Capability kind as declared in `ExtensionDependency.kind`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub enum CapabilityKind { + #[default] + Required, + Optional, + ConflictsWith, +} + +/// A capability provided by an extension (maps to `ExtensionCapability` in Nickel). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ExtensionCapability { + pub id: String, + pub version: String, + pub interface: String, +} + +/// A capability required by an extension (maps to `ExtensionDependency` in Nickel). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ExtensionDependency { + pub capability: String, + #[serde(default)] + pub kind: CapabilityKind, + pub min_version: Option, +} + +/// A taskserv definition embedded in a FormulaNode (maps to `TaskServDef` in Nickel). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskServDef { + pub name: String, + #[serde(default = "default_install_mode")] + pub install_mode: String, + #[serde(default = "default_profile")] + pub profile: String, + #[serde(default)] + pub target_save_path: String, + /// Capabilities this taskserv satisfies — from extension metadata provides[]. + #[serde(default)] + pub provides: Vec, + /// Capabilities this taskserv requires — from extension metadata requires[]. + #[serde(default)] + pub requires: Vec, + /// Extension names this taskserv conflicts with — from extension metadata conflicts_with[]. + #[serde(default)] + pub conflicts_with: Vec, +} + +fn default_install_mode() -> String { + "library".to_string() +} + +fn default_profile() -> String { + "default".to_string() +} + +fn default_deploy_mode() -> DeployMode { + DeployMode::Cluster +} + +/// Deployment mode for a component node — determines where and how the component is installed. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DeployMode { + Taskserv, + #[default] + Cluster, + Container, +} + +impl std::fmt::Display for DeployMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DeployMode::Taskserv => write!(f, "taskserv"), + DeployMode::Cluster => write!(f, "cluster"), + DeployMode::Container => write!(f, "container"), + } + } +} + +/// A component definition embedded in a FormulaNode (maps to `ComponentDef` in Nickel). +/// +/// Present when a node uses the unified L3+ component model instead of the legacy `taskserv` field. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComponentDef { + pub name: String, + #[serde(default = "default_deploy_mode")] + pub mode: DeployMode, + pub target: Option, + pub namespace: Option, + #[serde(default = "default_install_mode")] + pub install_mode: String, + #[serde(default = "default_profile")] + pub profile: String, + #[serde(default)] + pub target_save_path: String, +} + +/// A node in the Formula DAG — wraps either a `TaskServDef` (L2, legacy) or a `ComponentDef` +/// (L3+ unified model) with execution metadata. Exactly one of the two fields must be present. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FormulaNode { + pub id: String, + /// L2 legacy nodes — present when `component` is absent. + pub taskserv: Option, + /// L3+ unified model nodes — present when `taskserv` is absent. + pub component: Option, + #[serde(default)] + pub depends_on: Vec, + #[serde(default)] + pub parallel: bool, + #[serde(default)] + pub on_error: OnError, + #[serde(default)] + pub max_retries: u8, +} + +/// An explicit edge between two FormulaNodes. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FormulaEdge { + pub from: String, + pub to: String, + #[serde(default)] + pub kind: DepKind, +} + +/// The Formula DAG — exported from `schemas/lib/formula.ncl` via `nickel export --format json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Formula { + pub id: String, + pub description: String, + pub provider: String, + pub server: String, + pub nodes: Vec, + #[serde(default)] + pub edges: Vec, + #[serde(default = "default_max_parallel")] + pub max_parallel: usize, +} + +fn default_max_parallel() -> usize { + 4 +} + +/// Configuration for converting a `Formula` into a `WorkflowDefinition`. +pub struct FormulaWorkflowConfig<'a> { + pub operation_cmd: &'a str, + pub infra_name: &'a str, + pub settings_path: &'a str, + pub provisioning_path: &'a str, + pub environment: HashMap, + pub check_mode: bool, + pub wait: bool, +} + +impl Formula { + /// Deserialize from the JSON output of `nickel export --format json formula.ncl`. + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json).context("Failed to deserialize Formula from nickel export JSON") + } + + /// Build a `WorkflowDefinition` from this formula. + /// + /// Each `FormulaNode` becomes a `WorkflowTaskDefinition`. Dependencies are wired + /// from `depends_on` (node-level) and `edges` (formula-level). The resulting + /// `WorkflowDefinition` feeds directly into `BatchWorkflowEngine::execute_workflow`, + /// which runs the `DependencyGraph` topological sort and respects `max_parallel_tasks`. + pub fn into_workflow(self, cfg: FormulaWorkflowConfig<'_>) -> Result { + let FormulaWorkflowConfig { + operation_cmd, + infra_name, + settings_path, + provisioning_path, + environment, + check_mode, + wait, + } = cfg; + // Build node_id → task_name mapping (task names are unique within the workflow). + let task_names: HashMap = self + .nodes + .iter() + .map(|n| { + let logical_name = Self::node_logical_name(n); + (n.id.clone(), Self::task_name(operation_cmd, &n.id, logical_name)) + }) + .collect(); + + let mut tasks = Vec::with_capacity(self.nodes.len()); + + for node in &self.nodes { + let task_name = task_names[&node.id].clone(); + + // Collect dependencies from node.depends_on + formula-level edges that point to this node. + let mut dep_task_names: Vec = node + .depends_on + .iter() + .filter_map(|dep| task_names.get(&dep.node_id).cloned()) + .collect(); + + // Formula-level edges supplement node-level depends_on. + for edge in &self.edges { + if edge.to == node.id { + if let Some(src_task) = task_names.get(&edge.from) { + if !dep_task_names.contains(src_task) { + dep_task_names.push(src_task.clone()); + } + } + } + } + + let max_retries = if node.on_error == OnError::Retry { + Some(node.max_retries.max(1)) + } else { + Some(0) + }; + + let mut meta = HashMap::new(); + meta.insert("formula_id".to_string(), self.id.clone()); + meta.insert("node_id".to_string(), node.id.clone()); + meta.insert("provider".to_string(), self.provider.clone()); + meta.insert("server".to_string(), self.server.clone()); + meta.insert("parallel".to_string(), node.parallel.to_string()); + meta.insert("on_error".to_string(), format!("{:?}", node.on_error)); + + let (command, args) = match (&node.taskserv, &node.component) { + (Some(ts), None) => { + meta.insert("taskserv".to_string(), ts.name.clone()); + let mut a = vec![ + ts.name.clone(), + "--infra".to_string(), + infra_name.to_string(), + "--settings".to_string(), + settings_path.to_string(), + ]; + if ts.profile != "default" { + a.push("--profile".to_string()); + a.push(ts.profile.clone()); + } + if !ts.target_save_path.is_empty() { + a.push("--target-path".to_string()); + a.push(ts.target_save_path.clone()); + } + if check_mode { + a.push("--check".to_string()); + } + if wait { + a.push("--wait".to_string()); + } + (format!("{provisioning_path} {operation_cmd}"), a) + } + (None, Some(comp)) => { + let mode_str = comp.mode.to_string(); + meta.insert("component".to_string(), comp.name.clone()); + meta.insert("mode".to_string(), mode_str.clone()); + let mut a = vec![ + comp.name.clone(), + "--mode".to_string(), + mode_str, + "--infra".to_string(), + infra_name.to_string(), + "--settings".to_string(), + settings_path.to_string(), + ]; + if comp.profile != "default" { + a.push("--profile".to_string()); + a.push(comp.profile.clone()); + } + if !comp.target_save_path.is_empty() { + a.push("--target-path".to_string()); + a.push(comp.target_save_path.clone()); + } + if let Some(ref ns) = comp.namespace { + a.push("--namespace".to_string()); + a.push(ns.clone()); + } + if check_mode { + a.push("--check".to_string()); + } + if wait { + a.push("--wait".to_string()); + } + (format!("{provisioning_path} component install"), a) + } + (Some(_), Some(_)) => { + anyhow::bail!( + "FormulaNode '{}' has both `taskserv` and `component` — only one is allowed", + node.id + ); + } + (None, None) => { + anyhow::bail!( + "FormulaNode '{}' has neither `taskserv` nor `component` — one is required", + node.id + ); + } + }; + + tasks.push(WorkflowTaskDefinition { + name: task_name, + command, + args, + dependencies: dep_task_names, + provider: Some(self.provider.clone()), + timeout_seconds: None, + max_retries, + environment: Some(environment.clone()), + metadata: Some(meta), + }); + } + + Ok(WorkflowDefinition { + name: format!("formula_{}", self.id), + description: Some(self.description.clone()), + tasks, + config: Some(WorkflowConfig { + max_parallel_tasks: self.max_parallel, + ..WorkflowConfig::default() + }), + }) + } + + /// Return the logical name used in task naming: taskserv name for L2 nodes, component name for L3+. + fn node_logical_name(node: &FormulaNode) -> &str { + if let Some(ref ts) = node.taskserv { + return ts.name.as_str(); + } + if let Some(ref comp) = node.component { + return comp.name.as_str(); + } + node.id.as_str() + } + + fn task_name(operation_cmd: &str, node_id: &str, logical_name: &str) -> String { + format!( + "{}_{}_{}", + operation_cmd.replace(' ', "_"), + node_id, + logical_name + ) + } +} + +// --------------------------------------------------------------------------- +// Workspace Composition — inter-formula DAG +// --------------------------------------------------------------------------- + +/// Condition required from a depended formula before the dependent formula starts. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub enum CompositionCondition { + Completed, + Healthy, + Running, +} + +/// Dependency edge between two formulas in a WorkspaceComposition. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InterFormulaDep { + pub formula_id: String, + pub condition: CompositionCondition, +} + +/// Health gate checked between two formula groups. +/// Inserted as a synthetic `WorkflowTaskDefinition` between depended terminals and dependent roots. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompositionHealthGate { + pub check_cmd: String, + pub expect: String, + pub timeout_ms: u64, + pub retries: u8, +} + +/// One formula entry in the composition graph. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FormulaCompositionEntry { + pub formula_id: String, + #[serde(default)] + pub depends_on: Vec, + #[serde(default)] + pub parallel: bool, + pub health_gate: Option, +} + +/// The composition graph field inside WorkspaceComposition. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompositionGraph { + pub formulas: Vec, +} + +/// Workspace-level inter-formula DAG. Deserialized from `dag.ncl` via `nickel export --format json`. +/// +/// Composes multiple per-server `Formula` DAGs into a single `WorkflowDefinition`, +/// injecting health gate tasks and inter-formula dependency edges. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceComposition { + pub workspace: String, + pub infra: String, + pub composition: CompositionGraph, +} + +impl WorkspaceComposition { + /// Deserialize from the JSON output of `nickel export --format json dag.ncl`. + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json) + .context("Failed to deserialize WorkspaceComposition from dag.ncl export") + } + + /// Build a single `WorkflowDefinition` from all formulas in the composition. + /// + /// Per formula: calls `Formula::into_workflow`, namespaces all task names as + /// `"{formula_id}::{task_name}"`, computes root tasks (no dependencies) and + /// terminal tasks (no dependents). Then for each inter-formula dependency: + /// if a health gate is declared, injects a synthetic task between them; + /// otherwise wires terminal → root directly. + pub fn into_workflow( + self, + formulas: Vec, + cfg: FormulaWorkflowConfig<'_>, + ) -> Result { + use std::collections::{HashMap, HashSet}; + + let formula_map: HashMap = formulas + .into_iter() + .map(|f| (f.id.clone(), f)) + .collect(); + + // Validate all formula_id references before doing any work. + for entry in &self.composition.formulas { + if !formula_map.contains_key(&entry.formula_id) { + anyhow::bail!( + "dag.ncl references formula_id '{}' not found in servers.ncl export", + entry.formula_id + ); + } + for dep in &entry.depends_on { + if !formula_map.contains_key(&dep.formula_id) { + anyhow::bail!( + "dag.ncl depends_on references unknown formula_id '{}'", + dep.formula_id + ); + } + } + } + + let mut all_tasks: Vec = Vec::new(); + let mut formula_roots: HashMap> = HashMap::new(); + let mut formula_terminals: HashMap> = HashMap::new(); + + for entry in &self.composition.formulas { + let formula = formula_map[&entry.formula_id].clone(); + let prefix = entry.formula_id.clone(); + + let wf = formula.into_workflow(FormulaWorkflowConfig { + operation_cmd: cfg.operation_cmd, + infra_name: &entry.formula_id, + settings_path: cfg.settings_path, + provisioning_path: cfg.provisioning_path, + environment: cfg.environment.clone(), + check_mode: cfg.check_mode, + wait: cfg.wait, + })?; + + // Namespace all task names and their dependency references. + let namespaced: Vec = wf.tasks.into_iter().map(|t| { + WorkflowTaskDefinition { + name: format!("{}::{}", prefix, t.name), + dependencies: t.dependencies.into_iter() + .map(|d| format!("{}::{}", prefix, d)) + .collect(), + ..t + } + }).collect(); + + // Root tasks: no dependencies within this formula. + let dep_targets: HashSet = namespaced.iter() + .flat_map(|t| t.dependencies.iter().cloned()) + .collect(); + + let roots: Vec = namespaced.iter() + .filter(|t| t.dependencies.is_empty()) + .map(|t| t.name.clone()) + .collect(); + + // Terminal tasks: no other task depends on them within this formula. + let terminals: Vec = namespaced.iter() + .filter(|t| !dep_targets.contains(&t.name)) + .map(|t| t.name.clone()) + .collect(); + + formula_roots.insert(entry.formula_id.clone(), roots); + formula_terminals.insert(entry.formula_id.clone(), terminals); + all_tasks.extend(namespaced); + } + + // Wire inter-formula dependencies, injecting health gates as synthetic tasks. + for entry in &self.composition.formulas { + for dep in &entry.depends_on { + let depended_terminals = formula_terminals + .get(&dep.formula_id) + .with_context(|| format!("terminal_tasks missing for '{}'", dep.formula_id))? + .clone(); + + let dependent_roots = formula_roots + .get(&entry.formula_id) + .with_context(|| format!("root_tasks missing for '{}'", entry.formula_id))? + .clone(); + + if let Some(gate) = &entry.health_gate { + let gate_name = format!( + "{}::health-gate::{}", + dep.formula_id, entry.formula_id + ); + let gate_task = WorkflowTaskDefinition { + name: gate_name.clone(), + command: gate.check_cmd.clone(), + args: vec![], + dependencies: depended_terminals, + provider: None, + timeout_seconds: Some(gate.timeout_ms / 1000), + max_retries: Some(gate.retries), + environment: None, + metadata: Some(HashMap::from([ + ("type".to_string(), "health-gate".to_string()), + ("expect".to_string(), gate.expect.clone()), + ("depended_formula".to_string(), dep.formula_id.clone()), + ("dependent_formula".to_string(), entry.formula_id.clone()), + ])), + }; + for root in &dependent_roots { + if let Some(task) = all_tasks.iter_mut().find(|t| &t.name == root) { + task.dependencies.push(gate_name.clone()); + } + } + all_tasks.push(gate_task); + } else { + // No health gate: dependent roots wait directly on depended terminals. + for root in &dependent_roots { + if let Some(task) = all_tasks.iter_mut().find(|t| &t.name == root) { + task.dependencies.extend(depended_terminals.clone()); + } + } + } + } + } + + Ok(WorkflowDefinition { + name: format!("{}::{}", self.workspace, self.infra), + description: Some(format!( + "Composed workflow: {} formulas for {}/{}", + self.composition.formulas.len(), + self.workspace, + self.infra, + )), + tasks: all_tasks, + config: None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_FORMULA_JSON: &str = r#"{ + "id": "wuji-cp-0-formula", + "description": "Control plane DAG", + "provider": "hetzner", + "server": "wuji-cp-0", + "max_parallel": 3, + "nodes": [ + { + "id": "etcd", + "taskserv": {"name": "etcd", "install_mode": "library", "profile": "default", "target_save_path": "/wuwei/wuji/etcd"}, + "depends_on": [], + "parallel": false, + "on_error": "Stop", + "max_retries": 0 + }, + { + "id": "k8s", + "taskserv": {"name": "kubernetes", "install_mode": "library", "profile": "control-plane", "target_save_path": "/wuwei/wuji"}, + "depends_on": [{"node_id": "etcd", "kind": "OnSuccess"}], + "parallel": true, + "on_error": "Stop", + "max_retries": 0 + }, + { + "id": "containerd", + "taskserv": {"name": "containerd", "install_mode": "library", "profile": "default", "target_save_path": ""}, + "depends_on": [{"node_id": "etcd", "kind": "OnSuccess"}], + "parallel": true, + "on_error": "Stop", + "max_retries": 0 + }, + { + "id": "cilium", + "taskserv": {"name": "cilium", "install_mode": "library", "profile": "default", "target_save_path": ""}, + "depends_on": [ + {"node_id": "k8s", "kind": "OnSuccess"}, + {"node_id": "containerd", "kind": "OnSuccess"} + ], + "parallel": false, + "on_error": "Stop", + "max_retries": 0 + } + ], + "edges": [] + }"#; + + #[test] + fn deserialize_formula_from_json() { + let formula = Formula::from_json(SAMPLE_FORMULA_JSON).unwrap(); + assert_eq!(formula.id, "wuji-cp-0-formula"); + assert_eq!(formula.nodes.len(), 4); + assert_eq!(formula.max_parallel, 3); + } + + #[test] + fn formula_into_workflow_builds_real_dag() { + let formula = Formula::from_json(SAMPLE_FORMULA_JSON).unwrap(); + let workflow = formula + .into_workflow(FormulaWorkflowConfig { + operation_cmd: "taskserv create", + infra_name: "librecloud", + settings_path: "/settings.ncl", + provisioning_path: "./provisioning", + environment: std::collections::HashMap::new(), + check_mode: false, + wait: true, + }) + .unwrap(); + + assert_eq!(workflow.tasks.len(), 4); + + // etcd has no dependencies + let etcd = workflow.tasks.iter().find(|t| t.name.contains("etcd")).unwrap(); + assert!(etcd.dependencies.is_empty(), "etcd must have no deps"); + + // cilium depends on both k8s and containerd + let cilium = workflow.tasks.iter().find(|t| t.name.contains("cilium")).unwrap(); + assert_eq!(cilium.dependencies.len(), 2, "cilium must depend on k8s and containerd"); + + // k8s and containerd depend only on etcd + let k8s = workflow.tasks.iter().find(|t| t.name.contains("kubernetes")).unwrap(); + assert_eq!(k8s.dependencies.len(), 1); + assert!(k8s.dependencies[0].contains("etcd")); + + // WorkflowConfig respects formula.max_parallel + let config = workflow.config.unwrap(); + assert_eq!(config.max_parallel_tasks, 3); + } + + #[test] + fn formula_into_workflow_linear_fallback() { + // A formula with a single chain (etcd → k8s → cilium) produces linear deps. + let json = r#"{ + "id": "linear-formula", + "description": "Linear chain", + "provider": "test", + "server": "srv-0", + "max_parallel": 1, + "nodes": [ + {"id": "a", "taskserv": {"name": "etcd", "install_mode": "library", "profile": "default", "target_save_path": ""}, "depends_on": [], "parallel": false, "on_error": "Stop", "max_retries": 0}, + {"id": "b", "taskserv": {"name": "kubernetes", "install_mode": "library", "profile": "default", "target_save_path": ""}, "depends_on": [{"node_id": "a", "kind": "OnSuccess"}], "parallel": false, "on_error": "Stop", "max_retries": 0}, + {"id": "c", "taskserv": {"name": "cilium", "install_mode": "library", "profile": "default", "target_save_path": ""}, "depends_on": [{"node_id": "b", "kind": "OnSuccess"}], "parallel": false, "on_error": "Stop", "max_retries": 0} + ], + "edges": [] + }"#; + let formula = Formula::from_json(json).unwrap(); + let workflow = formula + .into_workflow(FormulaWorkflowConfig { + operation_cmd: "taskserv create", + infra_name: "test", + settings_path: "/s.ncl", + provisioning_path: "./p", + environment: Default::default(), + check_mode: false, + wait: false, + }) + .unwrap(); + + let c = workflow.tasks.iter().find(|t| t.name.contains("cilium")).unwrap(); + assert_eq!(c.dependencies.len(), 1); + assert!(c.dependencies[0].contains('b') || c.dependencies[0].contains("kubernetes")); + } + + // ── WorkspaceComposition tests ────────────────────────────────────────── + + fn make_single_node_formula(id: &str, node_id: &str) -> Formula { + let json = format!( + r#"{{ + "id": "{id}", + "description": "Test formula", + "provider": "test", + "server": "srv", + "max_parallel": 1, + "nodes": [ + {{"id": "{node_id}", "taskserv": {{"name": "test-svc", "install_mode": "library", "profile": "default", "target_save_path": ""}}, "depends_on": [], "parallel": false, "on_error": "Stop", "max_retries": 0}} + ], + "edges": [] + }}"# + ); + Formula::from_json(&json).unwrap() + } + + fn default_cfg<'a>() -> FormulaWorkflowConfig<'a> { + FormulaWorkflowConfig { + operation_cmd: "taskserv create", + infra_name: "wuji", + settings_path: "/s.ncl", + provisioning_path: "./p", + environment: Default::default(), + check_mode: false, + wait: false, + } + } + + #[test] + fn workspace_composition_task_namespacing() { + // Two formulas each with a node named "init" must produce distinct namespaced tasks. + let f1 = make_single_node_formula("formula-a", "init"); + let f2 = make_single_node_formula("formula-b", "init"); + + let comp_json = r#"{ + "workspace": "test-ws", + "infra": "cluster", + "composition": { + "formulas": [ + {"formula_id": "formula-a", "depends_on": [], "parallel": false}, + {"formula_id": "formula-b", "depends_on": [], "parallel": false} + ] + } + }"#; + + let comp = WorkspaceComposition::from_json(comp_json).unwrap(); + let wf = comp.into_workflow(vec![f1, f2], default_cfg()).unwrap(); + + // 2 formulas × 1 node each = 2 tasks, no health gate + assert_eq!(wf.tasks.len(), 2, "expected exactly 2 tasks"); + + let names: Vec<&str> = wf.tasks.iter().map(|t| t.name.as_str()).collect(); + assert!(names.iter().any(|n| n.starts_with("formula-a::")), "formula-a prefix missing"); + assert!(names.iter().any(|n| n.starts_with("formula-b::")), "formula-b prefix missing"); + + // No two tasks share the same name + let unique: std::collections::HashSet<&&str> = names.iter().collect(); + assert_eq!(unique.len(), names.len(), "task names must be unique after namespacing"); + } + + #[test] + fn workspace_composition_health_gate_injected() { + // cp → strg with health gate: expected total = 1 (cp node) + 1 (strg node) + 1 (gate) = 3. + let cp = make_single_node_formula("wuji-cp-0-formula", "cp-node"); + let strg = make_single_node_formula("wuji-strg-0-formula", "strg-node"); + + let comp_json = r#"{ + "workspace": "librecloud_renew", + "infra": "wuji", + "composition": { + "formulas": [ + {"formula_id": "wuji-cp-0-formula", "depends_on": [], "parallel": false}, + { + "formula_id": "wuji-strg-0-formula", + "depends_on": [{"formula_id": "wuji-cp-0-formula", "condition": "Healthy"}], + "parallel": true, + "health_gate": { + "check_cmd": "kubectl get nodes", + "expect": "Ready", + "timeout_ms": 120000, + "retries": 3 + } + } + ] + } + }"#; + + let comp = WorkspaceComposition::from_json(comp_json).unwrap(); + let wf = comp.into_workflow(vec![cp, strg], default_cfg()).unwrap(); + + // Total: 1 (cp-node) + 1 (strg-node) + 1 (health-gate) = 3 + assert_eq!(wf.tasks.len(), 3, "expected cp + strg + 1 health gate"); + + let gate = wf + .tasks + .iter() + .find(|t| { + t.metadata + .as_ref() + .and_then(|m| m.get("type")) + .map(|v| v == "health-gate") + .unwrap_or(false) + }) + .expect("health gate task must exist"); + + // Gate depends on cp terminals + assert!( + gate.dependencies.iter().any(|d| d.contains("wuji-cp-0-formula")), + "gate must depend on cp terminals" + ); + + // strg root depends on gate + let strg_root = wf + .tasks + .iter() + .find(|t| t.name.contains("wuji-strg-0-formula")) + .expect("strg task must exist"); + assert!( + strg_root.dependencies.iter().any(|d| d.contains("health-gate")), + "strg root must depend on health gate" + ); + } + + #[test] + fn workspace_composition_direct_wiring_no_gate() { + // cp → wrkr without health gate: wrkr root depends directly on cp terminals. + let cp = make_single_node_formula("wuji-cp-0-formula", "cp-node"); + let wrkr = make_single_node_formula("wuji-wrkr-0-formula", "wrkr-node"); + + let comp_json = r#"{ + "workspace": "librecloud_renew", + "infra": "wuji", + "composition": { + "formulas": [ + {"formula_id": "wuji-cp-0-formula", "depends_on": [], "parallel": false}, + {"formula_id": "wuji-wrkr-0-formula", "depends_on": [{"formula_id": "wuji-cp-0-formula", "condition": "Healthy"}], "parallel": true} + ] + } + }"#; + + let comp = WorkspaceComposition::from_json(comp_json).unwrap(); + let wf = comp.into_workflow(vec![cp, wrkr], default_cfg()).unwrap(); + + // No health gate: 1 (cp) + 1 (wrkr) = 2 tasks only + assert_eq!(wf.tasks.len(), 2, "no health gate — only formula tasks"); + + let wrkr_task = wf + .tasks + .iter() + .find(|t| t.name.contains("wuji-wrkr-0-formula")) + .unwrap(); + assert!( + wrkr_task + .dependencies + .iter() + .any(|d| d.contains("wuji-cp-0-formula")), + "wrkr root must depend directly on cp terminal" + ); + } + + #[test] + fn workspace_composition_unknown_formula_id_errors() { + let cp = make_single_node_formula("wuji-cp-0-formula", "cp-node"); + + let comp_json = r#"{ + "workspace": "test", + "infra": "test", + "composition": { + "formulas": [ + {"formula_id": "wuji-cp-0-formula", "depends_on": [], "parallel": false}, + {"formula_id": "does-not-exist", "depends_on": [], "parallel": false} + ] + } + }"#; + + let comp = WorkspaceComposition::from_json(comp_json).unwrap(); + let result = comp.into_workflow(vec![cp], default_cfg()); + assert!(result.is_err(), "unknown formula_id must return Err"); + assert!( + result.unwrap_err().to_string().contains("does-not-exist"), + "error must name the unknown id" + ); + } + + #[test] + fn workspace_composition_workflow_name() { + let f = make_single_node_formula("f1", "node"); + let comp_json = r#"{ + "workspace": "librecloud_renew", + "infra": "wuji", + "composition": { "formulas": [{"formula_id": "f1", "depends_on": [], "parallel": false}] } + }"#; + let comp = WorkspaceComposition::from_json(comp_json).unwrap(); + let wf = comp.into_workflow(vec![f], default_cfg()).unwrap(); + assert_eq!(wf.name, "librecloud_renew::wuji"); + } + + #[test] + fn component_node_produces_component_install_command() { + let json = r#"{ + "id": "comp-formula", + "description": "Component node test", + "provider": "hetzner", + "server": "data-0", + "max_parallel": 1, + "nodes": [ + { + "id": "postgresql", + "component": { + "name": "postgresql", + "mode": "cluster", + "namespace": "data", + "install_mode": "library", + "profile": "default", + "target_save_path": "" + }, + "depends_on": [], + "parallel": false, + "on_error": "Stop", + "max_retries": 0 + } + ], + "edges": [] + }"#; + let formula = Formula::from_json(json).unwrap(); + let workflow = formula + .into_workflow(FormulaWorkflowConfig { + operation_cmd: "component install", + infra_name: "libre-daoshi", + settings_path: "/settings.ncl", + provisioning_path: "./provisioning", + environment: std::collections::HashMap::new(), + check_mode: false, + wait: false, + }) + .unwrap(); + + assert_eq!(workflow.tasks.len(), 1); + let task = &workflow.tasks[0]; + assert!( + task.command.contains("component install"), + "component node must use 'component install' command, got: {}", + task.command + ); + assert!( + task.args.iter().any(|a| a == "--mode"), + "component node args must include --mode" + ); + assert!( + task.args.iter().any(|a| a == "--namespace"), + "component node args must include --namespace when set" + ); + let meta = task.metadata.as_ref().unwrap(); + assert_eq!(meta.get("component").map(String::as_str), Some("postgresql")); + assert_eq!(meta.get("mode").map(String::as_str), Some("cluster")); + assert!(!meta.contains_key("taskserv"), "component node must not have taskserv in meta"); + } + + #[test] + fn node_with_both_taskserv_and_component_errors() { + let json = r#"{ + "id": "bad-formula", + "description": "Ambiguous node", + "provider": "test", + "server": "srv", + "max_parallel": 1, + "nodes": [ + { + "id": "ambiguous", + "taskserv": {"name": "svc", "install_mode": "library", "profile": "default", "target_save_path": ""}, + "component": {"name": "svc", "mode": "cluster", "install_mode": "library", "profile": "default", "target_save_path": ""}, + "depends_on": [], + "parallel": false, + "on_error": "Stop", + "max_retries": 0 + } + ], + "edges": [] + }"#; + let formula = Formula::from_json(json).unwrap(); + let result = formula.into_workflow(FormulaWorkflowConfig { + operation_cmd: "taskserv create", + infra_name: "test", + settings_path: "/s.ncl", + provisioning_path: "./p", + environment: Default::default(), + check_mode: false, + wait: false, + }); + assert!(result.is_err(), "both taskserv and component must error"); + } +} diff --git a/crates/orchestrator/src/handlers/audit.rs b/crates/orchestrator/src/handlers/audit.rs new file mode 100644 index 0000000..ec1e18b --- /dev/null +++ b/crates/orchestrator/src/handlers/audit.rs @@ -0,0 +1,210 @@ +use axum::{ + extract::State, + http::StatusCode, + response::Json, +}; +use ontoref_derive::onto_api; +use provisioning_orchestrator::{ + audit::{AuditEvent, AuditFilter, AuditQuery, RetentionPolicy, SiemFormat}, + SharedState, +}; +use serde::Deserialize; +use tracing::error; + +use super::ApiResponse; + +/// Query audit logs with filters +#[onto_api( + method = "POST", + path = "/api/v1/audit/query", + description = "Query audit logs with filters", + auth = "bearer", + actors = "developer, agent", + tags = "audit", + feature = "" +)] +pub async fn query_audit_logs( + State(state): State, + Json(query): Json, +) -> Result>>, StatusCode> { + match state.audit_logger.query(query).await { + Ok(events) => Ok(Json(ApiResponse::success(events))), + Err(e) => { + error!("Failed to query audit logs: {}", e); + Ok(Json(ApiResponse::error(format!("Query failed: {}", e)))) + } + } +} + +/// Export audit logs to specific format +#[derive(Debug, Deserialize)] +pub struct ExportRequest { + pub format: SiemFormat, + pub filter: Option, +} + +#[onto_api( + method = "POST", + path = "/api/v1/audit/export", + description = "Export audit logs in SIEM format", + auth = "bearer", + actors = "developer, agent", + tags = "audit", + feature = "" +)] +pub async fn export_audit_logs( + State(state): State, + Json(request): Json, +) -> Result { + match state + .audit_logger + .export(request.format, request.filter) + .await + { + Ok(data) => { + let content_type = match request.format { + SiemFormat::Json + | SiemFormat::JsonLines + | SiemFormat::SplunkCim + | SiemFormat::ElasticEcs => "application/json", + SiemFormat::Csv => "text/csv", + }; + + Ok(axum::response::Response::builder() + .status(StatusCode::OK) + .header("Content-Type", content_type) + .body(axum::body::Body::from(data)) + .unwrap()) + } + Err(e) => { + error!("Failed to export audit logs: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +/// Get audit logger statistics +#[onto_api( + method = "GET", + path = "/api/v1/audit/stats", + description = "Get audit logger statistics", + auth = "bearer", + actors = "developer, agent", + tags = "audit", + feature = "" +)] +pub async fn get_audit_stats( + State(state): State, +) -> Result>, StatusCode> { + let stats = state.audit_logger.stats().await; + Ok(Json(ApiResponse::success( + serde_json::to_value(stats).unwrap_or_default(), + ))) +} + +/// Get audit storage statistics +#[onto_api( + method = "GET", + path = "/api/v1/audit/storage-stats", + description = "Get audit storage statistics", + auth = "bearer", + actors = "developer, agent", + tags = "audit", + feature = "" +)] +pub async fn get_audit_storage_stats( + State(_state): State, +) -> Result>, StatusCode> { + // Note: This would require access to the storage backend + // For now, return a placeholder + Ok(Json(ApiResponse::success(serde_json::json!({ + "total_events": 0, + "total_size_bytes": 0, + "oldest_event": null, + "newest_event": null, + "note": "Storage stats require direct storage access" + })))) +} + +/// Apply retention policy to audit logs +#[onto_api( + method = "POST", + path = "/api/v1/audit/apply-retention", + description = "Apply retention policy to audit logs", + auth = "bearer", + actors = "developer, agent", + tags = "audit", + feature = "" +)] +pub async fn apply_audit_retention( + State(state): State, + Json(_policy): Json, +) -> Result>, StatusCode> { + // Temporarily update the audit logger's retention policy + // In production, this should be done through configuration + match state.audit_logger.apply_retention().await { + Ok(deleted) => Ok(Json(ApiResponse::success(deleted))), + Err(e) => { + error!("Failed to apply retention policy: {}", e); + Ok(Json(ApiResponse::error(format!("Retention failed: {}", e)))) + } + } +} + +/// Search audit logs with text query +#[derive(Debug, Deserialize)] +pub struct SearchRequest { + pub query: String, + pub field: String, +} + +#[onto_api( + method = "POST", + path = "/api/v1/audit/search", + description = "Search audit logs by text query", + auth = "bearer", + actors = "developer, agent", + tags = "audit", + feature = "" +)] +pub async fn search_audit_logs( + State(state): State, + Json(request): Json, +) -> Result>>, StatusCode> { + // Build filter based on field + let filter = match request.field.as_str() { + "user" => AuditFilter { + user_id: Some(request.query.clone()), + ..Default::default() + }, + "resource" => AuditFilter { + resource: Some(request.query.clone()), + ..Default::default() + }, + "workspace" => AuditFilter { + workspace: Some(request.query.clone()), + ..Default::default() + }, + _ => { + // Search all fields - this is a simple implementation + // In production, you'd want full-text search + AuditFilter::default() + } + }; + + let query = AuditQuery { + filter, + limit: Some(100), + offset: None, + sort_by: Some("timestamp".to_string()), + sort_desc: true, + }; + + match state.audit_logger.query(query).await { + Ok(events) => Ok(Json(ApiResponse::success(events))), + Err(e) => { + error!("Failed to search audit logs: {}", e); + Ok(Json(ApiResponse::error(format!("Search failed: {}", e)))) + } + } +} diff --git a/crates/orchestrator/src/handlers/batch.rs b/crates/orchestrator/src/handlers/batch.rs new file mode 100644 index 0000000..9086998 --- /dev/null +++ b/crates/orchestrator/src/handlers/batch.rs @@ -0,0 +1,108 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::Json, +}; +use ontoref_derive::onto_api; +use provisioning_orchestrator::{ + batch::{BatchOperationRequest, BatchOperationResult}, + workflow::WorkflowExecutionState, + SharedState, +}; +use tracing::error; + +use super::ApiResponse; + +#[onto_api( + method = "POST", + path = "/batch/execute", + description = "Execute a batch operation", + auth = "bearer", + actors = "developer, agent", + tags = "batch", + feature = "" +)] +pub async fn execute_batch_operation( + State(state): State, + Json(request): Json, +) -> Result>, StatusCode> { + match state + .batch_coordinator + .execute_batch_operation(request) + .await + { + Ok(result) => Ok(Json(ApiResponse::success(result))), + Err(e) => { + error!("Failed to execute batch operation: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +#[onto_api( + method = "GET", + path = "/batch/operations/{id}", + description = "Get batch operation status", + auth = "bearer", + actors = "developer, agent", + tags = "batch", + feature = "" +)] +pub async fn get_batch_operation_status( + State(state): State, + Path(operation_id): Path, +) -> Result>, StatusCode> { + match state + .batch_coordinator + .get_operation_status(&operation_id) + .await + { + Some(state) => Ok(Json(ApiResponse::success(state))), + None => Err(StatusCode::NOT_FOUND), + } +} + +#[onto_api( + method = "GET", + path = "/batch/operations", + description = "List all batch operations", + auth = "bearer", + actors = "developer, agent", + tags = "batch", + feature = "" +)] +pub async fn list_batch_operations( + State(state): State, +) -> Result>>, StatusCode> { + let operations = state.batch_coordinator.list_operations().await; + Ok(Json(ApiResponse::success(operations))) +} + +#[onto_api( + method = "POST", + path = "/batch/operations/{id}/cancel", + description = "Cancel a batch operation", + auth = "bearer", + actors = "developer, agent", + tags = "batch", + feature = "" +)] +pub async fn cancel_batch_operation( + State(state): State, + Path(operation_id): Path, +) -> Result>, StatusCode> { + match state + .batch_coordinator + .cancel_operation(&operation_id) + .await + { + Ok(_) => Ok(Json(ApiResponse::success(format!( + "Operation {} cancelled", + operation_id + )))), + Err(e) => { + error!("Failed to cancel operation {}: {}", operation_id, e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} diff --git a/crates/orchestrator/src/handlers/extensions.rs b/crates/orchestrator/src/handlers/extensions.rs new file mode 100644 index 0000000..b501050 --- /dev/null +++ b/crates/orchestrator/src/handlers/extensions.rs @@ -0,0 +1,184 @@ +use axum::{ + extract::State, + http::StatusCode, + response::Json, +}; +use ontoref_derive::onto_api; +use provisioning_orchestrator::SharedState; +use serde::{Deserialize, Serialize}; +use tracing::error; + +use super::ApiResponse; + +// DNS Management Routes + +#[onto_api( + method = "GET", + path = "/api/v1/dns/records", + description = "List DNS records", + auth = "bearer", + actors = "developer, agent", + tags = "dns", + feature = "" +)] +pub async fn list_dns_records( + State(state): State, +) -> Result>>, StatusCode> { + match state.dns_manager.list_records().await { + Ok(records) => Ok(Json(ApiResponse::success(records))), + Err(e) => { + error!("Failed to list DNS records: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +// Extension Management Routes + +#[onto_api( + method = "GET", + path = "/api/v1/extensions/loaded", + description = "List loaded extensions", + auth = "bearer", + actors = "developer, agent", + tags = "extensions", + feature = "" +)] +pub async fn list_loaded_extensions( + State(state): State, +) -> Result>>, StatusCode> { + let extensions = state.extension_manager.list_loaded_extensions().await; + Ok(Json(ApiResponse::success(extensions))) +} + +#[derive(Deserialize)] +pub struct ReloadExtensionRequest { + pub extension_type: String, + pub name: String, +} + +#[onto_api( + method = "POST", + path = "/api/v1/extensions/reload", + description = "Reload an extension", + auth = "bearer", + actors = "developer, agent", + tags = "extensions", + feature = "" +)] +pub async fn reload_extension( + State(state): State, + Json(request): Json, +) -> Result>, StatusCode> { + use provisioning_orchestrator::extensions::ExtensionType; + + let ext_type = match request.extension_type.as_str() { + "provider" => ExtensionType::Provider, + "taskserv" => ExtensionType::Taskserv, + "cluster" => ExtensionType::Cluster, + _ => return Err(StatusCode::BAD_REQUEST), + }; + + match state + .extension_manager + .reload_extension(ext_type, request.name.clone()) + .await + { + Ok(_) => Ok(Json(ApiResponse::success(format!( + "Extension {} reloaded", + request.name + )))), + Err(e) => { + error!("Failed to reload extension: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +// OCI Registry Routes + +#[derive(Deserialize)] +pub struct ListOciArtifactsRequest { + pub namespace: String, +} + +#[onto_api( + method = "POST", + path = "/api/v1/oci/artifacts", + description = "List OCI artifacts in a namespace", + auth = "bearer", + actors = "developer, agent", + tags = "oci", + feature = "" +)] +pub async fn list_oci_artifacts( + State(state): State, + Json(request): Json, +) -> Result>>, StatusCode> { + match state + .oci_manager + .list_oci_artifacts(&request.namespace) + .await + { + Ok(artifacts) => Ok(Json(ApiResponse::success(artifacts))), + Err(e) => { + error!("Failed to list OCI artifacts: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +// Service Orchestration Routes + +#[onto_api( + method = "GET", + path = "/api/v1/services/list", + description = "List all orchestrated services", + auth = "bearer", + actors = "developer, agent", + tags = "services", + feature = "" +)] +pub async fn list_services( + State(state): State, +) -> Result>>, StatusCode> { + let services = state.service_orchestrator.list_services().await; + Ok(Json(ApiResponse::success(services))) +} + +#[derive(Serialize)] +pub struct ServiceStatusResponse { + pub name: String, + pub status: provisioning_orchestrator::services::ServiceStatus, +} + +#[onto_api( + method = "GET", + path = "/api/v1/services/status", + description = "Get status of all orchestrated services", + auth = "bearer", + actors = "developer, agent", + tags = "services", + feature = "" +)] +pub async fn get_services_status( + State(state): State, +) -> Result>>, StatusCode> { + let services = state.service_orchestrator.list_services().await; + let mut statuses = Vec::new(); + + for service in services { + if let Ok(status) = state + .service_orchestrator + .get_service_status(&service.name) + .await + { + statuses.push(ServiceStatusResponse { + name: service.name, + status, + }); + } + } + + Ok(Json(ApiResponse::success(statuses))) +} diff --git a/crates/orchestrator/src/handlers/infra.rs b/crates/orchestrator/src/handlers/infra.rs new file mode 100644 index 0000000..c8ebfc5 --- /dev/null +++ b/crates/orchestrator/src/handlers/infra.rs @@ -0,0 +1,50 @@ +#[cfg(feature = "nats")] +use axum::{ + extract::State, + http::StatusCode, + response::Json, +}; +#[cfg(feature = "nats")] +use provisioning_orchestrator::SharedState; +#[cfg(feature = "nats")] +use serde::Deserialize; +#[cfg(feature = "nats")] +use tracing::error; + +#[cfg(feature = "nats")] +use super::ApiResponse; + +#[cfg(feature = "nats")] +#[derive(Deserialize)] +pub struct ReconcileRequest { + pub workspace: String, +} + +#[cfg(feature = "nats")] +pub async fn trigger_infra_reconcile( + State(state): State, + Json(req): Json, +) -> Result>, StatusCode> { + let payload = match serde_json::to_vec(&serde_json::json!({"workspace": req.workspace})) { + Ok(b) => b.into(), + Err(e) => { + error!("reconcile: serialize failed: {e}"); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + match state + .nats + .publish_raw("provisioning.infra.reconcile.workspace", payload) + .await + { + Ok(()) => Ok(Json(ApiResponse::success(format!( + "reconcile triggered for {}", + req.workspace + )))), + Err(e) => { + error!(error = %e, workspace = %req.workspace, "reconcile publish failed"); + Err(StatusCode::BAD_GATEWAY) + } + } +} diff --git a/crates/orchestrator/src/handlers/mod.rs b/crates/orchestrator/src/handlers/mod.rs new file mode 100644 index 0000000..c5cd04f --- /dev/null +++ b/crates/orchestrator/src/handlers/mod.rs @@ -0,0 +1,12 @@ +pub mod audit; +pub mod batch; +pub mod extensions; +pub mod infra; +pub mod rollback; +pub mod state; +pub mod tasks; +pub mod test_env; +pub mod vm_pool; +pub mod workflows; + +pub use provisioning_orchestrator::ApiResponse; diff --git a/crates/orchestrator/src/handlers/rollback.rs b/crates/orchestrator/src/handlers/rollback.rs new file mode 100644 index 0000000..342c3ca --- /dev/null +++ b/crates/orchestrator/src/handlers/rollback.rs @@ -0,0 +1,216 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::Json, +}; +use ontoref_derive::onto_api; +use provisioning_orchestrator::{ + rollback::{Checkpoint, RollbackResult, RollbackStatistics}, + SharedState, +}; +use serde::Deserialize; +use tracing::{error, info, warn}; + +use super::ApiResponse; + +#[derive(Deserialize)] +pub struct CreateCheckpointRequest { + pub name: String, + pub description: Option, +} + +#[derive(Deserialize)] +pub struct RollbackRequest { + pub checkpoint_id: Option, + pub operation_ids: Option>, +} + +#[onto_api( + method = "POST", + path = "/rollback/checkpoints", + description = "Create a rollback checkpoint", + auth = "bearer", + actors = "developer, agent", + tags = "rollback", + feature = "" +)] +pub async fn create_checkpoint( + State(state): State, + Json(request): Json, +) -> Result>, StatusCode> { + match state + .rollback_system + .checkpoint_manager + .create_checkpoint(request.name, request.description) + .await + { + Ok(checkpoint_id) => { + info!("Created checkpoint: {}", checkpoint_id); + Ok(Json(ApiResponse::success(checkpoint_id))) + } + Err(e) => { + error!("Failed to create checkpoint: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +#[onto_api( + method = "GET", + path = "/rollback/checkpoints", + description = "List rollback checkpoints", + auth = "bearer", + actors = "developer, agent", + tags = "rollback", + feature = "" +)] +pub async fn list_checkpoints( + State(state): State, +) -> Result>>, StatusCode> { + let checkpoints = state + .rollback_system + .checkpoint_manager + .list_checkpoints() + .await; + Ok(Json(ApiResponse::success(checkpoints))) +} + +#[onto_api( + method = "GET", + path = "/rollback/checkpoints/{id}", + description = "Get a specific checkpoint", + auth = "bearer", + actors = "developer, agent", + tags = "rollback", + feature = "" +)] +pub async fn get_checkpoint( + State(state): State, + Path(checkpoint_id): Path, +) -> Result>, StatusCode> { + match state + .rollback_system + .checkpoint_manager + .get_checkpoint(&checkpoint_id) + .await + { + Some(checkpoint) => Ok(Json(ApiResponse::success(checkpoint))), + None => Err(StatusCode::NOT_FOUND), + } +} + +#[onto_api( + method = "POST", + path = "/rollback/execute", + description = "Execute a rollback operation", + auth = "admin", + actors = "developer", + tags = "rollback", + feature = "" +)] +pub async fn execute_rollback( + State(state): State, + Json(request): Json, +) -> Result>, StatusCode> { + let result = if let Some(checkpoint_id) = request.checkpoint_id { + // Rollback to specific checkpoint + match state + .rollback_system + .rollback_executor + .rollback_to_checkpoint(&checkpoint_id) + .await + { + Ok(result) => result, + Err(e) => { + error!( + "Failed to execute rollback to checkpoint {}: {}", + checkpoint_id, e + ); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + } + } else if let Some(operation_ids) = request.operation_ids { + // Partial rollback of specific operations + match state + .rollback_system + .rollback_executor + .rollback_operations(operation_ids) + .await + { + Ok(result) => result, + Err(e) => { + error!("Failed to execute partial rollback: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + } + } else { + return Err(StatusCode::BAD_REQUEST); + }; + + if result.success { + info!( + "Rollback executed successfully: {} operations", + result.operations_executed + ); + } else { + warn!( + "Rollback completed with errors: {}/{} operations failed", + result.operations_failed, + result.operations_executed + result.operations_failed + ); + } + + Ok(Json(ApiResponse::success(result))) +} + +#[onto_api( + method = "GET", + path = "/rollback/statistics", + description = "Get rollback statistics", + auth = "bearer", + actors = "developer, agent", + tags = "rollback", + feature = "" +)] +pub async fn get_rollback_statistics( + State(state): State, +) -> Result>, StatusCode> { + let stats = state.rollback_system.get_statistics().await; + Ok(Json(ApiResponse::success(stats))) +} + +#[onto_api( + method = "POST", + path = "/rollback/restore/{id}", + description = "Restore state from checkpoint", + auth = "admin", + actors = "developer", + tags = "rollback", + feature = "" +)] +pub async fn restore_from_checkpoint( + State(state): State, + Path(checkpoint_id): Path, +) -> Result>, StatusCode> { + match state + .rollback_system + .state_restorer + .restore_from_checkpoint(&checkpoint_id) + .await + { + Ok(_) => { + info!( + "Successfully restored state from checkpoint: {}", + checkpoint_id + ); + Ok(Json(ApiResponse::success(format!( + "State restored from checkpoint {}", + checkpoint_id + )))) + } + Err(e) => { + error!("Failed to restore from checkpoint {}: {}", checkpoint_id, e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} diff --git a/crates/orchestrator/src/handlers/state.rs b/crates/orchestrator/src/handlers/state.rs new file mode 100644 index 0000000..68f21b6 --- /dev/null +++ b/crates/orchestrator/src/handlers/state.rs @@ -0,0 +1,114 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::Json, +}; +use ontoref_derive::onto_api; +use provisioning_orchestrator::{ + monitor::SystemHealthStatus, + state::{ProgressInfo, StateManagerStatistics, StateSnapshot, SystemMetrics}, + SharedState, +}; +use tracing::error; + +use super::ApiResponse; + +#[onto_api( + method = "GET", + path = "/state/workflows/{id}/progress", + description = "Get workflow execution progress", + auth = "bearer", + actors = "developer, agent", + tags = "state", + feature = "" +)] +pub async fn get_workflow_progress( + State(state): State, + Path(workflow_id): Path, +) -> Result>, StatusCode> { + match state + .progress_tracker + .get_real_time_progress(&workflow_id) + .await + { + Some(progress) => Ok(Json(ApiResponse::success(progress))), + None => Err(StatusCode::NOT_FOUND), + } +} + +#[onto_api( + method = "GET", + path = "/state/workflows/{id}/snapshots", + description = "Get workflow state snapshots", + auth = "bearer", + actors = "developer, agent", + tags = "state", + feature = "" +)] +pub async fn get_workflow_snapshots( + State(state): State, + Path(workflow_id): Path, +) -> Result>>, StatusCode> { + let snapshots = state + .state_manager + .get_workflow_snapshots(&workflow_id) + .await; + Ok(Json(ApiResponse::success(snapshots))) +} + +#[onto_api( + method = "GET", + path = "/state/system/metrics", + description = "Get system-level metrics", + auth = "bearer", + actors = "developer, agent, ci", + tags = "state, metrics", + feature = "" +)] +pub async fn get_system_metrics( + State(state): State, +) -> Result>, StatusCode> { + let metrics = state.state_manager.get_system_metrics().await; + Ok(Json(ApiResponse::success(metrics))) +} + +#[onto_api( + method = "GET", + path = "/state/system/health", + description = "Get system health status", + auth = "none", + actors = "developer, agent, ci", + tags = "health, state", + feature = "" +)] +pub async fn get_system_health( + State(state): State, +) -> Result>, StatusCode> { + let health_status = state + .monitoring_system + .health_monitor() + .get_system_health() + .await; + Ok(Json(ApiResponse::success(health_status))) +} + +#[onto_api( + method = "GET", + path = "/state/statistics", + description = "Get state manager statistics", + auth = "bearer", + actors = "developer, agent", + tags = "state", + feature = "" +)] +pub async fn get_state_statistics( + State(state): State, +) -> Result>, StatusCode> { + match state.state_manager.get_statistics().await { + Ok(stats) => Ok(Json(ApiResponse::success(stats))), + Err(e) => { + error!("Failed to get state statistics: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} diff --git a/crates/orchestrator/src/handlers/tasks.rs b/crates/orchestrator/src/handlers/tasks.rs new file mode 100644 index 0000000..8edb283 --- /dev/null +++ b/crates/orchestrator/src/handlers/tasks.rs @@ -0,0 +1,67 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::Json, +}; +use ontoref_derive::onto_api; +use provisioning_orchestrator::{SharedState, WorkflowTask}; +use tracing::error; + +use super::ApiResponse; + +#[onto_api( + method = "GET", + path = "/tasks/{id}", + description = "Get task status by ID", + auth = "bearer", + actors = "developer, agent", + tags = "tasks", + feature = "" +)] +pub async fn get_task_status( + State(state): State, + Path(task_id): Path, +) -> Result>, StatusCode> { + match state.task_storage.get_task(&task_id).await { + Ok(Some(task)) => Ok(Json(ApiResponse::success(task))), + Ok(None) => Err(StatusCode::NOT_FOUND), + Err(e) => { + error!("Failed to get task {}: {}", task_id, e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +#[onto_api( + method = "GET", + path = "/tasks", + description = "List all workflow tasks", + auth = "bearer", + actors = "developer, agent", + tags = "tasks", + feature = "" +)] +pub async fn list_tasks( + State(state): State, +) -> Result>>, StatusCode> { + match state.task_storage.list_tasks(None).await { + Ok(task_list) => Ok(Json(ApiResponse::success(task_list))), + Err(e) => { + error!("Failed to list tasks: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +#[onto_api( + method = "GET", + path = "/health", + description = "Service health check", + auth = "none", + actors = "developer, agent, ci", + tags = "health", + feature = "" +)] +pub async fn health_check() -> Json> { + Json(ApiResponse::success("Orchestrator is healthy".to_string())) +} diff --git a/crates/orchestrator/src/handlers/test_env.rs b/crates/orchestrator/src/handlers/test_env.rs new file mode 100644 index 0000000..f177343 --- /dev/null +++ b/crates/orchestrator/src/handlers/test_env.rs @@ -0,0 +1,147 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::Json, +}; +use ontoref_derive::onto_api; +use provisioning_orchestrator::{ + test_environment::{ + CreateTestEnvironmentRequest, RunTestRequest, TestEnvironment, TestEnvironmentResponse, + TestResult, + }, + SharedState, +}; +use tracing::error; + +use super::ApiResponse; + +#[onto_api( + method = "POST", + path = "/test/environments/create", + description = "Create a test environment", + auth = "bearer", + actors = "developer, ci", + tags = "test-env", + feature = "" +)] +pub async fn create_test_environment( + State(state): State, + Json(request): Json, +) -> Result>, StatusCode> { + match state.test_orchestrator.create_environment(request).await { + Ok(response) => Ok(Json(ApiResponse::success(response))), + Err(e) => { + error!("Failed to create test environment: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +#[onto_api( + method = "GET", + path = "/test/environments/{id}", + description = "Get a test environment by ID", + auth = "bearer", + actors = "developer, ci", + tags = "test-env", + feature = "" +)] +pub async fn get_test_environment( + State(state): State, + Path(env_id): Path, +) -> Result>, StatusCode> { + match state.test_orchestrator.get_environment(&env_id).await { + Some(env) => Ok(Json(ApiResponse::success(env))), + None => Err(StatusCode::NOT_FOUND), + } +} + +#[onto_api( + method = "GET", + path = "/test/environments", + description = "List all test environments", + auth = "bearer", + actors = "developer, ci", + tags = "test-env", + feature = "" +)] +pub async fn list_test_environments( + State(state): State, +) -> Result>>, StatusCode> { + let environments = state.test_orchestrator.list_environments().await; + Ok(Json(ApiResponse::success(environments))) +} + +#[onto_api( + method = "POST", + path = "/test/environments/{id}/run", + description = "Run tests in a test environment", + auth = "bearer", + actors = "developer, ci", + tags = "test-env", + feature = "" +)] +pub async fn run_environment_tests( + State(state): State, + Path(env_id): Path, + Json(request): Json, +) -> Result>>, StatusCode> { + match state.test_orchestrator.run_tests(&env_id, request).await { + Ok(results) => Ok(Json(ApiResponse::success(results))), + Err(e) => { + error!("Failed to run tests: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +#[onto_api( + method = "DELETE", + path = "/test/environments/{id}", + description = "Cleanup a test environment", + auth = "bearer", + actors = "developer, ci", + tags = "test-env", + feature = "" +)] +pub async fn cleanup_test_environment( + State(state): State, + Path(env_id): Path, +) -> Result>, StatusCode> { + match state + .test_orchestrator + .cleanup_environment(&env_id, false) + .await + { + Ok(_) => Ok(Json(ApiResponse::success(format!( + "Environment {} cleaned up", + env_id + )))), + Err(e) => { + error!("Failed to cleanup environment: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +#[onto_api( + method = "GET", + path = "/test/environments/{id}/logs", + description = "Get logs for a test environment", + auth = "bearer", + actors = "developer, ci", + tags = "test-env", + feature = "" +)] +pub async fn get_environment_logs( + State(state): State, + Path(env_id): Path, +) -> Result>>, StatusCode> { + match state.test_orchestrator.get_environment_logs(&env_id).await { + Ok(logs) => Ok(Json(ApiResponse::success(logs))), + Err(e) => { + error!("Failed to get environment logs: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} diff --git a/crates/orchestrator/src/handlers/vm_pool.rs b/crates/orchestrator/src/handlers/vm_pool.rs new file mode 100644 index 0000000..7a6834f --- /dev/null +++ b/crates/orchestrator/src/handlers/vm_pool.rs @@ -0,0 +1,4 @@ +// Thin re-export shim — handlers live in the library crate's vm_pool module. +pub use provisioning_orchestrator::vm_pool::handlers::{ + destroy_runner, get_p95, record_build_metrics, spawn_runner, +}; diff --git a/crates/orchestrator/src/handlers/workflows.rs b/crates/orchestrator/src/handlers/workflows.rs new file mode 100644 index 0000000..1818b7b --- /dev/null +++ b/crates/orchestrator/src/handlers/workflows.rs @@ -0,0 +1,443 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::Json, +}; +use ontoref_derive::onto_api; +use provisioning_orchestrator::{ + config::OrchestratorConfig, + preconditions::{ComponentRef, WorkspacePath}, + ClusterWorkflow, ComponentWorkflow, CreateServerWorkflow, SharedState, TaskStatus, + TaskservWorkflow, WorkflowTask, +}; +use tracing::{error, info, warn}; +use uuid::Uuid; + +use super::ApiResponse; + +#[onto_api( + method = "POST", + path = "/workflows/servers/create", + description = "Enqueue server creation workflow", + auth = "bearer", + actors = "developer, agent", + tags = "workflows, servers", + feature = "" +)] +pub async fn create_server_workflow( + State(state): State, + Json(workflow): Json, +) -> Result>, StatusCode> { + let task_id = Uuid::new_v4().to_string(); + + // PROPER ARCHITECTURE: CLI renders script, orchestrator executes it + // If script_compressed is provided: execute it (that's ALL the orchestrator + // does) If NOT provided: error (legacy mode should not happen) + let task = if let Some(ref script_compressed) = workflow.script_compressed { + // CLI has provided the COMPLETE SCRIPT ready to execute + // No command construction, no decision logic + // Just: decompress -> execute + + // Store script in temp file for execution — async write to avoid blocking tokio worker + // threads when multiple concurrent submissions arrive simultaneously. + let script_file = format!("/tmp/orchestrator_script_{}.tar.gz.b64", task_id); + tokio::fs::write(&script_file, script_compressed) + .await + .map_err(|e| { + error!("Failed to write script payload to {}: {}", script_file, e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + WorkflowTask { + id: task_id.clone(), + name: if workflow.servers.is_empty() { + "execute_servers_script_all".to_string() + } else { + format!("execute_servers_script_{}", workflow.servers.join("_")) + }, + // Execute the decompressed script directly + command: "bash".to_string(), + args: vec![ + "-c".to_string(), + // Decompress: base64 decode -> gunzip -> extract script.sh -> execute + // CRITICAL: Use '+x' to DISABLE debug mode and prevent credential exposure + // Even if script contains 'set -x', it won't execute with +x flag + format!( + "base64 -d < {} | gunzip | tar -xOf - script.sh | bash +x", + script_file + ), + ], + dependencies: vec![], + status: TaskStatus::Pending, + created_at: chrono::Utc::now(), + started_at: None, + completed_at: None, + output: None, + error: None, + tags: Default::default(), + } + } else { + // LEGACY: Construct command from parameters (deprecated) + let mut args = vec![ + "--infra".to_string(), + workflow.infra.clone(), + "--settings".to_string(), + workflow.settings.clone(), + ]; + + for server in &workflow.servers { + args.push(server.clone()); + } + + if workflow.check_mode { + args.push("--check".to_string()); + } + if workflow.wait { + args.push("--wait".to_string()); + } + + WorkflowTask { + id: task_id.clone(), + name: if workflow.servers.is_empty() { + "create_servers_all".to_string() + } else { + format!("create_servers_{}", workflow.servers.join("_")) + }, + command: format!("{} servers create", state.args.provisioning_path), + args, + dependencies: vec![], + status: TaskStatus::Pending, + created_at: chrono::Utc::now(), + started_at: None, + completed_at: None, + output: None, + error: None, + tags: Default::default(), + } + }; + + let server_summary = if workflow.servers.is_empty() { + "all servers".to_string() + } else { + format!("{} server(s)", workflow.servers.len()) + }; + + match state.task_storage.enqueue(task, 5).await { + Ok(()) => { + info!( + "Enqueued server creation workflow ({}): {} | infra: {}", + server_summary, task_id, workflow.infra + ); + Ok(Json(ApiResponse::success(task_id))) + } + Err(e) => { + error!( + "Failed to enqueue server creation task ({}): {}", + server_summary, e + ); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +#[onto_api( + method = "POST", + path = "/workflows/taskserv/create", + description = "Enqueue taskserv operation workflow", + auth = "bearer", + actors = "developer, agent", + tags = "workflows, taskservs", + feature = "" +)] +pub async fn create_taskserv_workflow( + State(state): State, + Json(workflow): Json, +) -> Result>, StatusCode> { + let task_id = Uuid::new_v4().to_string(); + + let task = WorkflowTask { + id: task_id.clone(), + name: format!("{}_taskserv", workflow.operation), + command: format!( + "{} taskserv {}", + state.args.provisioning_path, workflow.operation + ), + args: vec![ + workflow.taskserv.clone(), + "--infra".to_string(), + workflow.infra.clone(), + "--settings".to_string(), + workflow.settings.clone(), + if workflow.check_mode { + "--check".to_string() + } else { + "".to_string() + }, + if workflow.wait { + "--wait".to_string() + } else { + "".to_string() + }, + ] + .into_iter() + .filter(|s| !s.is_empty()) + .collect(), + dependencies: vec![], + status: TaskStatus::Pending, + created_at: chrono::Utc::now(), + started_at: None, + completed_at: None, + output: None, + error: None, + tags: Default::default(), + }; + + match state.task_storage.enqueue(task, 6).await { + Ok(()) => { + info!( + "Enqueued taskserv {} workflow: {}", + workflow.operation, task_id + ); + Ok(Json(ApiResponse::success(task_id))) + } + Err(e) => { + error!("Failed to enqueue task: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +#[onto_api( + method = "POST", + path = "/api/v1/workflows/component/{operation}", + description = "Unified component lifecycle endpoint — polymorphic dispatch by deploy mode (taskserv/cluster/container)", + auth = "bearer", + actors = "developer, agent, mcp", + tags = "workflows, components", + feature = "" +)] +pub async fn create_component_workflow( + State(state): State, + Path(operation): Path, + Json(workflow): Json, +) -> Result>, StatusCode> { + // Feature flag — return 404 when disabled so legacy routing is unaffected. + let cfg = OrchestratorConfig::load_with_cli_args(&state.args).unwrap_or_default(); + if !cfg.orchestrator.features.enable_component_endpoint { + warn!( + "component endpoint disabled by feature flag; op={operation} component={}", + workflow.component + ); + return Err(StatusCode::NOT_FOUND); + } + + // Validate operation is in the allowed set. + const WRITE_OPS: &[&str] = &[ + "install", + "delete", + "update", + "reinstall", + "restart", + "backup", + "restore", + "check-updates", + ]; + const READ_OPS: &[&str] = &["status", "health", "list", "show"]; + + if !WRITE_OPS.contains(&operation.as_str()) && !READ_OPS.contains(&operation.as_str()) { + warn!("unknown component operation '{operation}'"); + return Err(StatusCode::BAD_REQUEST); + } + + // Precondition gate — skipped for read-only ops inside verify_component_preconditions. + let ws_path = WorkspacePath { + root: &workflow.workspace, + infra: &workflow.infra, + provisioning: &workflow.provisioning, + }; + let comp_mode = workflow.mode.as_deref().unwrap_or("cluster"); + let comp_ref = ComponentRef { + name: &workflow.component, + mode: comp_mode, + target: &workflow.server, + namespace: workflow.namespace.as_deref(), + ssh_user: &workflow.ssh_user, + ssh_key_path: workflow.ssh_key_path.as_deref(), + }; + + if let Err(pre_err) = + provisioning_orchestrator::preconditions::verify_component_preconditions( + &ws_path, &comp_ref, &operation, + ) + .await + { + warn!( + "precondition gate rejected component='{}' op='{operation}': {pre_err}", + workflow.component + ); + return Ok(Json(ApiResponse::error(pre_err.to_string()))); + } + + let task_id = Uuid::new_v4().to_string(); + + // CLI sends a pre-built bundle (base64 tar.gz). Orchestrator only does SCP + SSH. + // No Nu, no provisioning CLI invocation. + let Some(ref bundle_b64) = workflow.script_compressed else { + warn!( + "component {} op={operation}: missing script_compressed bundle — rejecting", + workflow.component + ); + return Ok(Json(ApiResponse::error( + "component workflow requires script_compressed bundle from CLI".to_string(), + ))); + }; + + let bundle_file = format!("/tmp/orchestrator_comp_{}.tar.gz.b64", task_id); + tokio::fs::write(&bundle_file, bundle_b64) + .await + .map_err(|e| { + error!("Failed to write component bundle {}: {}", bundle_file, e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + // Orchestrator is an automation daemon — no persistent known_hosts trust store. + // UserKnownHostsFile=/dev/null prevents stale-key failures after server rebuilds. + let key_opt = workflow + .ssh_key_path + .as_deref() + .map(|k| format!("-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i '{k}'")) + .unwrap_or_else(|| "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null".to_string()); + let name = &workflow.component; + let user = &workflow.ssh_user; + let server = &workflow.server; + let check_env = if workflow.check_mode { + "CHECK_MODE=1 " + } else { + "" + }; + // debug=true: bash -x traces every command; remote /tmp/{name} is left intact for ssh inspection. + let bash_runner = if workflow.debug { "bash -x" } else { "bash" }; + let remote_cleanup = if workflow.debug { + // Keep tar.gz and extracted dir on remote so the user can ssh in and inspect. + format!("echo '[debug] leaving /tmp/{name} and /tmp/{name}.tar.gz on remote for inspection'") + } else { + format!("rm -f /tmp/{name}.tar.gz && rm -rf /tmp/{name}") + }; + + // Decode bundle locally, SCP to remote /tmp, extract and execute install script. + // Pass the operation (install/delete/update/reinstall/…) as $1 to the install script. + let ssh_cmd = format!( + "base64 -d < '{bundle_file}' > /tmp/{name}.tar.gz \ + && scp {key_opt} /tmp/{name}.tar.gz '{user}@{server}:/tmp/' \ + && ssh {key_opt} '{user}@{server}' \ + 'rm -rf /tmp/{name} && mkdir -p /tmp/{name} \ + && tar xzf /tmp/{name}.tar.gz -C /tmp/{name} \ + && cd /tmp/{name} \ + && {check_env}sudo {bash_runner} install-{name}.sh {operation} \ + ; rc=$?; {remote_cleanup}; exit $rc' \ + ; rc=$?; rm -f '{bundle_file}' /tmp/{name}.tar.gz; exit $rc", + ); + + let mut component_tags = std::collections::HashMap::new(); + component_tags.insert("type".to_string(), "component".to_string()); + component_tags.insert("component".to_string(), workflow.component.clone()); + component_tags.insert("server".to_string(), workflow.server.clone()); + component_tags.insert("workspace".to_string(), workflow.workspace.clone()); + component_tags.insert("operation".to_string(), operation.clone()); + + let task = WorkflowTask { + id: task_id.clone(), + name: format!("component_{operation}_{}", workflow.component), + command: "bash".to_string(), + args: vec!["-c".to_string(), ssh_cmd], + dependencies: vec![], + status: TaskStatus::Pending, + created_at: chrono::Utc::now(), + started_at: None, + completed_at: None, + output: None, + error: None, + tags: component_tags, + }; + + match state.task_storage.enqueue(task, 6).await { + Ok(()) => { + info!( + "enqueued component {} op={operation} task_id={task_id}", + workflow.component + ); + Ok(Json(ApiResponse::success(task_id))) + } + Err(e) => { + error!("failed to enqueue component task: {e}"); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +#[onto_api( + method = "POST", + path = "/workflows/cluster/create", + description = "Enqueue cluster creation workflow", + auth = "bearer", + actors = "developer, agent", + tags = "workflows, clusters", + feature = "" +)] +pub async fn create_cluster_workflow( + State(state): State, + Json(workflow): Json, +) -> Result>, StatusCode> { + let task_id = Uuid::new_v4().to_string(); + + let task = WorkflowTask { + id: task_id.clone(), + name: format!("{}_cluster", workflow.operation), + command: format!( + "{} cluster {}", + state.args.provisioning_path, workflow.operation + ), + args: vec![ + workflow.cluster_type.clone(), + "--infra".to_string(), + workflow.infra.clone(), + "--settings".to_string(), + workflow.settings.clone(), + if workflow.check_mode { + "--check".to_string() + } else { + "".to_string() + }, + if workflow.wait { + "--wait".to_string() + } else { + "".to_string() + }, + ] + .into_iter() + .filter(|s| !s.is_empty()) + .collect(), + dependencies: vec![], + status: TaskStatus::Pending, + created_at: chrono::Utc::now(), + started_at: None, + completed_at: None, + output: None, + error: None, + tags: Default::default(), + }; + + match state.task_storage.enqueue(task, 7).await { + Ok(()) => { + info!( + "Enqueued cluster {} workflow: {}", + workflow.operation, task_id + ); + Ok(Json(ApiResponse::success(task_id))) + } + Err(e) => { + error!("Failed to enqueue task: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} diff --git a/crates/orchestrator/src/infra_state.rs b/crates/orchestrator/src/infra_state.rs new file mode 100644 index 0000000..29137e1 --- /dev/null +++ b/crates/orchestrator/src/infra_state.rs @@ -0,0 +1,527 @@ +//! Infrastructure state service — NATS subscriber + SurrealDB writer. +//! +//! Bridges the provisioning DAG event stream to the `NS infra DB provisioning` +//! schema. Three responsibilities: +//! +//! 1. **Step tracking** — translates `provisioning.dag.formula.*` events into +//! `dag_run` + `step_state` records in SurrealDB. +//! 2. **DAG run creation** — on `provisioning.infra.dag_run.create` (published +//! by radicle-nats-adapter on patch merge), creates the `dag_run` record and +//! maps the workflow_id for subsequent step events. +//! 3. **Reconciler** — on `provisioning.infra.reconcile.workspace`, compares +//! SurrealDB `resource` records against the live Hetzner API and marks +//! divergent resources as `drifted`. + +use std::collections::HashMap; +use std::sync::Arc; + +use chrono::Utc; +use futures::StreamExt; +use platform_db::SurrealPool; +use platform_nats::NatsBridge; +use reqwest::Client as HttpClient; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; +use tracing::{error, info, warn}; + +// ── NATS subjects ────────────────────────────────────────────────────────────── + +const SUBJ_FORMULA_WILDCARD: &str = "provisioning.dag.formula.*"; +const SUBJ_DAG_RUN_CREATE: &str = "provisioning.infra.dag_run.create"; +const SUBJ_RECONCILE: &str = "provisioning.infra.reconcile.workspace"; + +// ── NATS payload types ───────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct FormulaEvent { + workflow_id: String, + formula_id: String, + workspace: Option, + error: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct DagRunCreatePayload { + pub(crate) workspace: String, + pub(crate) trigger: String, + pub(crate) trigger_ref: Option, + pub(crate) actor: Option, + /// Pre-assigned workflow_id so formula events can be correlated immediately. + pub(crate) workflow_id: Option, +} + +#[derive(Debug, Deserialize)] +struct ReconcilePayload { + workspace: String, +} + +// ── Hetzner API response shapes (minimal) ────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct HetznerServer { + id: u64, + name: String, + status: String, +} + +#[derive(Debug, Deserialize)] +struct HetznerFip { + id: u64, + name: String, + ip: String, + server: Option, +} + +#[derive(Debug, Deserialize)] +struct HetznerServersResponse { + servers: Vec, +} + +#[derive(Debug, Deserialize)] +struct HetznerFipsResponse { + floating_ips: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ResourceRecord { + workspace: String, + kind: String, + name: String, + provider_id: Option, + state: String, + actual: Option, + last_synced: Option>, +} + +// ── Service ──────────────────────────────────────────────────────────────────── + +/// Runs the three infra-state bridges against a shared SurrealDB connection. +pub struct InfraStateService { + db: SurrealPool, + nats: Arc, + http: HttpClient, + hetzner_token: String, + /// workflow_id → dag_run SurrealDB key + run_map: Arc>>, +} + +impl InfraStateService { + pub fn new(db: SurrealPool, nats: Arc, hetzner_token: impl Into) -> Self { + Self { + db, + nats, + http: HttpClient::new(), + hetzner_token: hetzner_token.into(), + run_map: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Subscribe to all three subjects concurrently. + /// Returns when any subscriber closes — caller should restart. + pub async fn serve(self: Arc) -> anyhow::Result<()> { + let s1 = self.clone(); + let s2 = self.clone(); + let s3 = self.clone(); + + let mut h1 = tokio::spawn(async move { s1.subscribe_formula_events().await }); + let mut h2 = tokio::spawn(async move { s2.subscribe_dag_run_create().await }); + let mut h3 = tokio::spawn(async move { s3.subscribe_reconcile().await }); + + let result = tokio::select! { + r = &mut h1 => r.map_err(|e| anyhow::anyhow!("formula events panicked: {e}"))?, + r = &mut h2 => r.map_err(|e| anyhow::anyhow!("dag_run_create panicked: {e}"))?, + r = &mut h3 => r.map_err(|e| anyhow::anyhow!("reconcile panicked: {e}"))?, + }; + + h1.abort(); + h2.abort(); + h3.abort(); + + result + } + + // ── Bridge 1: formula events → step_state ───────────────────────────────── + + async fn subscribe_formula_events(self: Arc) -> anyhow::Result<()> { + let mut sub = self + .nats + .subscribe(SUBJ_FORMULA_WILDCARD) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + info!("InfraState: listening on {SUBJ_FORMULA_WILDCARD}"); + + while let Some(msg) = sub.next().await { + let event_type = msg + .subject + .strip_prefix("provisioning.dag.formula.") + .unwrap_or("unknown"); + + let event: FormulaEvent = match serde_json::from_slice(&msg.payload) { + Ok(e) => e, + Err(e) => { + warn!(error = %e, subject = %msg.subject, "cannot decode FormulaEvent"); + continue; + } + }; + + // Bootstrap a dag_run on first `started` for a new workflow_id. + if event_type == "started" { + let has_run = self.run_map.read().await.contains_key(&event.workflow_id); + if !has_run { + if let Some(ref ws) = event.workspace { + match self + .create_dag_run(ws, "manual", None, "daemon", Some(&event.workflow_id)) + .await + { + Ok(k) => { + self.run_map + .write() + .await + .insert(event.workflow_id.clone(), k); + } + Err(e) => { + error!(error = %e, "failed to create dag_run"); + } + } + } + } + } + + let status = match event_type { + "started" => "running", + "completed" => "completed", + "failed" => "failed", + _ => continue, + }; + + let now = Utc::now(); + let record = serde_json::json!({ + "workflow_id": event.workflow_id, + "formula_id": event.formula_id, + "status": status, + "started_at": if event_type == "started" { Some(now) } else { None::> }, + "ended_at": if event_type != "started" { Some(now) } else { None::> }, + "error": event.error, + }); + + let key = format!("{}_{}", event.workflow_id, event.formula_id); + self.upsert_record("step_state", &key, record).await; + + // On terminal events: close the dag_run and prune the run_map entry. + if event_type == "completed" || event_type == "failed" { + if let Some(run_key) = self.run_map.read().await.get(&event.workflow_id).cloned() { + let update = serde_json::json!({ + "status": status, + "ended_at": now, + }); + let close_result = self + .db + .db() + .query("USE NS infra DB provisioning; UPDATE type::thing('dag_run', $key) MERGE $update") + .bind(("key", run_key)) + .bind(("update", update)) + .await + .map_err(|e| anyhow::anyhow!("{e}")) + .and_then(|r| r.check().map_err(|e| anyhow::anyhow!("{e}"))); + + match close_result { + Ok(_) => { self.run_map.write().await.remove(&event.workflow_id); } + Err(e) => error!(error = %e, workflow_id = %event.workflow_id, "failed to close dag_run"), + } + } + } + } + + Err(anyhow::anyhow!("{SUBJ_FORMULA_WILDCARD} subscription closed")) + } + + // ── Bridge 2: dag_run.create → dag_run record ───────────────────────────── + + async fn subscribe_dag_run_create(self: Arc) -> anyhow::Result<()> { + let mut sub = self + .nats + .subscribe(SUBJ_DAG_RUN_CREATE) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + info!("InfraState: listening on {SUBJ_DAG_RUN_CREATE}"); + + while let Some(msg) = sub.next().await { + let payload: DagRunCreatePayload = match serde_json::from_slice(&msg.payload) { + Ok(p) => p, + Err(e) => { + warn!(error = %e, "cannot decode DagRunCreatePayload"); + continue; + } + }; + + info!( + workspace = %payload.workspace, + trigger = %payload.trigger, + trigger_ref = ?payload.trigger_ref, + "creating dag_run from external trigger" + ); + + match self + .create_dag_run( + &payload.workspace, + &payload.trigger, + payload.trigger_ref.as_deref(), + payload.actor.as_deref().unwrap_or("unknown"), + payload.workflow_id.as_deref(), + ) + .await + { + Ok(k) => { + if let Some(wf_id) = payload.workflow_id { + self.run_map.write().await.insert(wf_id, k); + } + } + Err(e) => error!(error = %e, "failed to create dag_run"), + } + } + + Err(anyhow::anyhow!("{SUBJ_DAG_RUN_CREATE} subscription closed")) + } + + // ── Bridge 3: reconcile.workspace → resource drift detection ───────────── + + async fn subscribe_reconcile(self: Arc) -> anyhow::Result<()> { + let mut sub = self + .nats + .subscribe(SUBJ_RECONCILE) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + info!("InfraState: listening on {SUBJ_RECONCILE}"); + + while let Some(msg) = sub.next().await { + let payload: ReconcilePayload = match serde_json::from_slice(&msg.payload) { + Ok(p) => p, + Err(e) => { + warn!(error = %e, "cannot decode ReconcilePayload"); + continue; + } + }; + + if let Err(e) = self.reconcile_workspace(&payload.workspace).await { + error!(workspace = %payload.workspace, error = %e, "reconcile failed"); + } + } + + Err(anyhow::anyhow!("{SUBJ_RECONCILE} subscription closed")) + } + + // ── SurrealDB helpers ───────────────────────────────────────────────────── + + // USE NS is inlined in each query via semicolon chaining so no extra round-trip + // is needed before each operation and the connection can be freely shared. + + async fn upsert_record(&self, table: &str, key: &str, record: serde_json::Value) { + let query = format!( + "USE NS infra DB provisioning; UPSERT type::thing('{table}', $key) MERGE $record" + ); + match self + .db + .db() + .query(query) + .bind(("key", key.to_string())) + .bind(("record", record)) + .await + { + Ok(resp) => { + if let Err(e) = resp.check() { + error!(table = %table, key = %key, error = %e, "upsert statement failed"); + } + } + Err(e) => error!(table = %table, key = %key, error = %e, "upsert transport failed"), + } + } + + /// Create a dag_run record. Returns the SurrealDB record key on success. + async fn create_dag_run( + &self, + workspace: &str, + trigger: &str, + trigger_ref: Option<&str>, + actor: &str, + workflow_id: Option<&str>, + ) -> anyhow::Result { + let key = workflow_id + .map(|id| id.to_string()) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + + let record = serde_json::json!({ + "workspace": workspace, + "trigger": trigger, + "trigger_ref": trigger_ref, + "actor": actor, + "status": "running", + "started_at": Utc::now(), + "ended_at": null, + }); + + self.db + .db() + .query("USE NS infra DB provisioning; UPSERT type::thing('dag_run', $key) CONTENT $record") + .bind(("key", key.clone())) + .bind(("record", record)) + .await + .map_err(|e| anyhow::anyhow!("dag_run transport error: {e}"))? + .check() + .map_err(|e| anyhow::anyhow!("dag_run statement error: {e}"))?; + + info!(key = %key, workspace = %workspace, trigger = %trigger, "dag_run created"); + Ok(key) + } + + /// Upsert a resource record (present / absent / drifted / unknown). + pub async fn upsert_resource( + &self, + workspace: &str, + kind: &str, + name: &str, + provider_id: Option<&str>, + state: &str, + actual: Option, + ) -> anyhow::Result<()> { + let key = format!("{workspace}/{kind}/{name}"); + let record = serde_json::json!({ + "workspace": workspace, + "kind": kind, + "name": name, + "provider_id": provider_id, + "state": state, + "actual": actual, + "last_synced": Utc::now(), + }); + + self.db + .db() + .query("USE NS infra DB provisioning; UPSERT type::thing('resource', $key) MERGE $record") + .bind(("key", key)) + .bind(("record", record)) + .await + .map_err(|e| anyhow::anyhow!("resource transport error: {e}"))? + .check() + .map_err(|e| anyhow::anyhow!("resource statement error: {e}"))?; + + Ok(()) + } + + // ── Reconciler ──────────────────────────────────────────────────────────── + + async fn reconcile_workspace(&self, workspace: &str) -> anyhow::Result<()> { + info!(workspace = %workspace, "reconciling resource state"); + + let raw: Vec = self + .db + .db() + .query("USE NS infra DB provisioning; SELECT * FROM resource WHERE workspace = $ws") + .bind(("ws", workspace.to_string())) + .await + .map_err(|e| anyhow::anyhow!("resource SELECT failed: {e}"))? + .take(1) + .map_err(|e| anyhow::anyhow!("resource take failed: {e}"))?; + + let tracked: Vec = raw + .into_iter() + .filter_map(|v| serde_json::from_value(v).ok()) + .collect(); + + let live_servers = self.hetzner_list_servers().await.unwrap_or_else(|e| { + warn!(error = %e, "failed to list Hetzner servers"); + vec![] + }); + let live_fips = self.hetzner_list_fips().await.unwrap_or_else(|e| { + warn!(error = %e, "failed to list Hetzner FIPs"); + vec![] + }); + + let live_server_names: std::collections::HashSet<&str> = + live_servers.iter().map(|s| s.name.as_str()).collect(); + let live_fip_names: std::collections::HashSet<&str> = + live_fips.iter().map(|f| f.name.as_str()).collect(); + + for resource in &tracked { + let live_present = match resource.kind.as_str() { + "server" => live_server_names.contains(resource.name.as_str()), + "fip" | "floating_ip" => live_fip_names.contains(resource.name.as_str()), + _ => true, + }; + + let new_state = if live_present { "present" } else { "drifted" }; + + if new_state == resource.state { + continue; + } + + info!( + workspace = %workspace, + kind = %resource.kind, + name = %resource.name, + old = %resource.state, + new = %new_state, + "drift detected" + ); + + let actual = match resource.kind.as_str() { + "server" => live_servers + .iter() + .find(|s| s.name == resource.name) + .map(|s| serde_json::json!({"id": s.id, "name": s.name, "status": s.status})), + "fip" | "floating_ip" => live_fips + .iter() + .find(|f| f.name == resource.name) + .map(|f| serde_json::json!({"id": f.id, "name": f.name, "ip": f.ip, "server": f.server})), + _ => None, + }; + + if let Err(e) = self + .upsert_resource( + &resource.workspace, + &resource.kind, + &resource.name, + resource.provider_id.as_deref(), + new_state, + actual, + ) + .await + { + error!(error = %e, "failed to update resource state after drift"); + } + } + + info!(workspace = %workspace, total = tracked.len(), "reconcile complete"); + Ok(()) + } + + // ── Hetzner API calls ───────────────────────────────────────────────────── + + async fn hetzner_list_servers(&self) -> anyhow::Result> { + let resp = self + .http + .get("https://api.hetzner.cloud/v1/servers") + .bearer_auth(&self.hetzner_token) + .send() + .await? + .error_for_status()? + .json::() + .await?; + Ok(resp.servers) + } + + async fn hetzner_list_fips(&self) -> anyhow::Result> { + let resp = self + .http + .get("https://api.hetzner.cloud/v1/floating_ips") + .bearer_auth(&self.hetzner_token) + .send() + .await? + .error_for_status()? + .json::() + .await?; + Ok(resp.floating_ips) + } +} diff --git a/crates/orchestrator/src/lib.rs b/crates/orchestrator/src/lib.rs index ce92847..87ceab3 100644 --- a/crates/orchestrator/src/lib.rs +++ b/crates/orchestrator/src/lib.rs @@ -29,6 +29,23 @@ pub struct WorkflowTask { pub completed_at: Option>, pub output: Option, pub error: Option, + /// Arbitrary key-value context carried with the task. + /// Component tasks set: type=component, component, server, workspace, operation. + #[serde(default)] + pub tags: std::collections::HashMap, +} + +/// NATS payload for `provisioning.component.lifecycle`. +/// Published by the orchestrator executor; consumed by the nu_daemon subscriber. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComponentLifecycleEvent { + pub task_id: String, + pub component: String, + pub server: String, + pub workspace: String, + pub operation: String, + /// "completed" | "failed" + pub status: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -75,6 +92,50 @@ pub struct ClusterWorkflow { pub wait: bool, } +/// Unified component lifecycle request body for `/api/v1/workflows/component/{op}`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComponentWorkflow { + /// Workspace root path (absolute). + pub workspace: String, + /// Infra subdirectory name under `infra/`. + pub infra: String, + /// Component name (must exist in workspace components dir). + pub component: String, + /// Target server hostname or cluster controller. + pub server: String, + /// Kubernetes namespace (cluster-mode only). + #[serde(default)] + pub namespace: Option, + /// SSH user for remote execution. + #[serde(default = "default_ssh_user")] + pub ssh_user: String, + /// Path to SSH identity file. + #[serde(default)] + pub ssh_key_path: Option, + /// Serialised settings payload forwarded to the component script. + pub settings: String, + /// Dry-run flag — passed as `CHECK_MODE=1` to the component script. + #[serde(default)] + pub check_mode: bool, + /// Debug flag — uses `bash -x` on the remote script and leaves /tmp/{name} intact for inspection. + #[serde(default)] + pub debug: bool, + /// Absolute path to the provisioning repo root (`$PROVISIONING`). + pub provisioning: String, + /// Base64-encoded tar.gz bundle built by the CLI (copy + Tera render). + /// When present the orchestrator skips Nu entirely: decode → SCP → SSH execute. + #[serde(default)] + pub script_compressed: Option, + /// Deploy mode declared in settings.ncl: "taskserv" | "cluster" | "container". + /// Used by the precondition gate to skip live-probe checks for taskserv components. + #[serde(default)] + pub mode: Option, +} + +fn default_ssh_user() -> String { + "root".to_string() +} + /// Validate storage type argument pub fn validate_storage_type(s: &str) -> Result { let available_types = storage::available_storage_types(); @@ -175,8 +236,13 @@ pub struct Args { pub nu_path: String, /// Provisioning script path - #[arg(long, default_value = "./core/nulib/provisioning")] + #[arg(long, default_value = "provisioning")] pub provisioning_path: String, + + /// Print all #[onto_api] registered routes as JSON and exit. + /// Pipe to api-catalog-orchestrator.json: `just export-api-catalog` + #[arg(long)] + pub dump_api_catalog: bool, } // ============================================================================ @@ -187,11 +253,15 @@ pub mod app_state_builder; pub mod config; pub mod config_manager; pub mod middleware; +pub mod response; pub mod orchestrator_state; +pub mod preconditions; +pub mod probes; pub mod secrets; pub mod security; pub mod security_integration; pub mod services; +pub mod ssh_exec; pub mod state; pub mod storage; @@ -207,6 +277,9 @@ pub mod audit; #[cfg(feature = "workflow")] pub mod batch; +#[cfg(feature = "workflow")] +pub mod formula; + #[cfg(feature = "workflow")] pub mod dependency; @@ -243,12 +316,19 @@ pub mod dns; pub mod extensions; #[cfg(feature = "platform")] -pub mod oci; +pub use security::oci; // SSH: SSH key management #[cfg(feature = "ssh")] pub mod ssh; +// Infrastructure state service — NATS subscriber + SurrealDB writer +#[cfg(feature = "nats")] +pub mod infra_state; + +// Build infrastructure: ephemeral VM pool for buildkit runners (ADR-039) +pub mod vm_pool; + // Testing: Test environment and container management #[cfg(feature = "testing")] pub mod container_manager; @@ -303,6 +383,7 @@ pub use middleware::AuditMiddleware; #[cfg(feature = "platform")] pub use oci::{OciArtifact, OciClient, OciManager, OciManifest}; pub use orchestrator_state::{AppState, SharedState}; +pub use response::ApiResponse; pub use secrets::{ create_secrets_router, Credentials, DynamicSecret, RenewRequest, RevokeRequest, SecretMetadata, SecretRequest, SecretStats, SecretType, SecretsConfig, SecretsService, diff --git a/crates/orchestrator/src/main.rs b/crates/orchestrator/src/main.rs index b9bf6cb..bf7d454 100644 --- a/crates/orchestrator/src/main.rs +++ b/crates/orchestrator/src/main.rs @@ -1,1128 +1,27 @@ -use std::env; use std::sync::Arc; use anyhow::{Context, Result}; -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::Json, - routing::{get, post}, - Router, -}; use clap::Parser; use platform_config::{load_deployment_mode, PlatformStartup}; -// Use types from the library -use provisioning_orchestrator::{ - audit::{AuditEvent, AuditFilter, AuditQuery, RetentionPolicy, SiemFormat}, - batch::{BatchOperationRequest, BatchOperationResult}, - compliance_routes, - config::OrchestratorConfig, - monitor::{MonitoringEvent, MonitoringEventType, SystemHealthStatus}, - rollback::{Checkpoint, RollbackResult, RollbackStatistics}, - state::{ProgressInfo, StateManagerStatistics, StateSnapshot, SystemMetrics}, - test_environment::{ - CreateTestEnvironmentRequest, RunTestRequest, TestEnvironment, TestEnvironmentResponse, - TestResult, - }, - webhooks::{handle_webhook, WebhookState, WorkspaceRegistry}, - workflow::WorkflowExecutionState, - AppState, Args, ClusterWorkflow, CreateServerWorkflow, SharedState, TaskStatus, - TaskservWorkflow, WorkflowTask, -}; -use serde::{Deserialize, Serialize}; -use tower_http::cors::CorsLayer; -use tracing::{error, info, warn}; -use uuid::Uuid; - -#[derive(Serialize)] -struct ApiResponse { - success: bool, - data: Option, - error: Option, -} - -impl ApiResponse { - fn success(data: T) -> Self { - Self { - success: true, - data: Some(data), - error: None, - } - } - - fn error(message: String) -> Self { - Self { - success: false, - data: None, - error: Some(message), - } - } -} - -// REST API Routes - -async fn create_server_workflow( - State(state): State, - Json(workflow): Json, -) -> Result>, StatusCode> { - let task_id = Uuid::new_v4().to_string(); - - // PROPER ARCHITECTURE: CLI renders script, orchestrator executes it - // If script_compressed is provided: execute it (that's ALL the orchestrator - // does) If NOT provided: error (legacy mode should not happen) - let task = if let Some(ref script_compressed) = workflow.script_compressed { - // CLI has provided the COMPLETE SCRIPT ready to execute - // No command construction, no decision logic - // Just: decompress -> execute - - // Store script in temp file for execution - let script_file = format!("/tmp/orchestrator_script_{}.tar.gz.b64", task_id); - std::fs::write(&script_file, script_compressed).ok(); - - WorkflowTask { - id: task_id.clone(), - name: if workflow.servers.is_empty() { - "execute_servers_script_all".to_string() - } else { - format!("execute_servers_script_{}", workflow.servers.join("_")) - }, - // Execute the decompressed script directly - command: "bash".to_string(), - args: vec![ - "-c".to_string(), - // Decompress: base64 decode -> gunzip -> extract script.sh -> execute - // CRITICAL: Use '+x' to DISABLE debug mode and prevent credential exposure - // Even if script contains 'set -x', it won't execute with +x flag - format!( - "base64 -d < {} | gunzip | tar -xOf - script.sh | bash +x", - script_file - ), - ], - dependencies: vec![], - status: TaskStatus::Pending, - created_at: chrono::Utc::now(), - started_at: None, - completed_at: None, - output: None, - error: None, - } - } else { - // LEGACY: Construct command from parameters (deprecated) - let mut args = vec![ - "--infra".to_string(), - workflow.infra.clone(), - "--settings".to_string(), - workflow.settings.clone(), - ]; - - for server in &workflow.servers { - args.push(server.clone()); - } - - if workflow.check_mode { - args.push("--check".to_string()); - } - if workflow.wait { - args.push("--wait".to_string()); - } - - WorkflowTask { - id: task_id.clone(), - name: if workflow.servers.is_empty() { - "create_servers_all".to_string() - } else { - format!("create_servers_{}", workflow.servers.join("_")) - }, - command: format!("{} servers create", state.args.provisioning_path), - args, - dependencies: vec![], - status: TaskStatus::Pending, - created_at: chrono::Utc::now(), - started_at: None, - completed_at: None, - output: None, - error: None, - } - }; - - let server_summary = if workflow.servers.is_empty() { - "all servers".to_string() - } else { - format!("{} server(s)", workflow.servers.len()) - }; - - match state.task_storage.enqueue(task, 5).await { - Ok(()) => { - info!( - "Enqueued server creation workflow ({}): {} | infra: {}", - server_summary, task_id, workflow.infra - ); - Ok(Json(ApiResponse::success(task_id))) - } - Err(e) => { - error!( - "Failed to enqueue server creation task ({}): {}", - server_summary, e - ); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } -} - -async fn create_taskserv_workflow( - State(state): State, - Json(workflow): Json, -) -> Result>, StatusCode> { - let task_id = Uuid::new_v4().to_string(); - - let task = WorkflowTask { - id: task_id.clone(), - name: format!("{}_taskserv", workflow.operation), - command: format!( - "{} taskserv {}", - state.args.provisioning_path, workflow.operation - ), - args: vec![ - workflow.taskserv.clone(), - "--infra".to_string(), - workflow.infra.clone(), - "--settings".to_string(), - workflow.settings.clone(), - if workflow.check_mode { - "--check".to_string() - } else { - "".to_string() - }, - if workflow.wait { - "--wait".to_string() - } else { - "".to_string() - }, - ] - .into_iter() - .filter(|s| !s.is_empty()) - .collect(), - dependencies: vec![], - status: TaskStatus::Pending, - created_at: chrono::Utc::now(), - started_at: None, - completed_at: None, - output: None, - error: None, - }; - - match state.task_storage.enqueue(task, 6).await { - Ok(()) => { - info!( - "Enqueued taskserv {} workflow: {}", - workflow.operation, task_id - ); - Ok(Json(ApiResponse::success(task_id))) - } - Err(e) => { - error!("Failed to enqueue task: {}", e); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } -} - -async fn create_cluster_workflow( - State(state): State, - Json(workflow): Json, -) -> Result>, StatusCode> { - let task_id = Uuid::new_v4().to_string(); - - let task = WorkflowTask { - id: task_id.clone(), - name: format!("{}_cluster", workflow.operation), - command: format!( - "{} cluster {}", - state.args.provisioning_path, workflow.operation - ), - args: vec![ - workflow.cluster_type.clone(), - "--infra".to_string(), - workflow.infra.clone(), - "--settings".to_string(), - workflow.settings.clone(), - if workflow.check_mode { - "--check".to_string() - } else { - "".to_string() - }, - if workflow.wait { - "--wait".to_string() - } else { - "".to_string() - }, - ] - .into_iter() - .filter(|s| !s.is_empty()) - .collect(), - dependencies: vec![], - status: TaskStatus::Pending, - created_at: chrono::Utc::now(), - started_at: None, - completed_at: None, - output: None, - error: None, - }; - - match state.task_storage.enqueue(task, 7).await { - Ok(()) => { - info!( - "Enqueued cluster {} workflow: {}", - workflow.operation, task_id - ); - Ok(Json(ApiResponse::success(task_id))) - } - Err(e) => { - error!("Failed to enqueue task: {}", e); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } -} - -async fn get_task_status( - State(state): State, - Path(task_id): Path, -) -> Result>, StatusCode> { - match state.task_storage.get_task(&task_id).await { - Ok(Some(task)) => Ok(Json(ApiResponse::success(task))), - Ok(None) => Err(StatusCode::NOT_FOUND), - Err(e) => { - error!("Failed to get task {}: {}", task_id, e); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } -} - -async fn list_tasks( - State(state): State, -) -> Result>>, StatusCode> { - match state.task_storage.list_tasks(None).await { - Ok(task_list) => Ok(Json(ApiResponse::success(task_list))), - Err(e) => { - error!("Failed to list tasks: {}", e); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } -} - -async fn health_check() -> Json> { - Json(ApiResponse::success("Orchestrator is healthy".to_string())) -} - -// Batch operation routes - -async fn execute_batch_operation( - State(state): State, - Json(request): Json, -) -> Result>, StatusCode> { - match state - .batch_coordinator - .execute_batch_operation(request) - .await - { - Ok(result) => Ok(Json(ApiResponse::success(result))), - Err(e) => { - error!("Failed to execute batch operation: {}", e); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } -} - -async fn get_batch_operation_status( - State(state): State, - Path(operation_id): Path, -) -> Result>, StatusCode> { - match state - .batch_coordinator - .get_operation_status(&operation_id) - .await - { - Some(state) => Ok(Json(ApiResponse::success(state))), - None => Err(StatusCode::NOT_FOUND), - } -} - -async fn list_batch_operations( - State(state): State, -) -> Result>>, StatusCode> { - let operations = state.batch_coordinator.list_operations().await; - Ok(Json(ApiResponse::success(operations))) -} - -async fn cancel_batch_operation( - State(state): State, - Path(operation_id): Path, -) -> Result>, StatusCode> { - match state - .batch_coordinator - .cancel_operation(&operation_id) - .await - { - Ok(_) => Ok(Json(ApiResponse::success(format!( - "Operation {} cancelled", - operation_id - )))), - Err(e) => { - error!("Failed to cancel operation {}: {}", operation_id, e); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } -} - -// Rollback and recovery routes - -#[derive(Deserialize)] -struct CreateCheckpointRequest { - name: String, - description: Option, -} - -#[derive(Deserialize)] -struct RollbackRequest { - checkpoint_id: Option, - operation_ids: Option>, -} - -async fn create_checkpoint( - State(state): State, - Json(request): Json, -) -> Result>, StatusCode> { - match state - .rollback_system - .checkpoint_manager - .create_checkpoint(request.name, request.description) - .await - { - Ok(checkpoint_id) => { - info!("Created checkpoint: {}", checkpoint_id); - Ok(Json(ApiResponse::success(checkpoint_id))) - } - Err(e) => { - error!("Failed to create checkpoint: {}", e); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } -} - -async fn list_checkpoints( - State(state): State, -) -> Result>>, StatusCode> { - let checkpoints = state - .rollback_system - .checkpoint_manager - .list_checkpoints() - .await; - Ok(Json(ApiResponse::success(checkpoints))) -} - -async fn get_checkpoint( - State(state): State, - Path(checkpoint_id): Path, -) -> Result>, StatusCode> { - match state - .rollback_system - .checkpoint_manager - .get_checkpoint(&checkpoint_id) - .await - { - Some(checkpoint) => Ok(Json(ApiResponse::success(checkpoint))), - None => Err(StatusCode::NOT_FOUND), - } -} - -async fn execute_rollback( - State(state): State, - Json(request): Json, -) -> Result>, StatusCode> { - let result = if let Some(checkpoint_id) = request.checkpoint_id { - // Rollback to specific checkpoint - match state - .rollback_system - .rollback_executor - .rollback_to_checkpoint(&checkpoint_id) - .await - { - Ok(result) => result, - Err(e) => { - error!( - "Failed to execute rollback to checkpoint {}: {}", - checkpoint_id, e - ); - return Err(StatusCode::INTERNAL_SERVER_ERROR); - } - } - } else if let Some(operation_ids) = request.operation_ids { - // Partial rollback of specific operations - match state - .rollback_system - .rollback_executor - .rollback_operations(operation_ids) - .await - { - Ok(result) => result, - Err(e) => { - error!("Failed to execute partial rollback: {}", e); - return Err(StatusCode::INTERNAL_SERVER_ERROR); - } - } - } else { - return Err(StatusCode::BAD_REQUEST); - }; - - if result.success { - info!( - "Rollback executed successfully: {} operations", - result.operations_executed - ); - } else { - warn!( - "Rollback completed with errors: {}/{} operations failed", - result.operations_failed, - result.operations_executed + result.operations_failed - ); - } - - Ok(Json(ApiResponse::success(result))) -} - -async fn get_rollback_statistics( - State(state): State, -) -> Result>, StatusCode> { - let stats = state.rollback_system.get_statistics().await; - Ok(Json(ApiResponse::success(stats))) -} - -async fn restore_from_checkpoint( - State(state): State, - Path(checkpoint_id): Path, -) -> Result>, StatusCode> { - match state - .rollback_system - .state_restorer - .restore_from_checkpoint(&checkpoint_id) - .await - { - Ok(_) => { - info!( - "Successfully restored state from checkpoint: {}", - checkpoint_id - ); - Ok(Json(ApiResponse::success(format!( - "State restored from checkpoint {}", - checkpoint_id - )))) - } - Err(e) => { - error!("Failed to restore from checkpoint {}: {}", checkpoint_id, e); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } -} - -// State management and monitoring routes - -async fn get_workflow_progress( - State(state): State, - Path(workflow_id): Path, -) -> Result>, StatusCode> { - match state - .progress_tracker - .get_real_time_progress(&workflow_id) - .await - { - Some(progress) => Ok(Json(ApiResponse::success(progress))), - None => Err(StatusCode::NOT_FOUND), - } -} - -async fn get_workflow_snapshots( - State(state): State, - Path(workflow_id): Path, -) -> Result>>, StatusCode> { - let snapshots = state - .state_manager - .get_workflow_snapshots(&workflow_id) - .await; - Ok(Json(ApiResponse::success(snapshots))) -} - -async fn get_system_metrics( - State(state): State, -) -> Result>, StatusCode> { - let metrics = state.state_manager.get_system_metrics().await; - Ok(Json(ApiResponse::success(metrics))) -} - -async fn get_system_health( - State(state): State, -) -> Result>, StatusCode> { - let health_status = state - .monitoring_system - .health_monitor() - .get_system_health() - .await; - Ok(Json(ApiResponse::success(health_status))) -} - -async fn get_state_statistics( - State(state): State, -) -> Result>, StatusCode> { - match state.state_manager.get_statistics().await { - Ok(stats) => Ok(Json(ApiResponse::success(stats))), - Err(e) => { - error!("Failed to get state statistics: {}", e); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } -} - -// Test environment routes - -async fn create_test_environment( - State(state): State, - Json(request): Json, -) -> Result>, StatusCode> { - match state.test_orchestrator.create_environment(request).await { - Ok(response) => Ok(Json(ApiResponse::success(response))), - Err(e) => { - error!("Failed to create test environment: {}", e); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } -} - -async fn get_test_environment( - State(state): State, - Path(env_id): Path, -) -> Result>, StatusCode> { - match state.test_orchestrator.get_environment(&env_id).await { - Some(env) => Ok(Json(ApiResponse::success(env))), - None => Err(StatusCode::NOT_FOUND), - } -} - -async fn list_test_environments( - State(state): State, -) -> Result>>, StatusCode> { - let environments = state.test_orchestrator.list_environments().await; - Ok(Json(ApiResponse::success(environments))) -} - -async fn run_environment_tests( - State(state): State, - Path(env_id): Path, - Json(request): Json, -) -> Result>>, StatusCode> { - match state.test_orchestrator.run_tests(&env_id, request).await { - Ok(results) => Ok(Json(ApiResponse::success(results))), - Err(e) => { - error!("Failed to run tests: {}", e); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } -} - -async fn cleanup_test_environment( - State(state): State, - Path(env_id): Path, -) -> Result>, StatusCode> { - match state - .test_orchestrator - .cleanup_environment(&env_id, false) - .await - { - Ok(_) => Ok(Json(ApiResponse::success(format!( - "Environment {} cleaned up", - env_id - )))), - Err(e) => { - error!("Failed to cleanup environment: {}", e); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } -} - -async fn get_environment_logs( - State(state): State, - Path(env_id): Path, -) -> Result>>, StatusCode> { - match state.test_orchestrator.get_environment_logs(&env_id).await { - Ok(logs) => Ok(Json(ApiResponse::success(logs))), - Err(e) => { - error!("Failed to get environment logs: {}", e); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } -} - -// DNS Management Routes - -async fn list_dns_records( - State(state): State, -) -> Result>>, StatusCode> { - match state.dns_manager.list_records().await { - Ok(records) => Ok(Json(ApiResponse::success(records))), - Err(e) => { - error!("Failed to list DNS records: {}", e); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } -} - -// Extension Management Routes - -async fn list_loaded_extensions( - State(state): State, -) -> Result>>, StatusCode> { - let extensions = state.extension_manager.list_loaded_extensions().await; - Ok(Json(ApiResponse::success(extensions))) -} - -#[derive(Deserialize)] -struct ReloadExtensionRequest { - extension_type: String, - name: String, -} - -async fn reload_extension( - State(state): State, - Json(request): Json, -) -> Result>, StatusCode> { - use provisioning_orchestrator::extensions::ExtensionType; - - let ext_type = match request.extension_type.as_str() { - "provider" => ExtensionType::Provider, - "taskserv" => ExtensionType::Taskserv, - "cluster" => ExtensionType::Cluster, - _ => return Err(StatusCode::BAD_REQUEST), - }; - - match state - .extension_manager - .reload_extension(ext_type, request.name.clone()) - .await - { - Ok(_) => Ok(Json(ApiResponse::success(format!( - "Extension {} reloaded", - request.name - )))), - Err(e) => { - error!("Failed to reload extension: {}", e); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } -} - -// OCI Registry Routes - -#[derive(Deserialize)] -struct ListOciArtifactsRequest { - namespace: String, -} - -async fn list_oci_artifacts( - State(state): State, - Json(request): Json, -) -> Result>>, StatusCode> { - match state - .oci_manager - .list_oci_artifacts(&request.namespace) - .await - { - Ok(artifacts) => Ok(Json(ApiResponse::success(artifacts))), - Err(e) => { - error!("Failed to list OCI artifacts: {}", e); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } -} - -// Service Orchestration Routes - -async fn list_services( - State(state): State, -) -> Result>>, StatusCode> { - let services = state.service_orchestrator.list_services().await; - Ok(Json(ApiResponse::success(services))) -} - -#[derive(Serialize)] -struct ServiceStatusResponse { - name: String, - status: provisioning_orchestrator::services::ServiceStatus, -} - -async fn get_services_status( - State(state): State, -) -> Result>>, StatusCode> { - let services = state.service_orchestrator.list_services().await; - let mut statuses = Vec::new(); - - for service in services { - if let Ok(status) = state - .service_orchestrator - .get_service_status(&service.name) - .await - { - statuses.push(ServiceStatusResponse { - name: service.name, - status, - }); - } - } - - Ok(Json(ApiResponse::success(statuses))) -} - -// ============================================================================ -// Audit API Handlers -// ============================================================================ - -/// Query audit logs with filters -async fn query_audit_logs( - State(state): State, - Json(query): Json, -) -> Result>>, StatusCode> { - match state.audit_logger.query(query).await { - Ok(events) => Ok(Json(ApiResponse::success(events))), - Err(e) => { - error!("Failed to query audit logs: {}", e); - Ok(Json(ApiResponse::error(format!("Query failed: {}", e)))) - } - } -} - -/// Export audit logs to specific format -#[derive(Debug, Deserialize)] -struct ExportRequest { - format: SiemFormat, - filter: Option, -} - -async fn export_audit_logs( - State(state): State, - Json(request): Json, -) -> Result { - match state - .audit_logger - .export(request.format, request.filter) - .await - { - Ok(data) => { - let content_type = match request.format { - SiemFormat::Json - | SiemFormat::JsonLines - | SiemFormat::SplunkCim - | SiemFormat::ElasticEcs => "application/json", - SiemFormat::Csv => "text/csv", - }; - - Ok(axum::response::Response::builder() - .status(StatusCode::OK) - .header("Content-Type", content_type) - .body(axum::body::Body::from(data)) - .unwrap()) - } - Err(e) => { - error!("Failed to export audit logs: {}", e); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } -} - -/// Get audit logger statistics -async fn get_audit_stats( - State(state): State, -) -> Result>, StatusCode> { - let stats = state.audit_logger.stats().await; - Ok(Json(ApiResponse::success( - serde_json::to_value(stats).unwrap_or_default(), - ))) -} - -/// Get audit storage statistics -async fn get_audit_storage_stats( - State(_state): State, -) -> Result>, StatusCode> { - // Note: This would require access to the storage backend - // For now, return a placeholder - Ok(Json(ApiResponse::success(serde_json::json!({ - "total_events": 0, - "total_size_bytes": 0, - "oldest_event": null, - "newest_event": null, - "note": "Storage stats require direct storage access" - })))) -} - -/// Apply retention policy to audit logs -async fn apply_audit_retention( - State(state): State, - Json(_policy): Json, -) -> Result>, StatusCode> { - // Temporarily update the audit logger's retention policy - // In production, this should be done through configuration - match state.audit_logger.apply_retention().await { - Ok(deleted) => Ok(Json(ApiResponse::success(deleted))), - Err(e) => { - error!("Failed to apply retention policy: {}", e); - Ok(Json(ApiResponse::error(format!("Retention failed: {}", e)))) - } - } -} - -/// Search audit logs with text query -#[derive(Debug, Deserialize)] -struct SearchRequest { - query: String, - field: String, -} - -async fn search_audit_logs( - State(state): State, - Json(request): Json, -) -> Result>>, StatusCode> { - // Build filter based on field - let filter = match request.field.as_str() { - "user" => AuditFilter { - user_id: Some(request.query.clone()), - ..Default::default() - }, - "resource" => AuditFilter { - resource: Some(request.query.clone()), - ..Default::default() - }, - "workspace" => AuditFilter { - workspace: Some(request.query.clone()), - ..Default::default() - }, - _ => { - // Search all fields - this is a simple implementation - // In production, you'd want full-text search - AuditFilter::default() - } - }; - - let query = AuditQuery { - filter, - limit: Some(100), - offset: None, - sort_by: Some("timestamp".to_string()), - sort_desc: true, - }; - - match state.audit_logger.query(query).await { - Ok(events) => Ok(Json(ApiResponse::success(events))), - Err(e) => { - error!("Failed to search audit logs: {}", e); - Ok(Json(ApiResponse::error(format!("Search failed: {}", e)))) - } - } -} - -// Task processor - runs tasks from the queue -async fn process_tasks(state: SharedState) { - info!("Starting task processor"); - - let metrics_collector = state.monitoring_system.metrics_collector(); - - loop { - match state.task_storage.dequeue().await { - Ok(Some(mut task)) => { - // Track task dequeue - metrics_collector.increment_task_counter(); - metrics_collector.record_storage_operation(true); - - if let Err(e) = state - .task_storage - .update_task_status(&task.id, TaskStatus::Running) - .await - { - error!("Failed to update task status: {}", e); - metrics_collector.record_storage_operation(false); - continue; - } - - #[cfg(feature = "nats")] - state - .publish_task_status(&task.id, "running", Some(0), None) - .await; - - info!("Processing task: {} ({})", task.id, task.name); - - let task_start = std::time::Instant::now(); - let result = state - .execute_nushell_command(&task.command, &task.args) - .await; - let task_duration = task_start.elapsed(); - - match result { - Ok(output) => { - info!("Task {} completed successfully", task.id); - - task.output = Some(output); - task.status = TaskStatus::Completed; - task.completed_at = Some(chrono::Utc::now()); - - #[cfg(feature = "nats")] - state - .publish_task_status(&task.id, "completed", Some(100), None) - .await; - - // Record metrics - metrics_collector.record_task_completion(task_duration.as_millis() as u64); - - // Publish monitoring event - let event = MonitoringEvent { - event_type: MonitoringEventType::TaskStatusChanged, - timestamp: chrono::Utc::now(), - data: serde_json::to_value(&task).unwrap_or_default(), - metadata: { - let mut meta = std::collections::HashMap::new(); - meta.insert("task_id".to_string(), task.id.clone()); - meta.insert("status".to_string(), "completed".to_string()); - meta.insert( - "duration_ms".to_string(), - task_duration.as_millis().to_string(), - ); - meta - }, - }; - - if let Err(e) = state.monitoring_system.publish_event(event).await { - error!("Failed to publish monitoring event: {}", e); - } - - if let Err(e) = state.task_storage.update_task(task).await { - error!("Failed to update task: {}", e); - metrics_collector.record_storage_operation(false); - } else { - metrics_collector.record_storage_operation(true); - } - } - Err(e) => { - error!("Task {} failed: {}", task.id, e); - - task.error = Some(e.to_string()); - task.status = TaskStatus::Failed; - task.completed_at = Some(chrono::Utc::now()); - - #[cfg(feature = "nats")] - state - .publish_task_status(&task.id, "failed", None, Some(&e.to_string())) - .await; - - // Record metrics - metrics_collector.record_task_failure(); - - // Publish monitoring event - let event = MonitoringEvent { - event_type: MonitoringEventType::TaskStatusChanged, - timestamp: chrono::Utc::now(), - data: serde_json::to_value(&task).unwrap_or_default(), - metadata: { - let mut meta = std::collections::HashMap::new(); - meta.insert("task_id".to_string(), task.id.clone()); - meta.insert("status".to_string(), "failed".to_string()); - meta.insert("error".to_string(), e.to_string()); - meta - }, - }; - - if let Err(e) = state.monitoring_system.publish_event(event).await { - error!("Failed to publish monitoring event: {}", e); - } - - if let Err(e) = state.task_storage.update_task(task.clone()).await { - error!("Failed to update task: {}", e); - metrics_collector.record_storage_operation(false); - } else { - metrics_collector.record_storage_operation(true); - } - - // Try to requeue for retry - if let Err(e) = state.task_storage.requeue_failed_task(&task.id).await { - error!("Failed to requeue task: {}", e); - metrics_collector.record_storage_operation(false); - } - } - } - } - Ok(None) => { - // No tasks in queue, sleep - tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; - } - Err(e) => { - error!("Error dequeuing task: {}", e); - metrics_collector.record_storage_operation(false); - tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; - } - } - } -} - -/// Solo mode helpers: spawn nats-server child process and wait for readiness -#[cfg(feature = "nats")] -mod solo_nats { - use anyhow::{Context, Result}; - use tokio::net::TcpStream; - use tokio::process::Command; - use tokio::time::{timeout, Duration, Instant}; - use tracing::info; - - /// Spawn `nats-server` as a child process with JetStream enabled. - /// - /// The returned `Child` holds the process alive; drop it to kill the - /// server. Uses `kill_on_drop(true)` so the process is cleaned up when - /// `Child` is dropped. - pub async fn spawn_nats_server(data_dir: &str) -> Result { - let nats_store_dir = format!("{}/nats", data_dir); - std::fs::create_dir_all(&nats_store_dir) - .context("Failed to create NATS storage directory")?; - - let child = Command::new("nats-server") - .args(["-js", "-sd", &nats_store_dir, "-p", "4222"]) - .kill_on_drop(true) - .spawn() - .context("Failed to spawn nats-server — ensure nats-server is in PATH")?; - - wait_for_nats(4222).await?; - info!("✓ NATS server (solo mode) ready on port 4222"); - Ok(child) - } - - /// Attempt TCP connect to 127.0.0.1:{port} in a loop until ready or - /// timeout. - async fn wait_for_nats(port: u16) -> Result<()> { - let addr = format!("127.0.0.1:{}", port); - let deadline = Instant::now() + Duration::from_secs(10); - loop { - if Instant::now() > deadline { - return Err(anyhow::anyhow!( - "NATS server did not become ready within 10 seconds on port {}", - port - )); - } - if timeout(Duration::from_millis(200), TcpStream::connect(&addr)) - .await - .is_ok() - { - return Ok(()); - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - } -} +use provisioning_orchestrator::{config::OrchestratorConfig, AppState, Args}; +use tracing::{info, warn}; + +mod api_catalog; +mod handlers; +mod router; +mod solo_nats; +mod task_executor; #[tokio::main] async fn main() -> Result<()> { // Parse CLI arguments FIRST (so --help works before any other processing) let args = Args::parse(); + if args.dump_api_catalog { + println!("{}", ontoref_ontology::api::dump_catalog_json()); + return Ok(()); + } + // Initialize centralized observability (logging, metrics, health checks) let _guard = observability::init_from_env("orchestrator", env!("CARGO_PKG_VERSION")) .context("Failed to initialize observability")?; @@ -1149,12 +48,10 @@ async fn main() -> Result<()> { .setup_git_repos() .context("Failed to setup Git repositories")?; - // Load orchestrator configuration from Nickel - let config = OrchestratorConfig::load().context("Failed to load orchestrator configuration")?; - - // Apply CLI overrides if provided - let mut config = config; - config.apply_cli_overrides(&args); + // Load orchestrator configuration from Nickel with env + CLI overrides + // All overrides are validated by Nickel contracts before deserialization + let config = OrchestratorConfig::load_with_cli_args(&args) + .context("Failed to load orchestrator configuration")?; let port = config.orchestrator.server.port; @@ -1168,19 +65,13 @@ async fn main() -> Result<()> { #[cfg(feature = "nats")] let _solo_nats_child = if args.mode.as_deref() == Some("solo") { info!("Solo mode: starting embedded NATS server"); - Some(solo_nats::spawn_nats_server(&args.data_dir).await?) + Some(solo_nats::inner::spawn_nats_server(&args.data_dir).await?) } else { None }; let state = Arc::new(AppState::new(args).await?); - // Build webhook state with empty registry (workspaces registered via API or - // config) - let webhook_state = Arc::new(WebhookState { - registry: Arc::new(parking_lot::RwLock::new(WorkspaceRegistry::new())), - }); - // Start audit collector (NATS → SurrealDB) #[cfg(feature = "nats")] { @@ -1193,85 +84,31 @@ async fn main() -> Result<()> { info!("✓ NATS audit collector started"); } + // Start InfraState service (formula events + dag_run creation + reconcile) + #[cfg(feature = "nats")] + { + use provisioning_orchestrator::infra_state::InfraStateService; + let nats = Arc::clone(&state.nats); + let pool = (*state.db).clone(); + let token = std::env::var("HETZNER_TOKEN") + .or_else(|_| std::env::var("HCLOUD_TOKEN")) + .unwrap_or_default(); + let svc = Arc::new(InfraStateService::new(pool, nats, token)); + tokio::spawn(async move { + if let Err(e) = svc.serve().await { + error!("InfraStateService exited: {e}"); + } + }); + info!("✓ InfraState service started"); + } + // Start task processor let processor_state = state.clone(); tokio::spawn(async move { - process_tasks(processor_state).await; + task_executor::process_tasks(processor_state).await; }); - let app = Router::new() - .route("/health", get(health_check)) - .route("/api/v1/health", get(health_check)) - .route("/tasks", get(list_tasks)) - .route("/tasks/{id}", get(get_task_status)) - .route("/workflows/servers/create", post(create_server_workflow)) - .route("/workflows/taskserv/create", post(create_taskserv_workflow)) - .route("/workflows/cluster/create", post(create_cluster_workflow)) - // Batch operation routes - .route("/batch/execute", post(execute_batch_operation)) - .route("/batch/operations", get(list_batch_operations)) - .route("/batch/operations/{id}", get(get_batch_operation_status)) - .route( - "/batch/operations/{id}/cancel", - post(cancel_batch_operation), - ) - // State management routes - .route("/state/workflows/{id}/progress", get(get_workflow_progress)) - .route( - "/state/workflows/{id}/snapshots", - get(get_workflow_snapshots), - ) - .route("/state/system/metrics", get(get_system_metrics)) - .route("/state/system/health", get(get_system_health)) - .route("/state/statistics", get(get_state_statistics)) - // Rollback and recovery routes - .route("/rollback/checkpoints", post(create_checkpoint)) - .route("/rollback/checkpoints", get(list_checkpoints)) - .route("/rollback/checkpoints/{id}", get(get_checkpoint)) - .route("/rollback/execute", post(execute_rollback)) - .route("/rollback/restore/{id}", post(restore_from_checkpoint)) - .route("/rollback/statistics", get(get_rollback_statistics)) - // Test environment routes - .route("/test/environments/create", post(create_test_environment)) - .route("/test/environments", get(list_test_environments)) - .route("/test/environments/{id}", get(get_test_environment)) - .route("/test/environments/{id}/run", post(run_environment_tests)) - .route( - "/test/environments/{id}", - axum::routing::delete(cleanup_test_environment), - ) - .route("/test/environments/{id}/logs", get(get_environment_logs)) - // DNS integration routes - .route("/api/v1/dns/records", get(list_dns_records)) - // Extension loading routes - .route("/api/v1/extensions/loaded", get(list_loaded_extensions)) - .route("/api/v1/extensions/reload", post(reload_extension)) - // OCI registry routes - .route("/api/v1/oci/artifacts", post(list_oci_artifacts)) - // Service orchestration routes - .route("/api/v1/services/list", get(list_services)) - .route("/api/v1/services/status", get(get_services_status)) - // Audit logging routes - .route("/api/v1/audit/query", post(query_audit_logs)) - .route("/api/v1/audit/export", post(export_audit_logs)) - .route("/api/v1/audit/stats", get(get_audit_stats)) - .route("/api/v1/audit/storage-stats", get(get_audit_storage_stats)) - .route("/api/v1/audit/apply-retention", post(apply_audit_retention)) - .route("/api/v1/audit/search", post(search_audit_logs)) - // Compliance routes - .nest( - "/api/v1/compliance", - compliance_routes(state.compliance_service.clone()), - ) - // Merge monitoring routes (includes /metrics, /ws, /events) - .merge(state.monitoring_system.create_routes()) - // Webhook handler (separate state — workspace registry) - .route( - "/api/v1/webhooks/:workspace_id", - post(handle_webhook).with_state(webhook_state), - ) - .layer(CorsLayer::permissive()) - .with_state(state); + let app = router::build_router(state); let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)) .await diff --git a/crates/orchestrator/src/oci/client.rs b/crates/orchestrator/src/oci/client.rs deleted file mode 100644 index 2291bc2..0000000 --- a/crates/orchestrator/src/oci/client.rs +++ /dev/null @@ -1,305 +0,0 @@ -//! OCI Registry Client Implementation -//! -//! Client for OCI Distribution Spec v2 compliant registries. - -use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; -use tracing::{debug, error}; - -/// OCI artifact metadata -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OciArtifact { - pub name: String, - pub version: String, - pub digest: String, - pub size: u64, - pub media_type: String, - pub created_at: chrono::DateTime, -} - -/// OCI manifest -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OciManifest { - pub schema_version: u32, - pub media_type: String, - pub config: OciDescriptor, - pub layers: Vec, - pub annotations: Option>, -} - -/// OCI descriptor -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OciDescriptor { - pub media_type: String, - pub digest: String, - pub size: u64, - pub annotations: Option>, -} - -/// Downloaded artifact with content -#[derive(Debug, Clone)] -pub struct DownloadedArtifact { - pub metadata: OciArtifact, - pub content: Vec, -} - -/// OCI registry client -pub struct OciClient { - registry_url: String, - namespace: String, - client: reqwest::Client, -} - -impl OciClient { - /// Create new OCI client - pub fn new(registry_url: String, namespace: String) -> Self { - Self { - registry_url, - namespace, - client: reqwest::Client::new(), - } - } - - /// Pull artifact from registry - pub async fn pull_artifact(&self, name: &str, version: &str) -> Result { - debug!("Pulling artifact: {}:{}", name, version); - - // Get manifest first - let manifest = self.get_manifest(name, version).await?; - - // Download layers - let mut content = Vec::new(); - - for layer in &manifest.layers { - let layer_content = self.download_blob(name, &layer.digest).await?; - content.extend_from_slice(&layer_content); - } - - let metadata = OciArtifact { - name: name.to_string(), - version: version.to_string(), - digest: manifest.config.digest.clone(), - size: content.len() as u64, - media_type: manifest.media_type.clone(), - created_at: chrono::Utc::now(), - }; - - debug!("Successfully pulled artifact: {}:{}", name, version); - - Ok(DownloadedArtifact { metadata, content }) - } - - /// Get manifest from registry - pub async fn get_manifest(&self, name: &str, version: &str) -> Result { - debug!("Getting manifest for {}:{}", name, version); - - let url = format!( - "{}/v2/{}/{}/manifests/{}", - self.registry_url, self.namespace, name, version - ); - - let response = self - .client - .get(&url) - .header("Accept", "application/vnd.oci.image.manifest.v1+json") - .send() - .await - .context("Failed to send manifest request")?; - - if response.status().is_success() { - let manifest: OciManifest = - response.json().await.context("Failed to parse manifest")?; - - debug!("Retrieved manifest for {}:{}", name, version); - Ok(manifest) - } else { - let status = response.status(); - let error_text = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - - error!( - "Failed to get manifest for {}:{}: {} - {}", - name, version, status, error_text - ); - - anyhow::bail!("Manifest retrieval failed: {} - {}", status, error_text) - } - } - - /// Download blob from registry - async fn download_blob(&self, name: &str, digest: &str) -> Result> { - debug!("Downloading blob: {} ({})", name, digest); - - let url = format!( - "{}/v2/{}/{}/blobs/{}", - self.registry_url, self.namespace, name, digest - ); - - let response = self - .client - .get(&url) - .send() - .await - .context("Failed to send blob request")?; - - if response.status().is_success() { - let bytes = response.bytes().await.context("Failed to download blob")?; - - debug!("Downloaded {} bytes for blob {}", bytes.len(), digest); - Ok(bytes.to_vec()) - } else { - let status = response.status(); - let error_text = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - - error!( - "Failed to download blob {}: {} - {}", - digest, status, error_text - ); - - anyhow::bail!("Blob download failed: {} - {}", status, error_text) - } - } - - /// List artifacts in repository - pub async fn list_artifacts(&self, repository: &str) -> Result> { - debug!("Listing artifacts in repository: {}", repository); - - let url = format!( - "{}/v2/{}/{}/tags/list", - self.registry_url, self.namespace, repository - ); - - let response = self - .client - .get(&url) - .send() - .await - .context("Failed to send tags list request")?; - - if response.status().is_success() { - #[derive(Deserialize)] - struct TagsList { - tags: Vec, - } - - let tags_list: TagsList = response.json().await.context("Failed to parse tags list")?; - - // Get metadata for each tag - let mut artifacts = Vec::new(); - - for tag in tags_list.tags { - if let Ok(manifest) = self.get_manifest(repository, &tag).await { - let artifact = OciArtifact { - name: repository.to_string(), - version: tag, - digest: manifest.config.digest, - size: manifest.config.size, - media_type: manifest.media_type, - created_at: chrono::Utc::now(), - }; - - artifacts.push(artifact); - } - } - - debug!("Found {} artifacts in {}", artifacts.len(), repository); - Ok(artifacts) - } else { - let status = response.status(); - let error_text = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - - error!( - "Failed to list artifacts in {}: {} - {}", - repository, status, error_text - ); - - anyhow::bail!("Artifact listing failed: {} - {}", status, error_text) - } - } - - /// Check if artifact exists - pub async fn artifact_exists(&self, name: &str, version: &str) -> Result { - debug!("Checking if artifact exists: {}:{}", name, version); - - let url = format!( - "{}/v2/{}/{}/manifests/{}", - self.registry_url, self.namespace, name, version - ); - - match self - .client - .head(&url) - .header("Accept", "application/vnd.oci.image.manifest.v1+json") - .send() - .await - { - Ok(response) => { - let exists = response.status().is_success(); - debug!("Artifact {}:{} exists: {}", name, version, exists); - Ok(exists) - } - Err(e) => { - error!("Failed to check artifact existence: {}", e); - Ok(false) - } - } - } - - /// Registry health check - pub async fn health_check(&self) -> Result { - debug!("Checking OCI registry health"); - - let url = format!("{}/v2/", self.registry_url); - - match self.client.get(&url).send().await { - Ok(response) => { - let is_healthy = response.status().is_success(); - debug!("OCI registry health check: {}", is_healthy); - Ok(is_healthy) - } - Err(e) => { - error!("OCI registry health check failed: {}", e); - Ok(false) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_oci_client_creation() { - let client = OciClient::new( - "http://localhost:5000".to_string(), - "provisioning-extensions".to_string(), - ); - - assert_eq!(client.registry_url, "http://localhost:5000"); - assert_eq!(client.namespace, "provisioning-extensions"); - } - - #[test] - fn test_oci_artifact_creation() { - let artifact = OciArtifact { - name: "test-package".to_string(), - version: "1.0.0".to_string(), - digest: "sha256:abcdef".to_string(), - size: 1024, - media_type: "application/vnd.oci.image.manifest.v1+json".to_string(), - created_at: chrono::Utc::now(), - }; - - assert_eq!(artifact.name, "test-package"); - assert_eq!(artifact.size, 1024); - } -} diff --git a/crates/orchestrator/src/oci/mod.rs b/crates/orchestrator/src/oci/mod.rs deleted file mode 100644 index 7f7b220..0000000 --- a/crates/orchestrator/src/oci/mod.rs +++ /dev/null @@ -1,175 +0,0 @@ -//! OCI Integration Module -//! -//! Provides OCI registry integration for pulling KCL packages and extension -//! artifacts. - -pub mod client; - -use std::num::NonZeroUsize; -use std::path::PathBuf; -use std::sync::Arc; - -use anyhow::Result; -pub use client::{OciArtifact, OciClient, OciManifest}; -use lru::LruCache; -use tokio::sync::Mutex; -use tracing::{debug, info}; - -/// OCI Manager for registry operations -pub struct OciManager { - client: OciClient, - manifest_cache: Arc>>, - cache_dir: PathBuf, -} - -impl OciManager { - /// Create new OCI manager - pub fn new(registry_url: String, namespace: String, cache_dir: PathBuf) -> Self { - let cache_size = NonZeroUsize::new(100).unwrap(); - - Self { - client: OciClient::new(registry_url, namespace), - manifest_cache: Arc::new(Mutex::new(LruCache::new(cache_size))), - cache_dir, - } - } - - /// Pull KCL package from OCI registry - pub async fn pull_kcl_package(&self, name: &str, version: &str) -> Result { - info!("Pulling KCL package: {}:{}", name, version); - - let artifact = self - .client - .pull_artifact(&format!("kcl/{}", name), version) - .await?; - - // Save to cache directory - let package_dir = self.cache_dir.join("kcl").join(name).join(version); - tokio::fs::create_dir_all(&package_dir).await?; - - let package_path = package_dir.join("package.tar.gz"); - tokio::fs::write(&package_path, &artifact.content).await?; - - info!( - "Successfully pulled KCL package to {}", - package_path.display() - ); - - Ok(package_path) - } - - /// Pull extension artifact from OCI registry - pub async fn pull_extension_artifact( - &self, - extension_type: &str, - name: &str, - version: &str, - ) -> Result { - info!( - "Pulling extension artifact: {} ({}):{}", - name, extension_type, version - ); - - let artifact = self - .client - .pull_artifact(&format!("extensions/{}/{}", extension_type, name), version) - .await?; - - // Save to cache directory - let artifact_dir = self - .cache_dir - .join("extensions") - .join(extension_type) - .join(name) - .join(version); - tokio::fs::create_dir_all(&artifact_dir).await?; - - let artifact_path = artifact_dir.join("artifact.tar.gz"); - tokio::fs::write(&artifact_path, &artifact.content).await?; - - info!( - "Successfully pulled extension artifact to {}", - artifact_path.display() - ); - - Ok(artifact_path) - } - - /// List OCI artifacts in namespace - pub async fn list_oci_artifacts(&self, namespace: &str) -> Result> { - debug!("Listing OCI artifacts in namespace: {}", namespace); - - let artifacts = self.client.list_artifacts(namespace).await?; - - info!("Found {} artifacts in {}", artifacts.len(), namespace); - - Ok(artifacts) - } - - /// Get manifest (with caching) - pub async fn get_manifest(&self, name: &str, version: &str) -> Result { - let cache_key = format!("{}:{}", name, version); - - // Check cache first - { - let mut cache = self.manifest_cache.lock().await; - if let Some(manifest) = cache.get(&cache_key) { - debug!("Manifest found in cache: {}", cache_key); - return Ok(manifest.clone()); - } - } - - // Fetch from registry - let manifest = self.client.get_manifest(name, version).await?; - - // Cache manifest - { - let mut cache = self.manifest_cache.lock().await; - cache.put(cache_key.clone(), manifest.clone()); - } - - debug!("Cached manifest: {}", cache_key); - - Ok(manifest) - } - - /// Clear manifest cache - pub async fn clear_cache(&self) { - let mut cache = self.manifest_cache.lock().await; - cache.clear(); - info!("Cleared OCI manifest cache"); - } - - /// Check if artifact exists - pub async fn artifact_exists(&self, name: &str, version: &str) -> Result { - self.client.artifact_exists(name, version).await - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_oci_manager_creation() { - let manager = OciManager::new( - "http://localhost:5000".to_string(), - "provisioning-extensions".to_string(), - PathBuf::from("/tmp/oci-cache"), - ); - - assert_eq!(manager.cache_dir, PathBuf::from("/tmp/oci-cache")); - } - - #[tokio::test] - async fn test_cache_operations() { - let manager = OciManager::new( - "http://localhost:5000".to_string(), - "provisioning-extensions".to_string(), - PathBuf::from("/tmp/oci-cache"), - ); - - // Clear empty cache - manager.clear_cache().await; - } -} diff --git a/crates/orchestrator/src/orchestrator_state.rs b/crates/orchestrator/src/orchestrator_state.rs index d25d5a5..1ff706e 100644 --- a/crates/orchestrator/src/orchestrator_state.rs +++ b/crates/orchestrator/src/orchestrator_state.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use anyhow::{Context, Result}; +use dashmap::DashMap; #[cfg(feature = "nats")] use platform_db::SurrealPool; #[cfg(feature = "nats")] @@ -57,6 +58,10 @@ pub struct AppState { /// feature. #[cfg(feature = "nats")] pub db: Arc, + /// Ephemeral VM pool for buildkit runners (ADR-039). `None` when RUNNER_SCRIPT is absent. + pub vm_pool: Option>, + /// Build metrics accumulator — always present, independent of vm_pool availability. + pub build_metrics: crate::vm_pool::BuildMetrics, pub args: Args, } @@ -317,7 +322,7 @@ impl AppState { .context("Failed to connect to NATS")?; let bridge = Arc::new(bridge); - let db_config = DbConfig::Embedded { + let db_config = DbConfig::EmbeddedSurrealKV { path: format!("{}/surrealdb", args.data_dir), }; let pool = SurrealPool::connect(&db_config) @@ -327,6 +332,14 @@ impl AppState { (bridge, Arc::new(pool)) }; + let build_metrics = Arc::new(DashMap::new()); + let vm_pool = crate::vm_pool::VmPool::try_new(build_metrics.clone()).map(Arc::new); + if vm_pool.is_some() { + info!("vm_pool initialized — ephemeral buildkit runner provisioning available"); + } else { + info!("vm_pool disabled — RUNNER_SCRIPT not set; set it to enable ephemeral build runners"); + } + Ok(Self { task_storage, batch_coordinator, @@ -346,10 +359,36 @@ impl AppState { nats, #[cfg(feature = "nats")] db, + vm_pool, + build_metrics, args, }) } + /// Publish a component lifecycle event to NATS JetStream. + /// + /// Subject: `provisioning.component.lifecycle` + /// Consumed by the nu_daemon subscriber to update `.provisioning-state.ncl`. + #[cfg(feature = "nats")] + pub async fn publish_component_lifecycle( + &self, + event: &crate::ComponentLifecycleEvent, + ) { + use tracing::warn; + if let Err(e) = self + .nats + .publish_json("provisioning.component.lifecycle", event) + .await + { + warn!( + task_id = %event.task_id, + component = %event.component, + "Failed to publish component lifecycle event to NATS: {}", + e + ); + } + } + /// Publish a task status update to NATS JetStream. /// /// Subject: `provisioning.tasks.{task_id}.status` @@ -409,13 +448,12 @@ impl AppState { let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); - // Include both stdout and stderr for better error diagnostics - let raw_output = if !stderr.is_empty() { - stderr.to_string() - } else if !stdout.is_empty() { - stdout.to_string() - } else { - format!("Command failed with exit code: {:?}", output.status.code()) + // Include both stdout and stderr — ssh warnings land in stderr and mask real errors + let raw_output = match (stderr.is_empty(), stdout.is_empty()) { + (false, false) => format!("{}\n{}", stderr, stdout), + (false, true) => stderr.to_string(), + (true, false) => stdout.to_string(), + (true, true) => format!("Command failed with exit code: {:?}", output.status.code()), }; // Sanitize sensitive data from logs (tokens, keys, passwords) diff --git a/crates/orchestrator/src/preconditions.rs b/crates/orchestrator/src/preconditions.rs new file mode 100644 index 0000000..73b27c9 --- /dev/null +++ b/crates/orchestrator/src/preconditions.rs @@ -0,0 +1,474 @@ +//! Precondition gate for the unified component lifecycle endpoint. +//! +//! Validates that all capability providers required by a component are operationally +//! healthy before a write operation is enqueued. Called by every handler under +//! `/api/v1/workflows/component/{op}`. +//! +//! Fast-fail path: reads `.provisioning-state.ncl` via nickel export before issuing +//! live SSH probes. A 15-second global timeout covers the entire chain. + +use std::backtrace::Backtrace; +use std::collections::HashMap; +use std::error::Error; +use std::fmt; +use std::path::Path; +use std::time::Duration; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use tokio::time::timeout; +use tracing::{debug, info, warn}; + +use crate::probes::{check_component, LiveStatus, ProbeConfig, ProbeReport}; + +const GATE_TIMEOUT_SECS: u64 = 15; + +/// Describes a precondition failure with enough detail to render the plan-P3 error format. +#[derive(Debug)] +pub struct PreconditionError { + pub kind: PreconditionErrorKind, + pub context: String, + source: Option>, + backtrace: Backtrace, +} + +#[derive(Debug)] +pub enum PreconditionErrorKind { + /// No workspace component provides the required capability. + ProviderNotFound { capability: String }, + /// State file reports the provider is in a terminal-bad state. + StateFailFast { component: String, state: String }, + /// Live probe returned Failed. + LiveProbeFailed(ProbeReport), + /// Capability resolution detected a cycle. + CircularDependency { chain: Vec }, + /// Gate timed out before all probes completed. + Timeout { after_secs: u64 }, +} + +impl PreconditionError { + fn new(kind: PreconditionErrorKind, context: impl Into) -> Self { + Self { + kind, + context: context.into(), + source: None, + backtrace: Backtrace::capture(), + } + } + + fn with_source( + mut self, + src: impl Error + Send + Sync + 'static, + ) -> Self { + self.source = Some(Box::new(src)); + self + } +} + +impl fmt::Display for PreconditionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.kind { + PreconditionErrorKind::ProviderNotFound { capability } => { + write!(f, "no provider found for capability '{capability}'") + } + PreconditionErrorKind::StateFailFast { component, state } => { + write!(f, "provider '{component}' is in non-recoverable state '{state}' — fix or reinstall before retrying") + } + PreconditionErrorKind::LiveProbeFailed(report) => { + write!( + f, + "provider '{}' (mode={}, server={}) live=FAILED — probe: {}", + report.component, report.mode, report.target, report.detail + ) + } + PreconditionErrorKind::CircularDependency { chain } => { + write!(f, "circular capability dependency: {}", chain.join(" → ")) + } + PreconditionErrorKind::Timeout { after_secs } => { + write!( + f, + "precondition gate timed out after {after_secs}s — retry or run `prvng component health `" + ) + } + } + } +} + +impl Error for PreconditionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + self.source.as_ref().map(|e| e.as_ref() as &dyn Error) + } +} + +// ── Workspace types loaded via nickel export ────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct ExtensionMetadata { + #[serde(default)] + provides: Vec, + #[serde(default)] + requires: Vec, + #[serde(default)] + modes: Vec, +} + +#[derive(Debug, Deserialize)] +struct CapabilityEntry { + id: String, +} + +#[derive(Debug, Deserialize)] +struct RequiresEntry { + capability: String, + #[serde(default)] + kind: String, +} + +#[derive(Debug, Deserialize)] +struct ComponentState { + state: Option, +} + +#[derive(Debug, Deserialize)] +struct StateFile { + #[serde(flatten)] + components: HashMap, +} + +// ── Public interface ────────────────────────────────────────────────────────── + +/// Reference to a workspace component. +pub struct ComponentRef<'a> { + pub name: &'a str, + pub mode: &'a str, + /// SSH target: server hostname (taskserv) or cluster controller (cluster/container). + pub target: &'a str, + /// Kubernetes namespace, if mode=cluster. + pub namespace: Option<&'a str>, + /// SSH user for probing. + pub ssh_user: &'a str, + /// SSH key path for probing. + pub ssh_key_path: Option<&'a str>, +} + +/// Path to a workspace. +pub struct WorkspacePath<'a> { + /// Absolute path to workspace root (e.g. `~/project/workspaces/libre-daoshi`). + pub root: &'a str, + /// Infra subdirectory name under `infra/`. + pub infra: &'a str, + /// Absolute path to the provisioning repo root (value of `$PROVISIONING`). + pub provisioning: &'a str, +} + +/// Verify all capability preconditions for a component operation. +/// +/// Returns `Ok(())` if all providers are healthy, or `Err(PreconditionError)` with +/// a structured description of the first failure found. Enforces a 15-second overall +/// timeout across all probes. +/// +/// Skips gate for read-only ops (`status`, `list`, `show`, `health`) and for taskserv +/// mode (it is the root provider — no dependencies to check). +pub async fn verify_component_preconditions( + workspace: &WorkspacePath<'_>, + component: &ComponentRef<'_>, + op: &str, +) -> Result<(), PreconditionError> { + // Read-only ops and taskserv providers skip the gate. + if matches!(op, "status" | "list" | "show" | "health") { + return Ok(()); + } + if component.mode == "taskserv" { + return Ok(()); + } + + let future = run_precondition_checks(workspace, component); + match timeout(Duration::from_secs(GATE_TIMEOUT_SECS), future).await { + Ok(result) => result, + Err(_) => Err(PreconditionError::new( + PreconditionErrorKind::Timeout { + after_secs: GATE_TIMEOUT_SECS, + }, + format!( + "gate timed out checking preconditions for '{}' op='{op}'", + component.name + ), + )), + } +} + +async fn run_precondition_checks( + workspace: &WorkspacePath<'_>, + component: &ComponentRef<'_>, +) -> Result<(), PreconditionError> { + let meta = load_extension_metadata(workspace.provisioning, component.name)?; + + // Components with no capability requirements always pass. + if meta.requires.is_empty() { + return Ok(()); + } + + for req in &meta.requires { + if req.kind.to_lowercase() == "optional" { + continue; + } + + let provider_name = + find_capability_provider(workspace, &req.capability, component.name)?; + + debug!( + "precondition: '{}' requires '{}' → provider '{}'", + component.name, req.capability, provider_name + ); + + // Fast-fail: check state file before doing SSH. + let provider_state = load_provider_state(workspace, &provider_name); + match provider_state.as_deref() { + Some(s @ "failed") | Some(s @ "error") => { + let s = s.to_string(); + return Err(PreconditionError::new( + PreconditionErrorKind::StateFailFast { + component: provider_name.clone(), + state: s.clone(), + }, + format!( + "provider '{}' (for capability '{}') is in state '{s}'", + provider_name, req.capability, + ), + )); + } + _ => {} + } + + // Live probe — skipped for taskserv providers. + // + // Taskserv components install K8s resources or OS-level config and then complete. + // They leave no persistent daemon to probe via SSH; the state file `running` entry + // is the authoritative health signal. Probing via systemd would always return + // `inactive` (exit=3) for K8s addons like CSI drivers, CNI plugins, etc. + let provider_meta = load_extension_metadata(workspace.provisioning, &provider_name) + .unwrap_or_else(|_| ExtensionMetadata::default()); + let provider_mode = resolve_provider_mode(workspace, &provider_name, &provider_meta); + + if provider_mode == "taskserv" { + info!( + "precondition OK (state-file): '{}' → '{}' mode=taskserv state={:?}", + req.capability, provider_name, provider_state + ); + continue; + } + + let probe_cfg = ProbeConfig { + ssh_user: component.ssh_user.to_string(), + ssh_key_path: component.ssh_key_path.map(str::to_string), + timeout: Duration::from_secs(8), + namespace: component.namespace.map(str::to_string), + }; + + let report = + check_component(&provider_name, &provider_mode, component.target, &probe_cfg).await; + + if report.live == LiveStatus::Failed { + return Err(PreconditionError::new( + PreconditionErrorKind::LiveProbeFailed(report), + format!( + "provider '{}' for capability '{}' failed live probe", + provider_name, req.capability + ), + )); + } + + info!( + "precondition OK: '{}' → '{}' live={}", + req.capability, provider_name, report.live + ); + } + + Ok(()) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn load_extension_metadata( + provisioning: &str, + name: &str, +) -> Result { + let meta_path = Path::new(provisioning) + .join("extensions") + .join("components") + .join(name) + .join("metadata.ncl"); + + if !meta_path.exists() { + return Ok(ExtensionMetadata::default()); + } + + let output = std::process::Command::new("nickel") + .arg("export") + .arg("--import-path") + .arg(provisioning) + .arg(meta_path.to_str().unwrap_or_default()) + .output() + .map_err(|e| { + PreconditionError::new( + PreconditionErrorKind::ProviderNotFound { + capability: name.to_string(), + }, + format!("failed to run nickel export for metadata of '{name}': {e}"), + ) + })?; + + if !output.status.success() { + return Ok(ExtensionMetadata::default()); + } + + Ok(serde_json::from_slice(&output.stdout).unwrap_or_else(|_| ExtensionMetadata::default())) +} + +fn find_capability_provider( + workspace: &WorkspacePath<'_>, + capability: &str, + requesting_component: &str, +) -> Result { + let comp_dir = Path::new(workspace.root) + .join("infra") + .join(workspace.infra) + .join("components"); + + let entries = std::fs::read_dir(&comp_dir).map_err(|e| { + PreconditionError::new( + PreconditionErrorKind::ProviderNotFound { + capability: capability.to_string(), + }, + format!("cannot read components dir {}: {e}", comp_dir.display()), + ) + })?; + + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("ncl") { + continue; + } + let cname = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default() + .to_string(); + + // Avoid self-reference. + if cname == requesting_component { + continue; + } + + let meta_path = Path::new(workspace.provisioning) + .join("extensions") + .join("components") + .join(&cname) + .join("metadata.ncl"); + + if !meta_path.exists() { + continue; + } + + let output = std::process::Command::new("nickel") + .arg("export") + .arg("--import-path") + .arg(workspace.provisioning) + .arg(meta_path.to_str().unwrap_or_default()) + .output(); + + if let Ok(out) = output { + if out.status.success() { + let meta: ExtensionMetadata = + serde_json::from_slice(&out.stdout).unwrap_or_default(); + if meta.provides.iter().any(|p| p.id == capability) { + return Ok(cname); + } + } + } + } + + Err(PreconditionError::new( + PreconditionErrorKind::ProviderNotFound { + capability: capability.to_string(), + }, + format!( + "no workspace component provides capability '{capability}' — required by '{requesting_component}'" + ), + )) +} + +fn load_provider_state(workspace: &WorkspacePath<'_>, provider: &str) -> Option { + // State file lives at workspace root, not inside infra/. See domain/workspace/state.nu. + let state_path = Path::new(workspace.root).join(".provisioning-state.ncl"); + + if !state_path.exists() { + return None; + } + + let output = std::process::Command::new("nickel") + .arg("export") + .arg("--import-path") + .arg(workspace.provisioning) + .arg(state_path.to_str().unwrap_or_default()) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?; + json.get(provider) + .and_then(|v| v.get("state")) + .and_then(|s| s.as_str()) + .map(str::to_string) +} + +fn resolve_provider_mode( + workspace: &WorkspacePath<'_>, + provider: &str, + meta: &ExtensionMetadata, +) -> String { + // Try workspace declaration first; fall back to first catalog mode. + let comp_ncl = Path::new(workspace.root) + .join("infra") + .join(workspace.infra) + .join("components") + .join(format!("{provider}.ncl")); + + if comp_ncl.exists() { + let output = std::process::Command::new("nickel") + .arg("export") + .arg("--import-path") + .arg(workspace.provisioning) + .arg(comp_ncl.to_str().unwrap_or_default()) + .output(); + + if let Ok(out) = output { + if out.status.success() { + if let Ok(json) = serde_json::from_slice::(&out.stdout) { + if let Some(mode) = json + .get(provider) + .and_then(|v| v.get("mode")) + .and_then(|m| m.as_str()) + { + return mode.to_string(); + } + } + } + } + } + + meta.modes.first().cloned().unwrap_or_else(|| "taskserv".to_string()) +} + +impl Default for ExtensionMetadata { + fn default() -> Self { + Self { + provides: vec![], + requires: vec![], + modes: vec!["taskserv".to_string()], + } + } +} diff --git a/crates/orchestrator/src/probes/mod.rs b/crates/orchestrator/src/probes/mod.rs new file mode 100644 index 0000000..ce5cdfc --- /dev/null +++ b/crates/orchestrator/src/probes/mod.rs @@ -0,0 +1,155 @@ +//! Live probes for workspace component health. +//! +//! Shared by the preconditions gate (abortive) and the `health` CLI operation (read-only). +//! The gate calls `check_component` and aborts on `Failed` status. +//! The `health` operation calls `check_component` and displays the `ProbeReport`. + +pub mod registry; + +use std::time::{Duration, Instant}; + +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use tracing::debug; + +use crate::ssh_exec::{run_ssh_command, SshOutput}; + +/// Liveness status returned by a probe. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum LiveStatus { + Healthy, + Degraded, + Failed, +} + +impl std::fmt::Display for LiveStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LiveStatus::Healthy => write!(f, "healthy"), + LiveStatus::Degraded => write!(f, "degraded"), + LiveStatus::Failed => write!(f, "FAILED"), + } + } +} + +/// Full result of a live probe — read-only, suitable for display in `health` output. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProbeReport { + pub component: String, + pub mode: String, + pub target: String, + pub live: LiveStatus, + pub detail: String, + pub latency_ms: u64, + pub probed_at: chrono::DateTime, +} + +/// Configuration passed to a probe. +pub struct ProbeConfig { + /// SSH user for the target host. + pub ssh_user: String, + /// Path to the SSH identity file (optional). + pub ssh_key_path: Option, + /// Per-probe timeout. + pub timeout: Duration, + /// Kubernetes namespace (only used for cluster-mode probes). + pub namespace: Option, +} + +impl Default for ProbeConfig { + fn default() -> Self { + Self { + ssh_user: "root".to_string(), + ssh_key_path: None, + timeout: Duration::from_secs(10), + namespace: None, + } + } +} + +/// Run a live probe against a component and return a `ProbeReport`. +/// +/// The probe command depends on the component's mode: +/// - `taskserv`: `systemctl is-active ` (from `registry::taskserv_probe_command`) +/// - `cluster`: `k0s kubectl get deploy` (from `registry::cluster_probe_command`) +/// - `container`: `docker inspect` (from `registry::container_probe_command`) +pub async fn check_component( + component: &str, + mode: &str, + target: &str, + config: &ProbeConfig, +) -> ProbeReport { + let start = Instant::now(); + let key_ref = config.ssh_key_path.as_deref(); + + let probe_cmd = match mode { + "taskserv" => registry::taskserv_probe_command(component) + .unwrap_or_else(|| format!("systemctl is-active {}", component.replace('_', "-"))), + "cluster" => { + let ns = config.namespace.as_deref().unwrap_or("default"); + registry::cluster_probe_command(component, ns) + } + "container" => registry::container_probe_command(component), + other => { + return ProbeReport { + component: component.to_string(), + mode: other.to_string(), + target: target.to_string(), + live: LiveStatus::Degraded, + detail: format!("unknown mode '{other}' — cannot probe"), + latency_ms: 0, + probed_at: Utc::now(), + }; + } + }; + + debug!("probing {}@{} (mode={}): {}", component, target, mode, probe_cmd); + + let result: Result = run_ssh_command( + target, + &config.ssh_user, + key_ref, + &probe_cmd, + config.timeout, + ) + .await; + + let latency_ms = start.elapsed().as_millis() as u64; + + match result { + Err(e) => ProbeReport { + component: component.to_string(), + mode: mode.to_string(), + target: target.to_string(), + live: LiveStatus::Failed, + detail: format!("SSH error: {e}"), + latency_ms, + probed_at: Utc::now(), + }, + Ok(out) => { + let (live, detail) = if out.succeeded() { + (LiveStatus::Healthy, out.stdout.trim().to_string()) + } else { + ( + LiveStatus::Failed, + format!( + "exit={} stdout={} stderr={}", + out.exit_code, + out.stdout.trim(), + out.stderr.trim() + ), + ) + }; + ProbeReport { + component: component.to_string(), + mode: mode.to_string(), + target: target.to_string(), + live, + detail, + latency_ms, + probed_at: Utc::now(), + } + } + } +} diff --git a/crates/orchestrator/src/probes/registry.rs b/crates/orchestrator/src/probes/registry.rs new file mode 100644 index 0000000..08923f2 --- /dev/null +++ b/crates/orchestrator/src/probes/registry.rs @@ -0,0 +1,42 @@ +//! Per-component probe dispatch. +//! +//! Maps component names to the shell commands that verify liveness. +//! Tier: mode → component → probe command string. + +use std::collections::HashMap; + +/// Returns the SSH command that probes a taskserv-mode component for liveness. +pub fn taskserv_probe_command(component: &str) -> Option { + let table: HashMap<&str, &str> = [ + ("k0s", "systemctl is-active k0scontroller"), + ("k0s-worker", "systemctl is-active k0sworker"), + ("postgresql", "pg_isready -q"), + ("external_nfs", "showmount -e localhost"), + ("democratic_csi", "systemctl is-active democratic-csi"), + ("hetzner_csi", "systemctl is-active hetzner-csi"), + ("cilium", "systemctl is-active cilium"), + ("coredns", "systemctl is-active coredns"), + ("etcd", "systemctl is-active etcd"), + ("containerd", "systemctl is-active containerd"), + ("crio", "systemctl is-active crio"), + ] + .into_iter() + .collect(); + + table.get(component).map(|s| s.to_string()) +} + +/// Returns the SSH command (run on the cluster controller) that probes a cluster-mode component. +/// +/// Uses `k0s kubectl` since the cluster controller runs k0s. +pub fn cluster_probe_command(component: &str, namespace: &str) -> String { + // kubectl get deploy checks readyReplicas ≥ 1 + format!( + "k0s kubectl get deploy -n {namespace} {component} -o jsonpath='{{.status.readyReplicas}}' 2>/dev/null | grep -qE '^[1-9]'" + ) +} + +/// Returns the SSH command that probes a container-mode component for liveness. +pub fn container_probe_command(component: &str) -> String { + format!("docker inspect --format='{{{{.State.Running}}}}' {component} | grep -q true") +} diff --git a/crates/orchestrator/src/response.rs b/crates/orchestrator/src/response.rs new file mode 100644 index 0000000..4f30c51 --- /dev/null +++ b/crates/orchestrator/src/response.rs @@ -0,0 +1,26 @@ +use serde::Serialize; + +#[derive(Serialize)] +pub struct ApiResponse { + pub success: bool, + pub data: Option, + pub error: Option, +} + +impl ApiResponse { + pub fn success(data: T) -> Self { + Self { + success: true, + data: Some(data), + error: None, + } + } + + pub fn error(message: String) -> Self { + Self { + success: false, + data: None, + error: Some(message), + } + } +} diff --git a/crates/orchestrator/src/router.rs b/crates/orchestrator/src/router.rs new file mode 100644 index 0000000..af7a5ec --- /dev/null +++ b/crates/orchestrator/src/router.rs @@ -0,0 +1,147 @@ +use axum::{ + routing::{delete, get, post}, + Router, +}; +use provisioning_orchestrator::{compliance_routes, webhooks::{handle_webhook, WebhookState, WorkspaceRegistry}}; +use std::sync::Arc; +use tower_http::cors::CorsLayer; + +use crate::{ + api_catalog, + handlers::{ + audit::{ + apply_audit_retention, export_audit_logs, get_audit_stats, get_audit_storage_stats, + query_audit_logs, search_audit_logs, + }, + batch::{ + cancel_batch_operation, execute_batch_operation, get_batch_operation_status, + list_batch_operations, + }, + extensions::{ + get_services_status, list_dns_records, list_loaded_extensions, list_oci_artifacts, + list_services, reload_extension, + }, + rollback::{ + create_checkpoint, execute_rollback, get_checkpoint, get_rollback_statistics, + list_checkpoints, restore_from_checkpoint, + }, + state::{ + get_state_statistics, get_system_health, get_system_metrics, get_workflow_progress, + get_workflow_snapshots, + }, + tasks::{get_task_status, health_check, list_tasks}, + test_env::{ + cleanup_test_environment, create_test_environment, get_environment_logs, + get_test_environment, list_test_environments, run_environment_tests, + }, + vm_pool::{destroy_runner, get_p95, record_build_metrics, spawn_runner}, + workflows::{ + create_cluster_workflow, create_component_workflow, create_server_workflow, + create_taskserv_workflow, + }, + }, +}; + +pub fn build_router(state: Arc) -> Router { + let webhook_state = Arc::new(WebhookState { + registry: Arc::new(parking_lot::RwLock::new(WorkspaceRegistry::new())), + }); + + let app = Router::new() + .route("/api/v1/catalog", get(api_catalog::api_catalog)) + .route("/health", get(health_check)) + .route("/api/v1/health", get(health_check)) + .route("/tasks", get(list_tasks)) + .route("/tasks/{id}", get(get_task_status)) + .route("/workflows/servers/create", post(create_server_workflow)) + .route("/workflows/taskserv/create", post(create_taskserv_workflow)) + .route("/workflows/cluster/create", post(create_cluster_workflow)) + .route( + "/api/v1/workflows/component/{operation}", + post(create_component_workflow), + ) + // Batch operation routes + .route("/batch/execute", post(execute_batch_operation)) + .route("/batch/operations", get(list_batch_operations)) + .route("/batch/operations/{id}", get(get_batch_operation_status)) + .route( + "/batch/operations/{id}/cancel", + post(cancel_batch_operation), + ) + // State management routes + .route("/state/workflows/{id}/progress", get(get_workflow_progress)) + .route( + "/state/workflows/{id}/snapshots", + get(get_workflow_snapshots), + ) + .route("/state/system/metrics", get(get_system_metrics)) + .route("/state/system/health", get(get_system_health)) + .route("/state/statistics", get(get_state_statistics)) + // Rollback and recovery routes + .route("/rollback/checkpoints", post(create_checkpoint)) + .route("/rollback/checkpoints", get(list_checkpoints)) + .route("/rollback/checkpoints/{id}", get(get_checkpoint)) + .route("/rollback/execute", post(execute_rollback)) + .route("/rollback/restore/{id}", post(restore_from_checkpoint)) + .route("/rollback/statistics", get(get_rollback_statistics)) + // Test environment routes + .route("/test/environments/create", post(create_test_environment)) + .route("/test/environments", get(list_test_environments)) + .route("/test/environments/{id}", get(get_test_environment)) + .route("/test/environments/{id}/run", post(run_environment_tests)) + .route( + "/test/environments/{id}", + axum::routing::delete(cleanup_test_environment), + ) + .route("/test/environments/{id}/logs", get(get_environment_logs)) + // DNS integration routes + .route("/api/v1/dns/records", get(list_dns_records)) + // Extension loading routes + .route("/api/v1/extensions/loaded", get(list_loaded_extensions)) + .route("/api/v1/extensions/reload", post(reload_extension)) + // OCI registry routes + .route("/api/v1/oci/artifacts", post(list_oci_artifacts)) + // Service orchestration routes + .route("/api/v1/services/list", get(list_services)) + .route("/api/v1/services/status", get(get_services_status)) + // VM pool routes (ephemeral buildkit runners — ADR-039) + .route("/api/v1/vm-pool/spawn", post(spawn_runner)) + .route("/api/v1/vm-pool/{lease_id}", delete(destroy_runner)) + .route("/api/v1/vm-pool/p95/{workspace}", get(get_p95)) + .route("/api/v1/vm-pool/metrics", post(record_build_metrics)) + // Audit logging routes + .route("/api/v1/audit/query", post(query_audit_logs)) + .route("/api/v1/audit/export", post(export_audit_logs)) + .route("/api/v1/audit/stats", get(get_audit_stats)) + .route("/api/v1/audit/storage-stats", get(get_audit_storage_stats)) + .route("/api/v1/audit/apply-retention", post(apply_audit_retention)) + .route("/api/v1/audit/search", post(search_audit_logs)) + // Compliance routes + .nest( + "/api/v1/compliance", + compliance_routes(state.compliance_service.clone()), + ) + // Merge monitoring routes (includes /metrics, /ws, /events) + .merge(state.monitoring_system.create_routes()) + // Webhook handler (separate state — workspace registry) + .route( + "/api/v1/webhooks/{workspace_id}", + post(handle_webhook).with_state(webhook_state), + ) + .layer(CorsLayer::permissive()) + .with_state(state.clone()); + + // Infra reconcile endpoint — only compiled when the `nats` feature is active. + // Must be registered on a fresh router that still holds a state reference. + #[cfg(feature = "nats")] + let app = { + use crate::handlers::infra::trigger_infra_reconcile; + app.merge( + Router::new() + .route("/api/v1/infra/reconcile", post(trigger_infra_reconcile)) + .with_state(state), + ) + }; + + app +} diff --git a/crates/orchestrator/src/solo_nats.rs b/crates/orchestrator/src/solo_nats.rs new file mode 100644 index 0000000..30c6f52 --- /dev/null +++ b/crates/orchestrator/src/solo_nats.rs @@ -0,0 +1,52 @@ +/// Solo mode helpers: spawn nats-server child process and wait for readiness +#[cfg(feature = "nats")] +pub mod inner { + use anyhow::{Context, Result}; + use tokio::net::TcpStream; + use tokio::process::Command; + use tokio::time::{timeout, Duration, Instant}; + use tracing::info; + + /// Spawn `nats-server` as a child process with JetStream enabled. + /// + /// The returned `Child` holds the process alive; drop it to kill the + /// server. Uses `kill_on_drop(true)` so the process is cleaned up when + /// `Child` is dropped. + pub async fn spawn_nats_server(data_dir: &str) -> Result { + let nats_store_dir = format!("{}/nats", data_dir); + std::fs::create_dir_all(&nats_store_dir) + .context("Failed to create NATS storage directory")?; + + let child = Command::new("nats-server") + .args(["-js", "-sd", &nats_store_dir, "-p", "4222"]) + .kill_on_drop(true) + .spawn() + .context("Failed to spawn nats-server — ensure nats-server is in PATH")?; + + wait_for_nats(4222).await?; + info!("✓ NATS server (solo mode) ready on port 4222"); + Ok(child) + } + + /// Attempt TCP connect to 127.0.0.1:{port} in a loop until ready or + /// timeout. + async fn wait_for_nats(port: u16) -> Result<()> { + let addr = format!("127.0.0.1:{}", port); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if Instant::now() > deadline { + return Err(anyhow::anyhow!( + "NATS server did not become ready within 10 seconds on port {}", + port + )); + } + if timeout(Duration::from_millis(200), TcpStream::connect(&addr)) + .await + .is_ok() + { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + } +} diff --git a/crates/orchestrator/src/ssh/key_deployer.rs b/crates/orchestrator/src/ssh/key_deployer.rs index e814a53..36c3bb5 100644 --- a/crates/orchestrator/src/ssh/key_deployer.rs +++ b/crates/orchestrator/src/ssh/key_deployer.rs @@ -4,7 +4,7 @@ use anyhow::Result; use chrono::Utc; -use service_clients::MachinesClient; +use platform_clients::MachinesClient; use tracing::{debug, error, info, warn}; use super::{SshKeyDeployment, TemporalSshKey}; diff --git a/crates/orchestrator/src/ssh_exec.rs b/crates/orchestrator/src/ssh_exec.rs new file mode 100644 index 0000000..79b56d2 --- /dev/null +++ b/crates/orchestrator/src/ssh_exec.rs @@ -0,0 +1,62 @@ +//! Thin SSH command executor for the preconditions gate. +//! +//! Shells out to the system `ssh` binary via `tokio::process::Command`. +//! Uses BatchMode=yes (no interactive prompts) and a configurable timeout. + +use std::time::Duration; + +use anyhow::{Context, Result}; +use tokio::process::Command; + +/// Output from a single SSH command. +#[derive(Debug, Clone)] +pub struct SshOutput { + pub exit_code: i32, + pub stdout: String, + pub stderr: String, +} + +impl SshOutput { + pub fn succeeded(&self) -> bool { + self.exit_code == 0 + } +} + +/// Execute a single command on a remote host via the system `ssh` binary. +/// +/// Connects with `BatchMode=yes` (no password prompts) and `StrictHostKeyChecking=no` +/// (suitable for known internal infrastructure). The optional `key_path` is passed as `-i`. +pub async fn run_ssh_command( + host: &str, + user: &str, + key_path: Option<&str>, + command: &str, + timeout: Duration, +) -> Result { + let mut cmd = Command::new("ssh"); + cmd.arg("-o") + .arg("BatchMode=yes") + .arg("-o") + .arg("StrictHostKeyChecking=no") + .arg("-o") + .arg("UserKnownHostsFile=/dev/null") + .arg("-o") + .arg(format!("ConnectTimeout={}", timeout.as_secs())); + + if let Some(key) = key_path { + cmd.arg("-i").arg(key); + } + + cmd.arg(format!("{user}@{host}")).arg(command); + + let output = tokio::time::timeout(timeout + Duration::from_secs(2), cmd.output()) + .await + .context("SSH command timed out")? + .context("Failed to spawn ssh process")?; + + Ok(SshOutput { + exit_code: output.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) +} diff --git a/crates/orchestrator/src/storage/surrealdb.rs b/crates/orchestrator/src/storage/surrealdb.rs index 9aebdff..c4cda73 100644 --- a/crates/orchestrator/src/storage/surrealdb.rs +++ b/crates/orchestrator/src/storage/surrealdb.rs @@ -16,7 +16,7 @@ use futures::stream::BoxStream; use serde::{Deserialize, Serialize}; use surrealdb::engine::any::Any; use surrealdb::opt::auth::Root; -use surrealdb::sql::Value; +use surrealdb::types::{RecordId, RecordIdKey, SurrealValue}; use surrealdb::Surreal; use tokio::sync::RwLock; use tracing::{debug, error, info, warn}; @@ -119,10 +119,11 @@ impl Default for SurrealConfig { } /// Internal task representation for SurrealDB -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] +#[surreal(crate = "surrealdb::types")] struct SurrealTask { #[serde(skip_serializing_if = "Option::is_none")] - id: Option, + id: Option, name: String, command: String, args: Vec, @@ -151,7 +152,7 @@ impl From for SurrealTask { id: if task.id.is_empty() { None } else { - Some(task.id) + Some(RecordId::new("tasks", task.id.as_str())) }, name: task.name, command: task.command, @@ -175,7 +176,15 @@ impl From for SurrealTask { impl From for WorkflowTask { fn from(task: SurrealTask) -> Self { Self { - id: task.id.unwrap_or_else(|| Uuid::new_v4().to_string()), + id: task + .id + .map(|r| match r.key { + RecordIdKey::String(s) => s, + RecordIdKey::Number(n) => n.to_string(), + RecordIdKey::Uuid(u) => u.to_string(), + _ => Uuid::new_v4().to_string(), + }) + .unwrap_or_else(|| Uuid::new_v4().to_string()), name: task.name, command: task.command, args: task.args, @@ -186,6 +195,7 @@ impl From for WorkflowTask { completed_at: task.completed_at, output: task.output, error: task.error, + tags: Default::default(), } } } @@ -292,7 +302,10 @@ impl SurrealStorage { })?; self.db - .signin(Root { username, password }) + .signin(Root { + username: username.clone(), + password: password.clone(), + }) .await .map_err(|e| StorageError::AuthenticationFailed { reason: format!("Authentication failed: {}", e), @@ -454,7 +467,7 @@ impl TaskStorage for SurrealStorage { let mut surreal_task = SurrealTask::from(task.clone()); surreal_task.priority = priority as i32; - surreal_task.id = Some(task_id.clone()); + surreal_task.id = Some(RecordId::new("tasks", task_id.as_str())); if let Some(user) = self.get_current_user().await { surreal_task.created_by = Some(user); @@ -475,7 +488,7 @@ impl TaskStorage for SurrealStorage { "enqueued_at": Utc::now(), }); - let _: Option = self + let _: Option = self .db .create("task_queue") .content(queue_entry) @@ -520,7 +533,7 @@ impl TaskStorage for SurrealStorage { .await .map_err(|e| StorageError::BackendError(e.into()))?; - let queue_items: Vec = result + let queue_items: Vec = result .take(0) .map_err(|e| StorageError::BackendError(e.into()))?; @@ -529,7 +542,7 @@ impl TaskStorage for SurrealStorage { } // Extract task_id from queue item (simplified approach) - let queue_item_json = queue_items[0].clone().into_json(); + let queue_item_json = queue_items[0].clone(); let task_id = queue_item_json .get("task_id") .and_then(|v| v.as_str()) @@ -545,7 +558,7 @@ impl TaskStorage for SurrealStorage { // Get the actual task let task: Option = self .db - .select(("tasks", &task_id)) + .select(("tasks", task_id.as_str())) .await .map_err(|e| StorageError::BackendError(e.into()))?; @@ -553,9 +566,9 @@ impl TaskStorage for SurrealStorage { Some(task) => task, None => { // Clean up orphaned queue entry - let _: Option = self + let _: Option = self .db - .delete(("task_queue", &queue_id)) + .delete(("task_queue", queue_id.as_str())) .await .map_err(|e| StorageError::BackendError(e.into()))?; return Ok(None); @@ -563,9 +576,9 @@ impl TaskStorage for SurrealStorage { }; // Remove from queue - let _: Option = self + let _: Option = self .db - .delete(("task_queue", &queue_id)) + .delete(("task_queue", queue_id.as_str())) .await .map_err(|e| StorageError::BackendError(e.into()))?; @@ -592,7 +605,7 @@ impl TaskStorage for SurrealStorage { .await .map_err(|e| StorageError::BackendError(e.into()))?; - let queue_items: Vec = result + let queue_items: Vec = result .take(0) .map_err(|e| StorageError::BackendError(e.into()))?; @@ -600,7 +613,7 @@ impl TaskStorage for SurrealStorage { return Ok(None); } - let queue_item_json = queue_items[0].clone().into_json(); + let queue_item_json = queue_items[0].clone(); let task_id = queue_item_json .get("task_id") .and_then(|v| v.as_str()) @@ -609,7 +622,7 @@ impl TaskStorage for SurrealStorage { let task: Option = self .db - .select(("tasks", &task_id)) + .select(("tasks", task_id.as_str())) .await .map_err(|e| StorageError::BackendError(e.into()))?; @@ -632,7 +645,7 @@ impl TaskStorage for SurrealStorage { let _: Option = self .db - .update(("tasks", &task.id)) + .update(("tasks", task.id.as_str())) .content(surreal_task) .await .map_err(|e| StorageError::BackendError(e.into()))?; @@ -647,7 +660,7 @@ impl TaskStorage for SurrealStorage { "updated_at": Utc::now(), }); - let _: Option = self + let _: Option = self .db .update(("tasks", id)) .merge(update_data) @@ -724,7 +737,7 @@ impl TaskStorage for SurrealStorage { "enqueued_at": Utc::now(), }); - let _: Option = self + let _: Option = self .db .create("task_queue") .content(queue_entry) @@ -743,13 +756,13 @@ impl TaskStorage for SurrealStorage { .await .map_err(|e| StorageError::BackendError(e.into()))?; - let counts: Vec = result + let counts: Vec = result .take(0) .map_err(|e| StorageError::BackendError(e.into()))?; let count_json = counts .first() - .map(|v| v.clone().into_json()) + .cloned() .unwrap_or_default(); let count = count_json @@ -768,13 +781,13 @@ impl TaskStorage for SurrealStorage { .await .map_err(|e| StorageError::BackendError(e.into()))?; - let counts: Vec = result + let counts: Vec = result .take(0) .map_err(|e| StorageError::BackendError(e.into()))?; let count_json = counts .first() - .map(|v| v.clone().into_json()) + .cloned() .unwrap_or_default(); let count = count_json @@ -799,7 +812,7 @@ impl TaskStorage for SurrealStorage { .await .map_err(|e| StorageError::BackendError(e.into()))?; - let deleted: Vec = result + let deleted: Vec = result .take(0) .map_err(|e| StorageError::BackendError(e.into()))?; @@ -826,13 +839,13 @@ impl TaskStorage for SurrealStorage { .await .map_err(|e| StorageError::BackendError(e.into()))?; - let entries: Vec = result + let entries: Vec = result .take(0) .map_err(|e| StorageError::BackendError(e.into()))?; let mut audit_entries = Vec::new(); for entry in entries { - if let Ok(audit_entry) = serde_json::from_value::(entry.into_json()) { + if let Ok(audit_entry) = serde_json::from_value::(entry) { audit_entries.push(audit_entry); } } @@ -843,7 +856,7 @@ impl TaskStorage for SurrealStorage { async fn record_audit_entry(&self, entry: AuditEntry) -> StorageResult<()> { let entry_data = serde_json::to_value(&entry).map_err(StorageError::SerializationError)?; - let _: Option = self + let _: Option = self .db .create("audit_log") .content(entry_data) @@ -868,13 +881,13 @@ impl TaskStorage for SurrealStorage { .await .map_err(|e| StorageError::BackendError(e.into()))?; - let entries: Vec = result + let entries: Vec = result .take(0) .map_err(|e| StorageError::BackendError(e.into()))?; let mut metrics = Vec::new(); for entry in entries { - if let Ok(metric) = serde_json::from_value::(entry.into_json()) { + if let Ok(metric) = serde_json::from_value::(entry) { metrics.push(metric); } } @@ -886,7 +899,7 @@ impl TaskStorage for SurrealStorage { let metric_data = serde_json::to_value(&metric).map_err(StorageError::SerializationError)?; - let _: Option = self + let _: Option = self .db .create("metrics") .content(metric_data) @@ -940,7 +953,7 @@ impl TaskStorage for SurrealStorage { async fn publish_event(&self, event: TaskEvent) -> StorageResult<()> { let event_data = serde_json::to_value(&event).map_err(StorageError::SerializationError)?; - let _: Option = self + let _: Option = self .db .create("task_events") .content(event_data) @@ -1033,14 +1046,14 @@ impl TaskStorage for SurrealStorage { .await .map_err(|e| StorageError::BackendError(e.into()))?; - let tasks: Vec = result + let tasks: Vec = result .take(0) .map_err(|e| StorageError::BackendError(e.into()))?; let deps: Vec = tasks .into_iter() .filter_map(|v| { - let json = v.into_json(); + let json = v; json.get("id").and_then(|id| id.as_str()).map(String::from) }) .collect(); @@ -1098,13 +1111,13 @@ impl TaskStorage for SurrealStorage { .await .map_err(|e| StorageError::BackendError(e.into()))?; - let counts: Vec = result + let counts: Vec = result .take(0) .map_err(|e| StorageError::BackendError(e.into()))?; let count_json = counts .first() - .map(|v| v.clone().into_json()) + .cloned() .unwrap_or_default(); let count = count_json @@ -1142,7 +1155,7 @@ impl SurrealStorage { let mut surreal_task = SurrealTask::from(task); surreal_task.dependencies = dependencies; - surreal_task.id = Some(task_id.clone()); + surreal_task.id = Some(RecordId::new("tasks", task_id.as_str())); if let Some(user) = self.get_current_user().await { surreal_task.created_by = Some(user); @@ -1214,11 +1227,11 @@ impl SurrealStorage { .await .map_err(|e| StorageError::BackendError(e.into()))?; - let avg_times: Vec = result + let avg_times: Vec = result .take(0) .map_err(|e| StorageError::BackendError(e.into()))?; - if let Some(avg_time_json) = avg_times.first().map(|v| v.clone().into_json()) { + if let Some(avg_time_json) = avg_times.first().cloned() { if let Some(avg_time) = avg_time_json.get("avg_time").and_then(|v| v.as_f64()) { result_map.insert("avg_execution_time_seconds".to_string(), avg_time); } @@ -1239,11 +1252,11 @@ impl SurrealStorage { .await .map_err(|e| StorageError::BackendError(e.into()))?; - let success_rates: Vec = result + let success_rates: Vec = result .take(0) .map_err(|e| StorageError::BackendError(e.into()))?; - if let Some(success_rate_json) = success_rates.first().map(|v| v.clone().into_json()) { + if let Some(success_rate_json) = success_rates.first().cloned() { if let Some(success_rate) = success_rate_json .get("success_rate") .and_then(|v| v.as_f64()) diff --git a/crates/orchestrator/src/task_executor.rs b/crates/orchestrator/src/task_executor.rs new file mode 100644 index 0000000..7d01596 --- /dev/null +++ b/crates/orchestrator/src/task_executor.rs @@ -0,0 +1,194 @@ +use std::sync::Arc; + +use provisioning_orchestrator::{ + monitor::{MonitoringEvent, MonitoringEventType}, + SharedState, TaskStatus, +}; +use tracing::{error, info}; + +pub async fn execute_task(state: SharedState, mut task: provisioning_orchestrator::WorkflowTask) { + let metrics_collector = state.monitoring_system.metrics_collector(); + + metrics_collector.increment_task_counter(); + metrics_collector.record_storage_operation(true); + + if let Err(e) = state + .task_storage + .update_task_status(&task.id, TaskStatus::Running) + .await + { + error!("Failed to update task status: {}", e); + metrics_collector.record_storage_operation(false); + return; + } + + // Sync local task struct with the storage update so the failure path + // does not overwrite started_at back to None via update_task(). + task.started_at = Some(chrono::Utc::now()); + task.status = TaskStatus::Running; + + #[cfg(feature = "nats")] + state + .publish_task_status(&task.id, "running", Some(0), None) + .await; + + info!("Processing task: {} ({})", task.id, task.name); + + let task_start = std::time::Instant::now(); + let result = state + .execute_nushell_command(&task.command, &task.args) + .await; + let task_duration = task_start.elapsed(); + + match result { + Ok(output) => { + info!("Task {} completed successfully", task.id); + + task.output = Some(output); + task.status = TaskStatus::Completed; + task.completed_at = Some(chrono::Utc::now()); + + #[cfg(feature = "nats")] + state + .publish_task_status(&task.id, "completed", Some(100), None) + .await; + + #[cfg(feature = "nats")] + if task.tags.get("type").map(|s| s.as_str()) == Some("component") { + let event = provisioning_orchestrator::ComponentLifecycleEvent { + task_id: task.id.clone(), + component: task.tags.get("component").cloned().unwrap_or_default(), + server: task.tags.get("server").cloned().unwrap_or_default(), + workspace: task.tags.get("workspace").cloned().unwrap_or_default(), + operation: task.tags.get("operation").cloned().unwrap_or_default(), + status: "completed".to_string(), + }; + state.publish_component_lifecycle(&event).await; + } + + // Record metrics + metrics_collector.record_task_completion(task_duration.as_millis() as u64); + + // Publish monitoring event + let event = MonitoringEvent { + event_type: MonitoringEventType::TaskStatusChanged, + timestamp: chrono::Utc::now(), + data: serde_json::to_value(&task).unwrap_or_default(), + metadata: { + let mut meta = std::collections::HashMap::new(); + meta.insert("task_id".to_string(), task.id.clone()); + meta.insert("status".to_string(), "completed".to_string()); + meta.insert( + "duration_ms".to_string(), + task_duration.as_millis().to_string(), + ); + meta + }, + }; + + if let Err(e) = state.monitoring_system.publish_event(event).await { + error!("Failed to publish monitoring event: {}", e); + } + + if let Err(e) = state.task_storage.update_task(task).await { + error!("Failed to update task: {}", e); + metrics_collector.record_storage_operation(false); + } else { + metrics_collector.record_storage_operation(true); + } + } + Err(e) => { + error!("Task {} failed: {}", task.id, e); + + task.error = Some(e.to_string()); + task.status = TaskStatus::Failed; + task.completed_at = Some(chrono::Utc::now()); + + #[cfg(feature = "nats")] + state + .publish_task_status(&task.id, "failed", None, Some(&e.to_string())) + .await; + + #[cfg(feature = "nats")] + if task.tags.get("type").map(|s| s.as_str()) == Some("component") { + let event = provisioning_orchestrator::ComponentLifecycleEvent { + task_id: task.id.clone(), + component: task.tags.get("component").cloned().unwrap_or_default(), + server: task.tags.get("server").cloned().unwrap_or_default(), + workspace: task.tags.get("workspace").cloned().unwrap_or_default(), + operation: task.tags.get("operation").cloned().unwrap_or_default(), + status: "failed".to_string(), + }; + state.publish_component_lifecycle(&event).await; + } + + // Record metrics + metrics_collector.record_task_failure(); + + // Publish monitoring event + let event = MonitoringEvent { + event_type: MonitoringEventType::TaskStatusChanged, + timestamp: chrono::Utc::now(), + data: serde_json::to_value(&task).unwrap_or_default(), + metadata: { + let mut meta = std::collections::HashMap::new(); + meta.insert("task_id".to_string(), task.id.clone()); + meta.insert("status".to_string(), "failed".to_string()); + meta.insert("error".to_string(), e.to_string()); + meta + }, + }; + + if let Err(e) = state.monitoring_system.publish_event(event).await { + error!("Failed to publish monitoring event: {}", e); + } + + if let Err(e) = state.task_storage.update_task(task.clone()).await { + error!("Failed to update task: {}", e); + metrics_collector.record_storage_operation(false); + } else { + metrics_collector.record_storage_operation(true); + } + + // Try to requeue for retry + if let Err(e) = state.task_storage.requeue_failed_task(&task.id).await { + error!("Failed to requeue task: {}", e); + metrics_collector.record_storage_operation(false); + } + } + } +} + +pub async fn process_tasks(state: SharedState) { + info!("Starting task processor (max 4 concurrent tasks)"); + + // Semaphore limits concurrent task executions; acquire BEFORE dequeue so we + // never pull a task we can't immediately run. + let semaphore = Arc::new(tokio::sync::Semaphore::new(4)); + + loop { + let permit = match Arc::clone(&semaphore).acquire_owned().await { + Ok(p) => p, + Err(_) => break, + }; + + match state.task_storage.dequeue().await { + Ok(Some(task)) => { + let state_clone = state.clone(); + tokio::spawn(async move { + let _permit = permit; // released when this task completes + execute_task(state_clone, task).await; + }); + } + Ok(None) => { + drop(permit); + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + } + Err(e) => { + drop(permit); + error!("Error dequeuing task: {}", e); + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + } + } + } +} diff --git a/crates/orchestrator/src/vm_pool/handlers.rs b/crates/orchestrator/src/vm_pool/handlers.rs new file mode 100644 index 0000000..e295016 --- /dev/null +++ b/crates/orchestrator/src/vm_pool/handlers.rs @@ -0,0 +1,81 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::Json, +}; +use serde::Deserialize; +use tracing::error; +use uuid::Uuid; + +use super::{ + accumulate_metrics, p95_from_metrics, + types::{SpawnRequest, SpawnResponse}, +}; +use crate::{ApiResponse, SharedState}; + +pub async fn spawn_runner( + State(state): State, + Json(req): Json, +) -> Result>, StatusCode> { + let pool = state.vm_pool.as_ref().ok_or_else(|| { + error!("vm_pool not configured — RUNNER_SCRIPT may be missing"); + StatusCode::SERVICE_UNAVAILABLE + })?; + + match pool.spawn_runner(req).await { + Ok(resp) => Ok(Json(ApiResponse::success(resp))), + Err(e) => { + error!(error = %e, "vm_pool spawn_runner failed"); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +pub async fn destroy_runner( + State(state): State, + Path(lease_id): Path, +) -> Result>, StatusCode> { + let pool = state.vm_pool.as_ref().ok_or(StatusCode::SERVICE_UNAVAILABLE)?; + + let uuid = Uuid::parse_str(&lease_id).map_err(|_| StatusCode::BAD_REQUEST)?; + + match pool.destroy_runner(uuid).await { + Ok(()) => Ok(Json(ApiResponse::success(()))), + Err(e) => { + error!(lease_id = %lease_id, error = %e, "vm_pool destroy_runner failed"); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +pub async fn get_p95( + State(state): State, + Path(workspace): Path, +) -> Result>, StatusCode> { + match p95_from_metrics(&state.build_metrics, &workspace) { + Some(stats) => Ok(Json(ApiResponse::success(stats))), + None => Err(StatusCode::NOT_FOUND), + } +} + +#[derive(Deserialize)] +pub struct BuildMetricsPayload { + pub workspace: String, + pub cpu_secs: f64, + pub mem_peak_mb: f64, + pub duration_secs: f64, +} + +pub async fn record_build_metrics( + State(state): State, + Json(payload): Json, +) -> Json> { + accumulate_metrics( + &state.build_metrics, + &payload.workspace, + payload.cpu_secs, + payload.mem_peak_mb, + payload.duration_secs, + ); + Json(ApiResponse::success(())) +} diff --git a/crates/orchestrator/src/vm_pool/mod.rs b/crates/orchestrator/src/vm_pool/mod.rs new file mode 100644 index 0000000..aa31319 --- /dev/null +++ b/crates/orchestrator/src/vm_pool/mod.rs @@ -0,0 +1,6 @@ +pub mod handlers; +pub mod pool; +pub mod types; + +pub use pool::{accumulate_metrics, p95_from_metrics, BuildMetrics, VmPool}; +pub use types::{P95Stats, RunnerLease, SpawnRequest, SpawnResponse}; diff --git a/crates/orchestrator/src/vm_pool/pool.rs b/crates/orchestrator/src/vm_pool/pool.rs new file mode 100644 index 0000000..403cf22 --- /dev/null +++ b/crates/orchestrator/src/vm_pool/pool.rs @@ -0,0 +1,241 @@ +use std::{path::PathBuf, sync::Arc}; + +use anyhow::{bail, Context, Result}; +use chrono::Utc; +use dashmap::DashMap; +use serde::{Deserialize, Serialize}; +use tokio::io::AsyncWriteExt as _; +use tracing::info; +use uuid::Uuid; + +use super::types::{P95Stats, RunnerLease, RunnerSpec, SpawnRequest, SpawnResponse}; + +pub type BuildMetrics = Arc>; + +/// Environment-driven configuration for the Nu runner script backend. +/// +/// All fields map to env vars; `RUNNER_SCRIPT` is the only required one — +/// its absence causes `try_new()` to return `None` (pool disabled). +#[derive(Clone)] +struct RunnerConfig { + /// Absolute path to the `runner.nu` script (e.g. `catalog/providers/hetzner/runner.nu`). + script: PathBuf, + /// Nu binary name or path. Defaults to `nu`. + nu_bin: String, + /// Provider location passed as `location` in the spawn JSON. + location: String, + /// SSH key name registered with the provider. + ssh_key_ref: String, + /// Default image ref — label selector or direct ID. Overridden per-request by `SpawnRequest.image`. + image_ref: String, + /// Optional private network name. + network_ref: Option, + /// Optional firewall name. + firewall_ref: Option, +} + +impl RunnerConfig { + fn from_env() -> Option { + let script = std::env::var("RUNNER_SCRIPT").ok().map(PathBuf::from)?; + Some(Self { + script, + nu_bin: std::env::var("NU_BIN").unwrap_or_else(|_| "nu".into()), + location: std::env::var("RUNNER_LOCATION").unwrap_or_else(|_| "fsn1".into()), + ssh_key_ref: std::env::var("RUNNER_SSH_KEY_REF") + .unwrap_or_else(|_| "orchestrator-buildkit".into()), + image_ref: std::env::var("RUNNER_IMAGE_REF") + .unwrap_or_else(|_| "app=buildkit-runner-golden".into()), + network_ref: std::env::var("RUNNER_NETWORK_REF").ok(), + firewall_ref: std::env::var("RUNNER_FIREWALL_REF").ok(), + }) + } +} + +#[derive(Serialize)] +struct SpawnInput<'a> { + cpu: u32, + memory_gb: u32, + disk_gb: u32, + time_budget_min: u32, + image_ref: &'a str, + location: &'a str, + ssh_key_ref: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + network_ref: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + firewall_ref: Option<&'a str>, +} + +#[derive(Deserialize)] +struct SpawnOutput { + handle: String, + ssh_host: String, + ssh_port: u16, + expires_at: Option, +} + +#[derive(Clone)] +pub struct VmPool { + config: RunnerConfig, + leases: Arc>, + build_metrics: BuildMetrics, +} + +impl VmPool { + pub fn new(build_metrics: BuildMetrics) -> Result { + let config = RunnerConfig::from_env() + .context("RUNNER_SCRIPT environment variable is required for vm_pool")?; + Ok(Self { + config, + leases: Arc::new(DashMap::new()), + build_metrics, + }) + } + + /// Returns `None` when `RUNNER_SCRIPT` is not set — pool disabled gracefully. + pub fn try_new(build_metrics: BuildMetrics) -> Option { + Self::new(build_metrics).ok() + } + + pub async fn spawn_runner(&self, req: SpawnRequest) -> Result { + let cfg = &self.config; + let image_ref = if req.image.is_empty() { &cfg.image_ref } else { &req.image }; + + let input = SpawnInput { + cpu: req.cpu, + memory_gb: req.memory_gb, + disk_gb: req.disk_gb, + time_budget_min: req.time_budget_min, + image_ref, + location: &cfg.location, + ssh_key_ref: &cfg.ssh_key_ref, + network_ref: cfg.network_ref.as_deref(), + firewall_ref: cfg.firewall_ref.as_deref(), + }; + + let json_in = serde_json::to_string(&input).context("serialize SpawnInput")?; + let stdout = dispatch(&cfg.nu_bin, &cfg.script, "spawn", &json_in).await?; + let out: SpawnOutput = serde_json::from_str(stdout.trim()) + .context("deserialize SpawnOutput from runner.nu")?; + + let lease_id = Uuid::new_v4(); + let expires_at = Utc::now() + chrono::Duration::minutes(req.time_budget_min as i64); + + let lease = RunnerLease { + lease_id, + runner_handle: out.handle.clone(), + ssh_host: out.ssh_host.clone(), + ssh_port: out.ssh_port, + spec: RunnerSpec { + cpu: req.cpu, + memory_gb: req.memory_gb, + disk_gb: req.disk_gb, + time_budget_min: req.time_budget_min, + }, + workspace: req.workspace, + created_at: Utc::now(), + expires_at, + }; + + self.leases.insert(lease_id, lease); + info!(lease_id = %lease_id, ssh_host = %out.ssh_host, "runner lease issued"); + + Ok(SpawnResponse { + lease_id, + ssh_host: out.ssh_host, + ssh_port: out.ssh_port, + expires_at: expires_at.to_rfc3339(), + }) + } + + pub async fn destroy_runner(&self, lease_id: Uuid) -> Result<()> { + let (_, lease) = self + .leases + .remove(&lease_id) + .ok_or_else(|| anyhow::anyhow!("lease {lease_id} not found"))?; + + let json_in = serde_json::json!({ "handle": lease.runner_handle }).to_string(); + dispatch(&self.config.nu_bin, &self.config.script, "destroy", &json_in).await?; + + info!(lease_id = %lease_id, "runner VM destroyed"); + Ok(()) + } + + pub fn p95_stats(&self, workspace: &str) -> Option { + p95_from_metrics(&self.build_metrics, workspace) + } + + pub fn record_build_metrics( + &self, + workspace: &str, + cpu_secs: f64, + mem_peak_mb: f64, + duration_secs: f64, + ) { + accumulate_metrics(&self.build_metrics, workspace, cpu_secs, mem_peak_mb, duration_secs); + } +} + +pub fn accumulate_metrics( + store: &BuildMetrics, + workspace: &str, + cpu_secs: f64, + mem_peak_mb: f64, + duration_secs: f64, +) { + let mut entry = store.entry(workspace.to_string()).or_insert((0.0, 0.0, 0.0, 0)); + entry.0 += cpu_secs; + entry.1 += mem_peak_mb; + entry.2 += duration_secs; + entry.3 += 1; +} + +pub fn p95_from_metrics(store: &BuildMetrics, workspace: &str) -> Option { + store.get(workspace).map(|acc| { + let (sum_cpu, sum_mem, sum_dur, count) = *acc; + if count == 0 { + return P95Stats { cpu_p95: 0.0, memory_mb_p95: 0.0, duration_secs_p95: 0.0, sample_count: 0 }; + } + P95Stats { + cpu_p95: sum_cpu / count as f64, + memory_mb_p95: sum_mem / count as f64, + duration_secs_p95: sum_dur / count as f64, + sample_count: count, + } + }) +} + +async fn dispatch(nu_bin: &str, script: &PathBuf, command: &str, json_input: &str) -> Result { + let mut child = tokio::process::Command::new(nu_bin) + .arg(script) + .arg(command) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .with_context(|| format!("failed to spawn `nu {} {command}`", script.display()))?; + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(json_input.as_bytes()) + .await + .with_context(|| format!("failed to write to runner `{command}` stdin"))?; + } + + let out = child + .wait_with_output() + .await + .with_context(|| format!("runner `{command}` process error"))?; + + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + bail!( + "runner `{command}` exit {}: {}", + out.status.code().unwrap_or(-1), + stderr.lines().last().unwrap_or("(no stderr)") + ); + } + + String::from_utf8(out.stdout).context("runner stdout not valid UTF-8") +} + diff --git a/crates/orchestrator/src/vm_pool/types.rs b/crates/orchestrator/src/vm_pool/types.rs new file mode 100644 index 0000000..2ca4ee2 --- /dev/null +++ b/crates/orchestrator/src/vm_pool/types.rs @@ -0,0 +1,53 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RunnerSpec { + pub cpu: u32, + pub memory_gb: u32, + pub disk_gb: u32, + pub time_budget_min: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RunnerLease { + pub lease_id: Uuid, + /// Opaque handle returned by `runner.nu spawn`; passed verbatim to `runner.nu destroy`. + pub runner_handle: String, + pub ssh_host: String, + pub ssh_port: u16, + pub spec: RunnerSpec, + pub workspace: String, + pub created_at: DateTime, + pub expires_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct SpawnRequest { + pub cpu: u32, + pub memory_gb: u32, + pub disk_gb: u32, + pub time_budget_min: u32, + pub workspace: String, + /// Image reference passed to the runner — label selector (e.g. `app=buildkit-runner-golden`) + /// or a direct image ID/name. Interpretation is left to the runner script. + pub image: String, +} + +#[derive(Debug, Serialize)] +pub struct SpawnResponse { + pub lease_id: Uuid, + pub ssh_host: String, + pub ssh_port: u16, + pub expires_at: String, +} + +#[derive(Debug, Serialize)] +pub struct P95Stats { + pub cpu_p95: f64, + pub memory_mb_p95: f64, + pub duration_secs_p95: f64, + pub sample_count: u64, +} + diff --git a/crates/orchestrator/src/workflow.rs b/crates/orchestrator/src/workflow.rs index ca6d4b7..08c2bb9 100644 --- a/crates/orchestrator/src/workflow.rs +++ b/crates/orchestrator/src/workflow.rs @@ -19,6 +19,9 @@ use uuid::Uuid; use crate::{storage::TaskStorage, TaskStatus, WorkflowTask}; +#[cfg(feature = "nats")] +use platform_nats::NatsBridge; + /// Configuration for workflow execution #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkflowConfig { @@ -259,6 +262,9 @@ pub struct BatchWorkflowEngine { storage: Arc, config: WorkflowConfig, execution_states: Arc>>, + #[cfg(feature = "nats")] + nats: Option>, + workspace: Option, } impl BatchWorkflowEngine { @@ -268,14 +274,70 @@ impl BatchWorkflowEngine { storage, config, execution_states: Arc::new(RwLock::new(HashMap::new())), + #[cfg(feature = "nats")] + nats: None, + workspace: None, } } - /// Execute a workflow definition + /// Attach a NATS bridge for DAG event emission. + #[cfg(feature = "nats")] + pub fn with_nats(mut self, nats: Arc) -> Self { + self.nats = Some(nats); + self + } + + /// Set the workspace name included in DAG NATS event payloads. + pub fn with_workspace(mut self, workspace: impl Into) -> Self { + self.workspace = Some(workspace.into()); + self + } + + /// Emit a DAG lifecycle event to NATS. No-op if NATS is not configured. + /// Failures are logged as warnings — event emission must not block execution. + #[cfg(feature = "nats")] + async fn emit_dag_event(&self, subject: &str, payload: serde_json::Value) { + if let Some(nats) = &self.nats { + if let Err(e) = nats.publish_json(subject, &payload).await { + tracing::warn!(subject, "DAG NATS event emission failed: {}", e); + } + } + } + + /// Emit a workspace NCL change event — signals ncl-sync to invalidate/re-export. + /// Subject contract: `provisioning.workspace.ncl.changed` + /// See ADR-024. + #[cfg(feature = "nats")] + async fn emit_ncl_changed(&self, workspace: Option<&str>, state_path: &str) { + let Some(ws) = workspace else { return }; + if let Some(nats) = &self.nats { + let payload = serde_json::json!({ + "workspace": ws, + "path": state_path, + "import_paths": [], + "source": "orchestrator", + }); + if let Err(e) = nats + .publish_json("provisioning.workspace.ncl.changed", &payload) + .await + { + tracing::warn!("NCL changed event emission failed: {}", e); + } + } + } + + /// Execute a workflow definition. + /// + /// `workspace` overrides the value set via `with_workspace()` for this + /// execution. Pass `None` to fall back to the struct-level workspace. pub async fn execute_workflow( &self, definition: WorkflowDefinition, + workspace: Option<&str>, ) -> Result { + let workspace: Option = workspace + .map(|s| s.to_owned()) + .or_else(|| self.workspace.clone()); let workflow_id = Uuid::new_v4().to_string(); info!( "Starting workflow execution: {} ({})", @@ -335,7 +397,7 @@ impl BatchWorkflowEngine { // Execute workflow let final_state = self - .execute_workflow_internal(workflow_id.clone(), definition, execution_state) + .execute_workflow_internal(workflow_id.clone(), definition, execution_state, workspace) .await?; info!( @@ -352,6 +414,8 @@ impl BatchWorkflowEngine { workflow_id: String, definition: WorkflowDefinition, mut state: WorkflowExecutionState, + #[cfg(feature = "nats")] workspace: Option, + #[cfg(not(feature = "nats"))] _workspace: Option, ) -> Result { let semaphore = Arc::new(Semaphore::new(self.config.max_parallel_tasks)); let mut completed_tasks = HashSet::new(); @@ -408,8 +472,40 @@ impl BatchWorkflowEngine { let workflow_id = workflow_id.clone(); let config = self.config.clone(); + // Capture emit data for the async block — the future must be awaited + // inside the async context, not dropped in this sync iterator closure. + #[cfg(feature = "nats")] + let workspace_for_event = workspace.clone(); + #[cfg(feature = "nats")] + let (started_subject, started_payload) = { + let is_hg = task_def + .metadata + .as_ref() + .and_then(|m| m.get("type")) + .map(|v| v == "health-gate") + .unwrap_or(false); + let fid = task_name + .split_once("::") + .map(|(f, _)| f.to_string()) + .unwrap_or_else(|| task_name.clone()); + let subject = if is_hg { + "provisioning.dag.healthgate.checking" + } else { + "provisioning.dag.formula.started" + }; + let payload = serde_json::json!({ + "workflow_id": workflow_id, + "formula_id": fid, + "task": task_name, + "workspace": workspace_for_event, + }); + (subject.to_string(), payload) + }; + async move { let _permit = semaphore.acquire().await.unwrap(); + #[cfg(feature = "nats")] + self.emit_dag_event(&started_subject, started_payload).await; self.execute_single_task( workflow_id, task_name.clone(), @@ -429,6 +525,23 @@ impl BatchWorkflowEngine { // Process results for (task_name, result) in results { + // Derive formula_id from namespaced task name: "formula-id::task-name" → "formula-id". + // For non-namespaced tasks the full name is used as the formula_id. + let formula_id = task_name + .split_once("::") + .map(|(fid, _)| fid) + .unwrap_or(&task_name); + + // Determine if this is a health gate synthetic task. + let is_health_gate = definition + .tasks + .iter() + .find(|t| t.name == task_name) + .and_then(|t| t.metadata.as_ref()) + .and_then(|m| m.get("type")) + .map(|v| v == "health-gate") + .unwrap_or(false); + match result { Ok(_) => { completed_tasks.insert(task_name.clone()); @@ -439,6 +552,39 @@ impl BatchWorkflowEngine { task_state.status = TaskStatus::Completed; task_state.completed_at = Some(chrono::Utc::now()); } + + #[cfg(feature = "nats")] + if is_health_gate { + self.emit_dag_event( + "provisioning.dag.healthgate.passed", + serde_json::json!({ + "workflow_id": workflow_id, + "gate_id": task_name, + }), + ) + .await; + } else { + self.emit_dag_event( + "provisioning.dag.formula.completed", + serde_json::json!({ + "workflow_id": workflow_id, + "formula_id": formula_id, + "task": task_name, + "workspace": workspace, + }), + ) + .await; + + // Nu tasks write .provisioning-state.ncl on transition — signal + // ncl-sync so the cache is refreshed before the next read. + // Path is conventional (/.provisioning-state.ncl); daemon + // also watches filesystem so exact path is best-effort. + if let Some(ws) = &workspace { + let state_path = + format!("{}/.provisioning-state.ncl", ws); + self.emit_ncl_changed(Some(ws), &state_path).await; + } + } } Err(e) => { error!("Task {} failed: {}", task_name, e); @@ -452,6 +598,31 @@ impl BatchWorkflowEngine { task_state.error = Some(e.to_string()); } + #[cfg(feature = "nats")] + if is_health_gate { + self.emit_dag_event( + "provisioning.dag.healthgate.failed", + serde_json::json!({ + "workflow_id": workflow_id, + "gate_id": task_name, + "error": e.to_string(), + }), + ) + .await; + } else { + self.emit_dag_event( + "provisioning.dag.formula.failed", + serde_json::json!({ + "workflow_id": workflow_id, + "formula_id": formula_id, + "task": task_name, + "error": e.to_string(), + "workspace": workspace, + }), + ) + .await; + } + if self.config.fail_fast { state.status = WorkflowStatus::Failed; return Ok(state); @@ -477,13 +648,17 @@ impl BatchWorkflowEngine { state.status = WorkflowStatus::Completed; } - // Calculate final statistics + // Calculate final statistics. + // duration_ms is written by execute_single_task into execution_states (the shared map), + // not into the local `state` copy. Read from execution_states to get accurate durations. if state.statistics.completed_tasks > 0 { - let total_task_duration: u64 = state - .task_states - .values() - .filter_map(|ts| ts.duration_ms) - .sum(); + let total_task_duration: u64 = { + let states = self.execution_states.read().await; + states + .get(&workflow_id) + .map(|s| s.task_states.values().filter_map(|ts| ts.duration_ms).sum()) + .unwrap_or(0) + }; state.statistics.average_task_duration_ms = Some(total_task_duration / state.statistics.completed_tasks as u64); @@ -576,6 +751,7 @@ impl BatchWorkflowEngine { completed_at: None, output: None, error: None, + tags: Default::default(), }; // Execute via storage backend diff --git a/crates/platform-config/src/format.rs b/crates/platform-config/src/format.rs index 8553133..6ae07b4 100644 --- a/crates/platform-config/src/format.rs +++ b/crates/platform-config/src/format.rs @@ -20,11 +20,30 @@ use crate::nickel; /// - `NickelExportFailed` si el export de NCL falla /// - `TomlParseFailed` o `JsonParseFailed` si el parsing falla pub fn load_config>(path: P) -> Result { + load_config_with_overrides(path, &serde_json::Value::Null) +} + +/// Carga un archivo de configuración mergeando overrides dinámicos. +/// Para archivos NCL, los overrides se pasan a Nickel que aplica contracts +/// al resultado mergeado. Para TOML, se hace deep-merge en memoria. +pub fn load_config_with_overrides>( + path: P, + overrides: &serde_json::Value, +) -> Result { let path = path.as_ref(); match path.extension().and_then(|s| s.to_str()) { - Some("ncl") => load_ncl_as_json(path), - Some("toml") => load_toml_as_json(path), + Some("ncl") => load_ncl_with_overrides(path, overrides), + Some("toml") => { + let base = load_toml_as_json(path)?; + let has_overrides = + matches!(overrides, serde_json::Value::Object(m) if !m.is_empty()); + if has_overrides { + Ok(deep_merge_json(base, overrides.clone())) + } else { + Ok(base) + } + } _ => { tracing::error!("Invalid config format: {:?}", path); Err(ConfigError::invalid_format(path)) @@ -32,10 +51,9 @@ pub fn load_config>(path: P) -> Result { } } -/// Carga un archivo NCL y lo convierte a JSON -fn load_ncl_as_json>(path: P) -> Result { - let path = path.as_ref(); - let json_string = nickel::export_to_json(path)?; +/// Carga un archivo NCL con overrides, validando contracts en el merge +fn load_ncl_with_overrides(path: &Path, overrides: &serde_json::Value) -> Result { + let json_string = nickel::export_with_overrides(path, overrides)?; serde_json::from_str::(&json_string) .map_err(|e| ConfigError::json_failed(format!("Failed to parse NCL export as JSON: {}", e))) @@ -53,43 +71,78 @@ fn load_toml_as_json>(path: P) -> Result { ConfigError::toml_failed(format!("Failed to parse TOML file {:?}: {}", path, e)) })?; - // Convierte TOML a JSON para consistencia - let json_value = serde_json::to_value(value) - .map_err(|e| ConfigError::json_failed(format!("Failed to convert TOML to JSON: {}", e)))?; - - Ok(json_value) + serde_json::to_value(value) + .map_err(|e| ConfigError::json_failed(format!("Failed to convert TOML to JSON: {}", e))) } -/// Trait que deben implementar todas las estructuras de configuración +/// Deep merge de dos JSON Values. `overlay` sobreescribe campos de `base`. +/// Para objetos, mergea recursivamente. Para otros tipos, overlay gana. +fn deep_merge_json(base: Value, overlay: Value) -> Value { + match (base, overlay) { + (Value::Object(mut base_map), Value::Object(overlay_map)) => { + for (key, overlay_val) in overlay_map { + let merged = match base_map.remove(&key) { + Some(base_val) => deep_merge_json(base_val, overlay_val), + None => overlay_val, + }; + base_map.insert(key, merged); + } + Value::Object(base_map) + } + (_, overlay) => overlay, + } +} + +/// Trait que deben implementar todas las estructuras de configuración. +/// +/// El flujo de carga es: +/// 1. `collect_env_overrides()` recoge env vars como JSON Value +/// 2. El archivo NCL base se mergea con los overrides +/// 3. Nickel aplica contracts al resultado mergeado (validación) +/// 4. Si pasa, serde deserializa el JSON validado al struct +/// +/// Los overrides nunca bypasean los contracts de Nickel. pub trait ConfigLoader: DeserializeOwned + Sized + Default { /// Retorna el nombre del servicio (ej: "orchestrator", "mcp-server") fn service_name() -> &'static str; - /// Carga la configuración usando la jerarquía estándar - /// Usa el tipo de error específico de la implementación - fn load() -> std::result::Result> { - let mut config = Self::load_from_hierarchy()?; - config.apply_env_overrides()?; - Ok(config) + /// Recoge env var overrides como un JSON Value (estructura anidada). + /// Los servicios sobreescriben este método para mapear sus env vars + /// específicas al shape del config NCL. + /// + /// Retorna un objeto JSON vacío si no hay overrides. + fn collect_env_overrides() -> serde_json::Value { + serde_json::Value::Object(serde_json::Map::new()) } - /// Carga desde la jerarquía de configuración (env vars → archivos → - /// defaults) + /// Carga la configuración usando la jerarquía estándar. + /// Los env overrides pasan por Nickel para validación de contracts. + fn load() -> std::result::Result> { + Self::load_from_hierarchy() + } + + /// Carga desde la jerarquía de configuración (archivo + env overrides + /// validados por Nickel → defaults) fn load_from_hierarchy() -> std::result::Result> { let service = Self::service_name(); + let overrides = Self::collect_env_overrides(); - // Intenta cargar desde la jerarquía if let Some(path) = hierarchy::resolve_config_path(service) { - return Self::from_path(&path); + let json_value = load_config_with_overrides(&path, &overrides)?; + return serde_json::from_value(json_value).map_err(|e| { + Box::new(ConfigError::deserialization_failed(format!( + "Failed to deserialize {} config: {}", + service, e + ))) as Box + }); } - // Fallback a defaults tracing::debug!("No config file found for {} - using defaults", service); Ok(Self::default()) } - /// Carga desde un archivo específico + /// Carga desde un archivo específico (sin env overrides) fn from_path>( path: P, ) -> std::result::Result> { @@ -104,11 +157,29 @@ pub trait ConfigLoader: DeserializeOwned + Sized + Default { }) } - /// Aplica overrides desde variables de entorno + /// Carga desde un archivo con overrides validados por Nickel + fn from_path_with_overrides>( + path: P, + overrides: &serde_json::Value, + ) -> std::result::Result> { + let path = path.as_ref(); + let json_value = load_config_with_overrides(path, overrides)?; + + serde_json::from_value(json_value).map_err(|e| { + Box::new(ConfigError::deserialization_failed(format!( + "Failed to deserialize config from {:?}: {}", + path, e + ))) as Box + }) + } + + /// DEPRECATED: Mutates config after deserialization, bypassing Nickel contracts. + /// Migrate to `collect_env_overrides()` instead. This method exists only for + /// backward compatibility while services are migrated. + #[deprecated(note = "Use collect_env_overrides() instead — overrides must pass through Nickel")] fn apply_env_overrides( &mut self, ) -> std::result::Result<(), Box> { - // Base implementation - servicios específicos pueden sobrescribir Ok(()) } } diff --git a/crates/platform-config/src/lib.rs b/crates/platform-config/src/lib.rs index f5d4ce9..7ff8315 100644 --- a/crates/platform-config/src/lib.rs +++ b/crates/platform-config/src/lib.rs @@ -33,14 +33,11 @@ //! "my-service" //! } //! -//! fn load_from_hierarchy() -> Result { -//! // Use default hierarchy: env vars -> files -> defaults -//! Ok(Self::default()) -//! } -//! -//! fn apply_env_overrides(&mut self) -> Result<()> { -//! // Apply service-specific overrides -//! Ok(()) +//! fn collect_env_overrides() -> serde_json::Value { +//! // Return env vars as JSON matching the NCL schema shape. +//! // These get merged into the NCL config and validated by contracts +//! // BEFORE deserialization — no post-hoc mutation. +//! serde_json::Value::Object(serde_json::Map::new()) //! } //! } //! @@ -73,7 +70,7 @@ pub use format::ConfigLoader; pub use git::{expand_path, GitRepoCache}; pub use hierarchy::{config_base_path, find_config_file, resolve_config_path}; pub use loader::{ConfigLoaderExt, ConfigValidator}; -pub use nickel::is_nickel_available; +pub use nickel::{export_with_overrides, is_nickel_available}; pub use resolver::{find_config_in_dir, find_config_in_dir_with_mode, ConfigResolver}; pub use startup::PlatformStartup; @@ -87,6 +84,16 @@ pub use startup::PlatformStartup; /// /// Returns the parsed JSON configuration or error if not found pub fn load_service_config_from_ncl(service_name: &str) -> Result { + load_service_config_from_ncl_with_overrides(service_name, &serde_json::Value::Null) +} + +/// Load service config from NCL with dynamic overrides validated by Nickel contracts. +/// Overrides are merged into the NCL config before export; contracts apply to the +/// merged result, so invalid overrides (e.g., port out of range) are rejected by Nickel. +pub fn load_service_config_from_ncl_with_overrides( + service_name: &str, + overrides: &serde_json::Value, +) -> Result { let config_dir = if let Ok(home) = env::var("HOME") { #[cfg(target_os = "macos")] { @@ -113,7 +120,7 @@ pub fn load_service_config_from_ncl(service_name: &str) -> Result Result { let service = Self::service_name(); + let overrides = Self::collect_env_overrides(); - // Intenta cargar desde la jerarquía if let Some(path) = crate::hierarchy::resolve_config_path(service) { - return format::load_config(&path); + return format::load_config_with_overrides(&path, &overrides); } - // Fallback a defaults tracing::debug!("No config file found for {} - using empty object", service); Ok(Value::Object(serde_json::Map::new())) } diff --git a/crates/platform-config/src/nickel.rs b/crates/platform-config/src/nickel.rs index 3afb4f2..7439cad 100644 --- a/crates/platform-config/src/nickel.rs +++ b/crates/platform-config/src/nickel.rs @@ -46,12 +46,46 @@ fn scan_extension_schemas(home: &str) -> Vec { extension_paths } +/// Returns the platform user data directory: ~/Library/Application Support/provisioning (macOS) +/// or ~/.local/share/provisioning (Linux). +fn platform_user_data_dir(home: &str) -> PathBuf { + #[cfg(target_os = "macos")] + return PathBuf::from(home).join("Library/Application Support/provisioning"); + #[cfg(not(target_os = "macos"))] + return PathBuf::from(home).join(".local/share/provisioning"); +} + +/// Reads the stored provisioning project root from the user data dir. +/// +/// Written by `prvng` (via `write-provisioning-root` in platform/target.nu) when +/// `$PROVISIONING` is available. Acts as a fallback for Rust binaries launched +/// outside the CLI wrapper where `PROVISIONING` is not set. +pub fn read_stored_provisioning_root(home: &str) -> Option { + let root_file = platform_user_data_dir(home).join("project-root"); + std::fs::read_to_string(&root_file) + .ok() + .map(|s| PathBuf::from(s.trim())) + .filter(|p| p.exists()) +} + +/// Persists the provisioning project root to the user data dir. +/// +/// Called from the CLI wrapper or platform init so that Rust binaries +/// can resolve schema imports without requiring `PROVISIONING` in env. +pub fn write_provisioning_root(home: &str, root: &Path) -> std::io::Result<()> { + let data_dir = platform_user_data_dir(home); + std::fs::create_dir_all(&data_dir)?; + std::fs::write(data_dir.join("project-root"), root.to_string_lossy().as_bytes()) +} + /// Construye el NICKEL_IMPORT_PATH correcto usando: /// 1. Variable de entorno NICKEL_IMPORT_PATH si existe (additive, prepended) /// 2. Extension schemas (scan ~/.cache/provisioning/extensions/*/schemas) /// 3. Git cache directories (schemas, configs) -/// 4. Paths desde el script principal (PROVISIONING_USER_PLATFORM) -/// 5. Path relativo al directorio actual (provisioning/schemas) +/// 4. $PROVISIONING env var (repo provisioning/ root — set by CLI wrapper) +/// 5. Stored project-root file (fallback when PROVISIONING not in env) +/// 6. Path relativo al directorio actual (provisioning/schemas parent) +/// 7. PROVISIONING_USER_PLATFORM fn build_nickel_import_path() -> Result { let mut paths = Vec::new(); @@ -81,8 +115,25 @@ fn build_nickel_import_path() -> Result { } } - // Priority 4: Find provisioning/schemas relative to current directory or - // project root + // Priority 4: $PROVISIONING — repo provisioning/ root (set by the CLI wrapper). + // schemas/ lives directly under this directory, so Nickel can resolve + // `import "schemas/platform/..."` when this path is in NICKEL_IMPORT_PATH. + if let Ok(provisioning_root) = env::var("PROVISIONING") { + let root = PathBuf::from(&provisioning_root); + if root.exists() { + tracing::debug!("Found PROVISIONING root at: {:?}", root); + paths.push(root); + } + } else if let Some(ref home) = home { + // Priority 4b: stored project-root file — written by the CLI wrapper, + // used as fallback when the binary is launched outside `prvng`. + if let Some(stored_root) = read_stored_provisioning_root(home) { + tracing::debug!("Found stored provisioning root at: {:?}", stored_root); + paths.push(stored_root); + } + } + + // Priority 5: Find provisioning/schemas relative to current directory if let Ok(cwd) = env::current_dir() { let schemas_path = cwd.join("provisioning").join("schemas"); if schemas_path.exists() { @@ -91,7 +142,7 @@ fn build_nickel_import_path() -> Result { } } - // Priority 5: Use PROVISIONING_USER_PLATFORM from main provisioning script + // Priority 6: Use PROVISIONING_USER_PLATFORM from main provisioning script if let Ok(user_platform) = env::var("PROVISIONING_USER_PLATFORM") { let user_platform_path = PathBuf::from(&user_platform); if user_platform_path.exists() { @@ -133,6 +184,81 @@ fn build_nickel_import_path() -> Result { /// - `NickelNotInstalled` si el CLI no está disponible /// - `NickelExportFailed` si la exportación falla pub fn export_to_json>(ncl_path: P) -> Result { + export_with_overrides(ncl_path, &serde_json::Value::Null) +} + +/// Converts a `serde_json::Value` into a Nickel record literal. +/// +/// Nickel uses `{ key = value }` syntax; JSON's `{"key": value}` is invalid +/// Nickel because `:` is a contract annotation, not assignment. +/// Generates a Nickel record literal from a JSON value. +/// +/// Record containers are emitted without merge priority so Nickel deep-merges +/// them with the base config. Leaf values (strings, numbers, bools, arrays) +/// are emitted with `| priority 1` so they win over user config assignments +/// at the default (priority 0) level, without dropping unrelated fields. +fn json_to_nickel_record(value: &serde_json::Value) -> String { + json_to_nickel_object(value, 0) +} + +fn json_to_nickel_object(value: &serde_json::Value, indent: usize) -> String { + match value { + serde_json::Value::Object(map) => { + if map.is_empty() { + return "{}".to_string(); + } + let pad = " ".repeat(indent); + let inner_pad = " ".repeat(indent + 1); + let fields: Vec = map + .iter() + .map(|(k, v)| match v { + serde_json::Value::Object(_) => { + format!("{}{} = {}", inner_pad, k, json_to_nickel_object(v, indent + 1)) + } + _ => format!( + "{}{} | priority 1 = {}", + inner_pad, + k, + json_to_nickel_leaf(v) + ), + }) + .collect(); + format!("{{\n{}\n{}}}", fields.join(",\n"), pad) + } + _ => json_to_nickel_leaf(value), + } +} + +fn json_to_nickel_leaf(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Null => "null".to_string(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::String(s) => { + format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) + } + serde_json::Value::Array(arr) => { + let items: Vec = arr.iter().map(json_to_nickel_leaf).collect(); + format!("[{}]", items.join(", ")) + } + serde_json::Value::Object(_) => json_to_nickel_object(value, 0), + } +} + +/// Exporta un NCL a JSON, mergeando overrides dinámicos (env vars, CLI args) +/// como un record Nickel temporal. Los contracts del schema base se aplican +/// al resultado mergeado, validando los overrides contra las constraints. +/// +/// Si `overrides` es null o un objeto vacío, se comporta como `export_to_json`. +/// +/// # Errors +/// - `NickelNotInstalled` si el CLI no está disponible +/// - `NickelExportFailed` si la exportación o validación de contracts falla +/// (e.g., un override viola un constraint como port_high) +pub fn export_with_overrides>( + ncl_path: P, + overrides: &serde_json::Value, +) -> Result { let ncl_path = ncl_path.as_ref(); if !is_nickel_available() { @@ -142,19 +268,58 @@ pub fn export_to_json>(ncl_path: P) -> Result { tracing::debug!("Exporting NCL config to JSON: {:?}", ncl_path); - // Build NICKEL_IMPORT_PATH with proper schema directories let import_path = build_nickel_import_path()?; tracing::debug!("Using NICKEL_IMPORT_PATH: {}", import_path); - let output = Command::new("nickel") - .arg("export") + let has_overrides = matches!(overrides, serde_json::Value::Object(m) if !m.is_empty()); + + // Build override temp file if needed. + // JSON uses `{"k": v}` but Nickel interprets `:` as a contract annotation. + // We must emit valid Nickel record syntax: `{ k = v }`. + let override_file = if has_overrides { + let override_content = json_to_nickel_record(overrides); + + let tmp_dir = std::env::temp_dir(); + let override_path = tmp_dir.join(format!("prov-override-{}.ncl", std::process::id())); + + std::fs::write(&override_path, &override_content).map_err(|e| { + ConfigError::io_error(format!("Failed to write override file: {}", e)) + })?; + + tracing::debug!( + "Created override file at {:?} with content: {}", + override_path, + override_content + ); + + Some(override_path) + } else { + None + }; + + let mut cmd = Command::new("nickel"); + cmd.arg("export") .arg("--format") .arg("json") .arg(ncl_path) - .env("NICKEL_IMPORT_PATH", &import_path) + .env("NICKEL_IMPORT_PATH", &import_path); + + // Nickel merges multiple input files: base config + overrides + if let Some(ref path) = override_file { + cmd.arg(path); + } + + let output = cmd .output() .map_err(|e| ConfigError::io_error(format!("Failed to execute nickel CLI: {}", e)))?; + // Cleanup temp file regardless of outcome + if let Some(ref path) = override_file { + if let Err(e) = std::fs::remove_file(path) { + tracing::warn!("Failed to cleanup override file {:?}: {}", path, e); + } + } + if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); tracing::error!("Nickel export failed for {:?}: {}", ncl_path, stderr); diff --git a/crates/platform-db/Cargo.toml b/crates/platform-db/Cargo.toml index 2a4bb61..3abed0a 100644 --- a/crates/platform-db/Cargo.toml +++ b/crates/platform-db/Cargo.toml @@ -15,10 +15,14 @@ serde_json = { workspace = true } chrono = { workspace = true } [features] -# Embedded RocksDB backend (for solo mode) -embedded = ["surrealdb/kv-rocksdb"] -# In-process memory backend (for tests) +# In-process memory backend (tests only) memory = ["surrealdb/kv-mem"] +# Embedded SurrealKV: core relational/graph — orchestrator state, resource tracking +embedded-surrealkv = ["surrealdb/kv-surrealkv"] +# Embedded RocksDB: hot data — AI session state, audit logs, embedding caches +embedded-rocksdb = ["surrealdb/kv-rocksdb"] +# Remote via WebSocket (production multi-node) +remote = ["surrealdb/protocol-ws", "surrealdb/rustls"] default = ["memory"] diff --git a/crates/platform-db/src/config.rs b/crates/platform-db/src/config.rs index 97942fa..2f3671d 100644 --- a/crates/platform-db/src/config.rs +++ b/crates/platform-db/src/config.rs @@ -1,24 +1,45 @@ use serde::{Deserialize, Serialize}; +/// SurrealDB backend selector. +/// +/// | Variant | Engine | Use for | +/// |---------|--------|---------| +/// | `Memory` | kv-mem | Tests — non-persistent | +/// | `EmbeddedSurrealKV` | kv-surrealkv | Core relational/graph: orchestrator state, resource tracking | +/// | `EmbeddedRocksDB` | kv-rocksdb | Hot data: AI session state, audit logs, embedding caches | +/// | `Server` | protocol-ws | Remote SurrealDB via WebSocket (multi-node production) | #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(tag = "mode", rename_all = "snake_case")] pub enum DbConfig { #[default] Memory, - Embedded { + /// Embedded SurrealKV — requires feature `embedded-surrealkv`. + EmbeddedSurrealKV { + path: String, + }, + /// Embedded RocksDB — requires feature `embedded-rocksdb`. + EmbeddedRocksDB { path: String, }, Server { url: String, + #[serde(default)] + namespace: String, + #[serde(default)] + database: String, }, } impl DbConfig { - pub fn server(url: impl Into) -> Self { - Self::Server { url: url.into() } + pub fn server(url: impl Into, namespace: impl Into, database: impl Into) -> Self { + Self::Server { url: url.into(), namespace: namespace.into(), database: database.into() } } - pub fn embedded(path: impl Into) -> Self { - Self::Embedded { path: path.into() } + pub fn surrealkv(path: impl Into) -> Self { + Self::EmbeddedSurrealKV { path: path.into() } + } + + pub fn rocksdb(path: impl Into) -> Self { + Self::EmbeddedRocksDB { path: path.into() } } } diff --git a/crates/platform-db/src/lib.rs b/crates/platform-db/src/lib.rs index 5f69532..b48bd6b 100644 --- a/crates/platform-db/src/lib.rs +++ b/crates/platform-db/src/lib.rs @@ -28,6 +28,14 @@ pub type Result = std::result::Result; #[cfg(test)] mod tests { use super::*; + use surrealdb::types::{RecordId, SurrealValue}; + + #[derive(Debug, SurrealValue)] + #[surreal(crate = "surrealdb::types")] + struct Task { + id: Option, + name: String, + } #[tokio::test] async fn connect_memory_and_migrate() { @@ -39,15 +47,6 @@ mod tests { #[tokio::test] async fn roundtrip_insert_select() { - use serde::{Deserialize, Serialize}; - use surrealdb::RecordId; - - #[derive(Serialize, Deserialize)] - struct Task { - id: Option, - name: String, - } - let pool = SurrealPool::connect(&DbConfig::Memory) .await .expect("connect"); @@ -59,10 +58,7 @@ mod tests { let _: Vec = db .create("tasks") - .content(Task { - id: None, - name: "test-task".into(), - }) + .content(Task { id: None, name: "test-task".into() }) .await .expect("insert"); diff --git a/crates/platform-db/src/migrate.rs b/crates/platform-db/src/migrate.rs index 7f542b1..63e6486 100644 --- a/crates/platform-db/src/migrate.rs +++ b/crates/platform-db/src/migrate.rs @@ -89,5 +89,75 @@ pub async fn migrate(db: &Surreal) -> Result<(), Error> { .map_err(|e| Error::Migration(e.to_string()))?; info!(namespace = "workspace", "tables ready"); + // infra namespace — resource actual state + DAG graph + db.query("USE NS infra DB provisioning") + .await + .map_err(|e| Error::Migration(e.to_string()))?; + db.query( + r#" + -- Actual state of any provider resource (server, fip, network, firewall, volume, snapshot). + -- `desired` is option: populated when a full NCL reconcile runs; absent when the + -- bridge writes live state only (bootstrap trigger, drift detection). + DEFINE TABLE IF NOT EXISTS resource SCHEMAFULL; + DEFINE FIELD IF NOT EXISTS workspace ON resource TYPE string; + DEFINE FIELD IF NOT EXISTS kind ON resource TYPE string; + DEFINE FIELD IF NOT EXISTS name ON resource TYPE string; + DEFINE FIELD IF NOT EXISTS provider_id ON resource TYPE option; + DEFINE FIELD IF NOT EXISTS state ON resource TYPE string; + DEFINE FIELD IF NOT EXISTS desired ON resource TYPE option; + DEFINE FIELD IF NOT EXISTS actual ON resource TYPE option; + DEFINE FIELD IF NOT EXISTS created_at ON resource TYPE datetime VALUE $before OR time::now(); + DEFINE FIELD IF NOT EXISTS updated_at ON resource TYPE datetime VALUE time::now(); + DEFINE FIELD IF NOT EXISTS last_synced ON resource TYPE option; + DEFINE INDEX IF NOT EXISTS resource_ws_kind_name ON resource FIELDS workspace, kind, name UNIQUE; + + -- DAG structural definition (imported from dag.ncl at reconcile time) + DEFINE TABLE IF NOT EXISTS dag SCHEMAFULL; + DEFINE FIELD IF NOT EXISTS workspace ON dag TYPE string; + DEFINE FIELD IF NOT EXISTS name ON dag TYPE string; + DEFINE FIELD IF NOT EXISTS version ON dag TYPE string; + DEFINE FIELD IF NOT EXISTS created_at ON dag TYPE datetime VALUE $before OR time::now(); + DEFINE INDEX IF NOT EXISTS dag_ws_name ON dag FIELDS workspace, name UNIQUE; + + -- DAG node: one formula entry (populated from dag.ncl structural import) + DEFINE TABLE IF NOT EXISTS dag_node SCHEMAFULL; + DEFINE FIELD IF NOT EXISTS dag ON dag_node TYPE record; + DEFINE FIELD IF NOT EXISTS formula_id ON dag_node TYPE string; + DEFINE FIELD IF NOT EXISTS kind ON dag_node TYPE string; + DEFINE FIELD IF NOT EXISTS label ON dag_node TYPE string; + DEFINE INDEX IF NOT EXISTS dag_node_key ON dag_node FIELDS dag, formula_id UNIQUE; + + -- Dependency edges between dag nodes (graph relation for native traversal) + DEFINE TABLE IF NOT EXISTS node_depends_on SCHEMAFULL TYPE RELATION IN dag_node OUT dag_node; + + -- DAG run: one execution instance (triggered by rad patch, manual, reconcile, schedule). + -- Written by the NATS bridge on formula events — no `dag` record reference at write time; + -- the `workspace` field is the correlation key used until a full dag import is available. + DEFINE TABLE IF NOT EXISTS dag_run SCHEMAFULL; + DEFINE FIELD IF NOT EXISTS workspace ON dag_run TYPE string; + DEFINE FIELD IF NOT EXISTS actor ON dag_run TYPE string; + DEFINE FIELD IF NOT EXISTS trigger ON dag_run TYPE string; + DEFINE FIELD IF NOT EXISTS trigger_ref ON dag_run TYPE option; + DEFINE FIELD IF NOT EXISTS status ON dag_run TYPE string; + DEFINE FIELD IF NOT EXISTS started_at ON dag_run TYPE datetime VALUE $before OR time::now(); + DEFINE FIELD IF NOT EXISTS ended_at ON dag_run TYPE option; + + -- Step state: per-formula execution state within a run. + -- Written by the NATS formula event bridge; `workflow_id` + `formula_id` are string + -- identifiers from the orchestrator, not record references. + DEFINE TABLE IF NOT EXISTS step_state SCHEMAFULL; + DEFINE FIELD IF NOT EXISTS workflow_id ON step_state TYPE string; + DEFINE FIELD IF NOT EXISTS formula_id ON step_state TYPE string; + DEFINE FIELD IF NOT EXISTS status ON step_state TYPE string; + DEFINE FIELD IF NOT EXISTS started_at ON step_state TYPE option; + DEFINE FIELD IF NOT EXISTS ended_at ON step_state TYPE option; + DEFINE FIELD IF NOT EXISTS error ON step_state TYPE option; + DEFINE INDEX IF NOT EXISTS step_wf_formula ON step_state FIELDS workflow_id, formula_id UNIQUE; + "#, + ) + .await + .map_err(|e| Error::Migration(e.to_string()))?; + info!(namespace = "infra", "tables ready"); + Ok(()) } diff --git a/crates/platform-db/src/pool.rs b/crates/platform-db/src/pool.rs index 26a17e3..3b3658d 100644 --- a/crates/platform-db/src/pool.rs +++ b/crates/platform-db/src/pool.rs @@ -23,34 +23,54 @@ impl SurrealPool { .await .map_err(|e| Error::Connect(e.to_string()))? } - #[cfg(feature = "embedded")] - DbConfig::Embedded { path } => { - info!(path, "SurrealDB: embedded RocksDB backend"); + + #[cfg(feature = "embedded-surrealkv")] + DbConfig::EmbeddedSurrealKV { path } => { + info!(path, "SurrealDB: embedded SurrealKV backend (core/graph)"); + surrealdb::engine::any::connect(format!("surrealkv://{path}")) + .await + .map_err(|e| Error::Connect(e.to_string()))? + } + + #[cfg(feature = "embedded-rocksdb")] + DbConfig::EmbeddedRocksDB { path } => { + info!(path, "SurrealDB: embedded RocksDB backend (hot data)"); surrealdb::engine::any::connect(format!("rocksdb://{path}")) .await .map_err(|e| Error::Connect(e.to_string()))? } - DbConfig::Server { url } => { + + DbConfig::Server { url, .. } => { info!(url, "SurrealDB: WebSocket server backend"); surrealdb::engine::any::connect(url.as_str()) .await .map_err(|e| Error::Connect(e.to_string()))? } - #[cfg(not(feature = "embedded"))] - DbConfig::Embedded { .. } => { + + // Fallback for embedded variants when the feature is disabled. + #[cfg(not(feature = "embedded-surrealkv"))] + DbConfig::EmbeddedSurrealKV { .. } => { return Err(Error::Connect( - "embedded RocksDB requires the 'embedded' feature".into(), + "SurrealKV requires the 'embedded-surrealkv' feature".into(), + )); + } + #[cfg(not(feature = "embedded-rocksdb"))] + DbConfig::EmbeddedRocksDB { .. } => { + return Err(Error::Connect( + "RocksDB requires the 'embedded-rocksdb' feature".into(), )); } }; - // Sign in with root credentials for schema ops - db.signin(surrealdb::opt::auth::Root { - username: "root", - password: "root", - }) - .await - .map_err(|e| Error::Connect(e.to_string()))?; + // Only remote backends require signin — embedded engines have no auth subsystem. + if let DbConfig::Server { .. } = config { + db.signin(surrealdb::opt::auth::Root { + username: "root".to_string(), + password: "root".to_string(), + }) + .await + .map_err(|e| Error::Connect(e.to_string()))?; + } migrate(&db).await?; @@ -67,8 +87,9 @@ impl SurrealPool { /// Verify connectivity. pub async fn health(&self) -> Result<(), Error> { self.inner - .health() + .query("RETURN true") .await - .map_err(|e| Error::Health(e.to_string())) + .map_err(|e| Error::Health(e.to_string()))?; + Ok(()) } } diff --git a/crates/platform-nats/src/bridge.rs b/crates/platform-nats/src/bridge.rs index 5af2037..15f3f31 100644 --- a/crates/platform-nats/src/bridge.rs +++ b/crates/platform-nats/src/bridge.rs @@ -143,4 +143,23 @@ impl NatsBridge { pub fn jetstream(&self) -> &Context { &self.jetstream } + + /// Subscribe to a core NATS subject (not JetStream). + /// + /// Used by services that need push-based subscriptions for internal + /// coordination subjects not backed by JetStream persistence. + pub async fn subscribe(&self, subject: impl Into) -> Result { + self.client + .subscribe(subject.into()) + .await + .map_err(|e| Error::Connect(format!("subscribe failed: {e}"))) + } + + /// Publish a raw message to a core NATS subject (not JetStream). + pub async fn publish_raw(&self, subject: &str, payload: bytes::Bytes) -> Result<(), Error> { + self.client + .publish(subject.to_string(), payload) + .await + .map_err(|e| Error::Publish(e.to_string())) + } } diff --git a/crates/provisioning-core/Cargo.toml b/crates/provisioning-core/Cargo.toml new file mode 100644 index 0000000..49fef9f --- /dev/null +++ b/crates/provisioning-core/Cargo.toml @@ -0,0 +1,59 @@ +[package] +authors.workspace = true +edition.workspace = true +license.workspace = true +name = "provisioning-core" +repository.workspace = true +version.workspace = true +description = "Shared tool registry and protocol types for provisioning surfaces (CLI, HTTP, MCP)" + +[features] +default = [] +# Requires ../../../../stratumiops/crates/stratum-llm checkout adjacent to this repo. +# Add manually when implementing tools/ai.rs (plan B10): +# stratum-llm = { path = "../../../../stratumiops/crates/stratum-llm" } +ai = [] + +[dependencies] +# Serialization +serde = { workspace = true } +serde_json = { workspace = true } + +# Async +tokio = { workspace = true } +async-trait = { workspace = true } + +# Error handling +thiserror = { workspace = true } +anyhow = { workspace = true } + +# Logging +tracing = { workspace = true } + +# HTTP client — for orchestrator source (B4) +reqwest = { workspace = true } + +# Platform config (workspace dep) +platform-config = { workspace = true } + +# Service clients — SSH via machines::execute_command (B5) +platform-clients = { workspace = true } + +# Vault (B5) — path dep, not yet in workspace.dependencies +vault-service = { path = "../vault-service" } + +# Extension registry (B5) — path dep +extension-registry = { path = "../extension-registry" } + +# Concurrent cache (parking_lot is lighter than std::sync for short critical sections) +parking_lot = { workspace = true } +chrono = { workspace = true } +serde_yaml = { workspace = true } +dirs = { workspace = true } +hex = { workspace = true } +toml = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } +mockito = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/provisioning-core/src/lib.rs b/crates/provisioning-core/src/lib.rs new file mode 100644 index 0000000..592b324 --- /dev/null +++ b/crates/provisioning-core/src/lib.rs @@ -0,0 +1,18 @@ +//! Shared tool registry and protocol types consumed by CLI, HTTP daemon, and MCP server. +//! +//! Surfaces (provisioning-tool, provisioning-daemon, mcp-server) are thin adapters +//! over this library — all domain logic lives here. + +pub mod protocol; +pub mod providers; +pub mod registry; +pub mod sources; +pub mod tool; +pub mod tools; + +pub use protocol::{ + Environment, ErrorResponse, ItemResponse, Listing, SmartResponse, ToolCategory, ToolError, + UserContext, +}; +pub use registry::{Registry, RegistryError}; +pub use tool::{Context, Tool, ToolMetadata}; diff --git a/crates/provisioning-core/src/protocol.rs b/crates/provisioning-core/src/protocol.rs new file mode 100644 index 0000000..e647e04 --- /dev/null +++ b/crates/provisioning-core/src/protocol.rs @@ -0,0 +1,253 @@ +//! Wire types shared across all provisioning surfaces (CLI, HTTP, MCP). + +use serde::{Deserialize, Serialize}; + +// ── Pagination ──────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize, Deserialize)] +pub struct Listing { + pub items: Vec, + pub total: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +impl Listing { + pub fn new(items: Vec) -> Self { + let total = items.len(); + Self { items, total, cursor: None } + } + + pub fn with_cursor(items: Vec, total: usize, cursor: Option) -> Self { + Self { items, total, cursor } + } +} + +// ── Single resource ─────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize, Deserialize)] +pub struct ItemResponse { + #[serde(flatten)] + pub inner: T, +} + +impl ItemResponse { + pub fn new(inner: T) -> Self { + Self { inner } + } +} + +impl From for ItemResponse { + fn from(inner: T) -> Self { + Self { inner } + } +} + +// ── Errors ──────────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize, Deserialize, thiserror::Error)] +#[error("{error} [{code}]")] +pub struct ErrorResponse { + pub error: String, + pub code: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub hint: Option, +} + +impl ErrorResponse { + pub fn new(code: impl Into, error: impl Into) -> Self { + Self { error: error.into(), code: code.into(), hint: None } + } + + pub fn with_hint(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } +} + +// ── Tool errors ─────────────────────────────────────────────────────────────── + +#[derive(Debug, thiserror::Error)] +pub enum ToolError { + #[error("invalid parameter '{param}': {reason}")] + InvalidParam { param: String, reason: String }, + + #[error("backend error: {0}")] + Backend(#[from] anyhow::Error), + + #[error("unauthorized: {0}")] + Unauthorized(String), + + #[error("not found: {0}")] + NotFound(String), + + #[error("tool invocation failed: {0}")] + Invocation(String), +} + +impl ToolError { + pub fn invalid_param(param: impl Into, reason: impl Into) -> Self { + Self::InvalidParam { param: param.into(), reason: reason.into() } + } + + pub fn not_found(what: impl Into) -> Self { + Self::NotFound(what.into()) + } + + pub fn invocation(msg: impl Into) -> Self { + Self::Invocation(msg.into()) + } + + pub fn to_error_response(&self) -> ErrorResponse { + let (code, hint) = match self { + Self::InvalidParam { .. } => ("INVALID_PARAM", None), + Self::Backend(_) => ("BACKEND_ERROR", None), + Self::Unauthorized(_) => ("UNAUTHORIZED", Some("check your credentials or role")), + Self::NotFound(_) => ("NOT_FOUND", None), + Self::Invocation(_) => ("INVOCATION_ERROR", None), + }; + let mut resp = ErrorResponse::new(code, self.to_string()); + if let Some(h) = hint { + resp = resp.with_hint(h); + } + resp + } +} + +// ── Tool taxonomy ───────────────────────────────────────────────────────────── + +/// Used by auth middleware (RBAC) to gate access without cedar. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolCategory { + /// Safe reads — allowed to all authenticated roles. + Read, + /// State-changing but reversible operations (create, deploy). + Mutation, + /// Irreversible operations (delete, unseal, destroy). + Destructive, + /// Platform admin operations (config reload, policy change). + Admin, +} + +impl ToolCategory { + /// Whether this category requires at least `operator` role. + pub fn requires_operator(self) -> bool { + matches!(self, Self::Mutation | Self::Destructive | Self::Admin) + } + + /// Whether this category requires `admin` role exclusively. + pub fn requires_admin(self) -> bool { + matches!(self, Self::Destructive | Self::Admin) + } +} + +// ── Auth context ────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserContext { + pub user_id: String, + pub roles: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tenant: Option, +} + +impl UserContext { + pub fn has_role(&self, role: &str) -> bool { + self.roles.iter().any(|r| r == role) + } + + pub fn is_admin(&self) -> bool { + self.has_role("admin") + } + + pub fn is_operator(&self) -> bool { + self.has_role("operator") || self.is_admin() + } +} + +/// Execution environment passed to every tool invocation. +#[derive(Debug, Clone, Default)] +pub struct Environment { + /// Base URL of the running orchestrator HTTP API, e.g. `http://localhost:9011`. + pub orchestrator_url: String, + /// Root path of provisioning workspaces on disk. + pub workspaces_root: std::path::PathBuf, + /// Nickel cache directory (ncl-sync output). + pub ncl_cache_dir: std::path::PathBuf, + /// Root of the extensions tree (`provisioning/extensions/`). + pub extensions_root: std::path::PathBuf, + /// Root of the project-level ontology (`.ontology/` relative to project root). + pub ontology_root: std::path::PathBuf, +} + +// ── Human-readable summary ──────────────────────────────────────────────────── + +/// Implemented by response types to produce a one-line human-readable summary. +pub trait SmartResponse { + fn summary(&self) -> String; +} + +impl SmartResponse for Listing { + fn summary(&self) -> String { + if self.items.is_empty() { + return "no items".to_owned(); + } + let first = self.items[0].summary(); + if self.total == 1 { + return first; + } + format!("{first} (+{} more)", self.total - 1) + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn listing_roundtrip() { + let l: Listing = Listing::new(vec!["a".to_owned(), "b".to_owned()]); + let json = serde_json::to_string(&l).unwrap(); + let parsed: Listing = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.total, 2); + assert_eq!(parsed.items, vec!["a", "b"]); + } + + #[test] + fn error_response_hint() { + let e = ErrorResponse::new("NOT_FOUND", "workspace foo not found") + .with_hint("run 'provisioning workspace register'"); + let json = serde_json::to_string(&e).unwrap(); + assert!(json.contains("hint")); + } + + #[test] + fn tool_error_to_response_unauthorized() { + let e = ToolError::Unauthorized("read-only role".to_owned()); + let r = e.to_error_response(); + assert_eq!(r.code, "UNAUTHORIZED"); + assert!(r.hint.is_some()); + } + + #[test] + fn tool_category_rbac() { + assert!(!ToolCategory::Read.requires_operator()); + assert!(ToolCategory::Mutation.requires_operator()); + assert!(ToolCategory::Destructive.requires_admin()); + assert!(!ToolCategory::Mutation.requires_admin()); + } + + #[test] + fn user_context_roles() { + let u = UserContext { + user_id: "u1".to_owned(), + roles: vec!["operator".to_owned()], + tenant: None, + }; + assert!(u.is_operator()); + assert!(!u.is_admin()); + } +} diff --git a/crates/provisioning-core/src/providers/generic_cli.rs b/crates/provisioning-core/src/providers/generic_cli.rs new file mode 100644 index 0000000..cb22c25 --- /dev/null +++ b/crates/provisioning-core/src/providers/generic_cli.rs @@ -0,0 +1,167 @@ +//! Async CLI runner — subprocess with timeout, stdout/stderr capture, JSON parse. +//! +//! All provider wrappers (hcloud, aws, kubectl, …) delegate here rather than +//! invoking `tokio::process::Command` directly. + +use crate::protocol::ToolError; +use std::collections::HashMap; +use std::time::Duration; +use tokio::process::Command; +use tokio::time::timeout; + +/// Default per-invocation timeout. Provider wrappers may pass a shorter value +/// for read-only list/describe calls and a longer one for mutations. +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +/// Result of a subprocess invocation. +#[derive(Debug, Clone)] +pub struct CliOutput { + pub stdout: String, + pub stderr: String, + pub exit_code: i32, +} + +impl CliOutput { + pub fn success(&self) -> bool { + self.exit_code == 0 + } + + /// Parse stdout as JSON. Call after verifying `success()`. + pub fn parse_json(&self) -> Result { + serde_json::from_str(&self.stdout).map_err(|e| { + ToolError::invocation(format!("CLI output is not valid JSON: {e}\nstdout: {}", self.stdout.chars().take(200).collect::())) + }) + } +} + +/// Run `binary` with `args` and optional extra environment variables. +/// +/// Returns `ToolError::Invocation` on timeout, spawn failure, or non-zero exit. +/// Returns `ToolError::NotFound` when the binary is absent from PATH. +pub async fn run( + binary: &str, + args: &[&str], + env: &HashMap, + deadline: Duration, +) -> Result { + let mut cmd = Command::new(binary); + cmd.args(args).envs(env).kill_on_drop(true); + + let task = cmd.output(); + let result = timeout(deadline, task).await.map_err(|_| { + ToolError::invocation(format!( + "{binary} timed out after {}s (args: {})", + deadline.as_secs(), + args.join(" ") + )) + })?; + + let output = result.map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + ToolError::not_found(format!("binary '{binary}' not found in PATH")) + } else { + ToolError::invocation(format!("failed to spawn '{binary}': {e}")) + } + })?; + + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + let exit_code = output.status.code().unwrap_or(-1); + + if !output.status.success() { + return Err(ToolError::invocation(format!( + "{binary} {}: exit {exit_code}\n{stderr}", + args.join(" "), + ))); + } + + Ok(CliOutput { stdout, stderr, exit_code }) +} + +/// Convenience wrapper: run and parse stdout as JSON in one call. +pub async fn run_json( + binary: &str, + args: &[&str], + env: &HashMap, + deadline: Duration, +) -> Result { + run(binary, args, env, deadline).await?.parse_json() +} + +/// Build args list from a base + optional flag pairs. Skips `None` values. +/// +/// Example: `args_with_flags("hcloud", &["server", "create"], &[("--name", Some("web1")), ("--type", None)])` +/// → `["server", "create", "--name", "web1"]` +pub fn args_with_flags<'a>( + base: &[&'a str], + flags: &[(&'a str, Option<&'a str>)], +) -> Vec<&'a str> { + let mut out: Vec<&str> = base.to_vec(); + for (flag, val) in flags { + if let Some(v) = val { + out.push(flag); + out.push(v); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn no_env() -> HashMap { + HashMap::new() + } + + #[tokio::test] + async fn run_echo_returns_output() { + let out = run("echo", &["hello"], &no_env(), DEFAULT_TIMEOUT).await.unwrap(); + assert!(out.success()); + assert!(out.stdout.contains("hello")); + } + + #[tokio::test] + async fn missing_binary_returns_not_found() { + let err = run("__nonexistent_binary__", &[], &no_env(), DEFAULT_TIMEOUT) + .await + .unwrap_err(); + assert!(matches!(err, ToolError::NotFound(_))); + } + + #[tokio::test] + async fn nonzero_exit_returns_invocation_error() { + let err = run("false", &[], &no_env(), DEFAULT_TIMEOUT).await.unwrap_err(); + assert!(matches!(err, ToolError::Invocation(_))); + } + + #[tokio::test] + async fn timeout_returns_invocation_error() { + // `sleep 5` will be killed after 50ms + let err = run("sleep", &["5"], &no_env(), Duration::from_millis(50)) + .await + .unwrap_err(); + assert!(matches!(err, ToolError::Invocation(_))); + } + + #[tokio::test] + async fn run_json_parses_echo_json() { + let val = run_json( + "echo", + &[r#"{"ok":true}"#], + &no_env(), + DEFAULT_TIMEOUT, + ) + .await + .unwrap(); + assert_eq!(val["ok"], true); + } + + #[test] + fn args_with_flags_skips_none() { + let base = &["server", "create"]; + let flags = &[("--name", Some("web1")), ("--image", None), ("--type", Some("cx21"))]; + let result = args_with_flags(base, flags); + assert_eq!(result, &["server", "create", "--name", "web1", "--type", "cx21"]); + } +} diff --git a/crates/provisioning-core/src/providers/hcloud.rs b/crates/provisioning-core/src/providers/hcloud.rs new file mode 100644 index 0000000..590959b --- /dev/null +++ b/crates/provisioning-core/src/providers/hcloud.rs @@ -0,0 +1,240 @@ +//! Hetzner Cloud CLI wrapper — thin typed API over `hcloud --output json`. +//! +//! All reads use a 15s timeout; mutations use 60s. +//! The `HCLOUD_TOKEN` env var is forwarded transparently — callers may override +//! by passing a token explicitly to `HcloudClient::with_token`. + +use crate::protocol::ToolError; +use crate::providers::generic_cli::run_json; +use std::collections::HashMap; +use std::time::Duration; + +const READ_TIMEOUT: Duration = Duration::from_secs(15); +const MUTATE_TIMEOUT: Duration = Duration::from_secs(60); + +/// Client for the `hcloud` CLI. The token is injected via `HCLOUD_TOKEN` env var. +#[derive(Debug, Clone)] +pub struct HcloudClient { + binary: String, + env: HashMap, +} + +impl HcloudClient { + /// Use `hcloud` from PATH with `HCLOUD_TOKEN` inherited from the process environment. + pub fn new() -> Self { + Self { binary: "hcloud".into(), env: HashMap::new() } + } + + /// Override the token — useful for per-workspace credentials. + pub fn with_token(mut self, token: impl Into) -> Self { + self.env.insert("HCLOUD_TOKEN".into(), token.into()); + self + } + + /// Use a different binary path (e.g. for tests via `HCLOUD_BINARY=echo`). + pub fn with_binary(mut self, binary: impl Into) -> Self { + self.binary = binary.into(); + self + } + + // ── Servers ─────────────────────────────────────────────────────────────── + + pub async fn server_list(&self) -> Result { + self.read_json(&["server", "list", "--output", "json"]).await + } + + pub async fn server_describe(&self, name: &str) -> Result { + self.read_json(&["server", "describe", name, "--output", "json"]).await + } + + pub async fn server_poweron(&self, name: &str) -> Result<(), ToolError> { + self.mutate(&["server", "poweron", name]).await + } + + pub async fn server_shutdown(&self, name: &str) -> Result<(), ToolError> { + self.mutate(&["server", "shutdown", name]).await + } + + pub async fn server_delete(&self, name: &str) -> Result<(), ToolError> { + self.mutate(&["server", "disable-protection", name, "delete", "rebuild"]) + .await?; + self.mutate(&["server", "delete", name]).await + } + + pub async fn server_change_type( + &self, + name: &str, + server_type: &str, + ) -> Result<(), ToolError> { + self.mutate(&["server", "change-type", name, server_type]).await + } + + // ── Volumes ─────────────────────────────────────────────────────────────── + + pub async fn volume_list(&self) -> Result { + self.read_json(&["volume", "list", "--output", "json"]).await + } + + pub async fn volume_describe(&self, name: &str) -> Result { + self.read_json(&["volume", "describe", name, "--output", "json"]).await + } + + pub async fn volume_create( + &self, + name: &str, + size_gb: u32, + location: &str, + format: Option<&str>, + ) -> Result { + let size_str = size_gb.to_string(); + let mut args = vec![ + "volume", "create", + "--name", name, + "--size", &size_str, + "--location", location, + "--output", "json", + ]; + if let Some(fmt) = format { + args.push("--format"); + args.push(fmt); + } + self.mutate_json(&args).await + } + + pub async fn volume_attach(&self, volume: &str, server: &str) -> Result<(), ToolError> { + self.mutate(&["volume", "attach", volume, "--server", server]).await + } + + pub async fn volume_detach(&self, volume: &str) -> Result<(), ToolError> { + self.mutate(&["volume", "detach", volume]).await + } + + pub async fn volume_delete(&self, name: &str) -> Result<(), ToolError> { + self.mutate(&["volume", "disable-protection", name, "delete"]) + .await?; + self.mutate(&["volume", "delete", name]).await + } + + // ── Floating IPs ────────────────────────────────────────────────────────── + + pub async fn floating_ip_list(&self) -> Result { + self.read_json(&["floating-ip", "list", "--output", "json"]).await + } + + pub async fn floating_ip_describe(&self, name: &str) -> Result { + self.read_json(&["floating-ip", "describe", name, "--output", "json"]).await + } + + pub async fn floating_ip_unassign(&self, name: &str) -> Result<(), ToolError> { + self.mutate(&["floating-ip", "unassign", name]).await + } + + pub async fn floating_ip_delete(&self, name: &str) -> Result<(), ToolError> { + self.mutate(&["floating-ip", "disable-protection", name, "delete"]) + .await?; + self.mutate(&["floating-ip", "delete", name]).await + } + + // ── Networks ────────────────────────────────────────────────────────────── + + pub async fn network_describe(&self, name: &str) -> Result { + self.read_json(&["network", "describe", name, "--output", "json"]).await + } + + // ── Firewalls ───────────────────────────────────────────────────────────── + + pub async fn firewall_describe(&self, name: &str) -> Result { + self.read_json(&["firewall", "describe", name, "--output", "json"]).await + } + + // ── Images ──────────────────────────────────────────────────────────────── + + pub async fn image_list(&self, image_type: Option<&str>) -> Result { + let mut args = vec!["image", "list", "--output", "json"]; + if let Some(t) = image_type { + args.push("--type"); + args.push(t); + } + self.read_json(&args).await + } + + pub async fn image_delete(&self, id: &str) -> Result<(), ToolError> { + self.mutate(&["image", "delete", id]).await + } + + // ── Locations / Datacenters ─────────────────────────────────────────────── + + pub async fn location_list(&self) -> Result { + self.read_json(&["location", "list", "--output", "json"]).await + } + + pub async fn datacenter_list(&self) -> Result { + self.read_json(&["datacenter", "list", "--output", "json"]).await + } + + // ── Contexts ────────────────────────────────────────────────────────────── + + pub async fn context_list(&self) -> Result { + self.read_json(&["context", "list", "--output", "json"]).await + } + + // ── Internal ────────────────────────────────────────────────────────────── + + async fn read_json(&self, args: &[&str]) -> Result { + run_json(&self.binary, args, &self.env, READ_TIMEOUT).await + } + + async fn mutate(&self, args: &[&str]) -> Result<(), ToolError> { + run_json(&self.binary, args, &self.env, MUTATE_TIMEOUT).await.map(|_| ()) + } + + async fn mutate_json(&self, args: &[&str]) -> Result { + run_json(&self.binary, args, &self.env, MUTATE_TIMEOUT).await + } +} + +impl Default for HcloudClient { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Return a client that uses `echo` as its binary. + /// `hcloud server list --output json` becomes `echo server list --output json`. + /// Echo prints its args as-is — not valid JSON, so we test error paths; + /// JSON-success paths require integration with a real/mock hcloud. + fn echo_client() -> HcloudClient { + HcloudClient::new().with_binary("echo") + } + + #[tokio::test] + async fn server_list_with_echo_fails_json_parse() { + let client = echo_client(); + let err = client.server_list().await.unwrap_err(); + // echo prints plain text → JSON parse fails with Invocation + assert!(matches!(err, ToolError::Invocation(_))); + } + + #[tokio::test] + async fn missing_hcloud_binary_returns_not_found() { + let client = HcloudClient::new().with_binary("__no_hcloud__"); + let err = client.server_list().await.unwrap_err(); + assert!(matches!(err, ToolError::NotFound(_))); + } + + #[tokio::test] + async fn with_token_sets_env() { + let client = HcloudClient::new().with_token("tok-test"); + assert_eq!(client.env.get("HCLOUD_TOKEN").map(|s| s.as_str()), Some("tok-test")); + } + + #[test] + fn default_binary_is_hcloud() { + let c = HcloudClient::new(); + assert_eq!(c.binary, "hcloud"); + } +} diff --git a/crates/provisioning-core/src/providers/mod.rs b/crates/provisioning-core/src/providers/mod.rs new file mode 100644 index 0000000..c6e5fae --- /dev/null +++ b/crates/provisioning-core/src/providers/mod.rs @@ -0,0 +1,11 @@ +//! Provider adapters — thin wrappers over external CLI tools and APIs. +//! +//! Each module targets one external system. All I/O goes through +//! `generic_cli::run[_json]` so timeout enforcement and error mapping are +//! consistent across providers. + +pub mod generic_cli; +pub mod hcloud; + +pub use generic_cli::{CliOutput, args_with_flags, run, run_json, DEFAULT_TIMEOUT}; +pub use hcloud::HcloudClient; diff --git a/crates/provisioning-core/src/registry.rs b/crates/provisioning-core/src/registry.rs new file mode 100644 index 0000000..71eabb0 --- /dev/null +++ b/crates/provisioning-core/src/registry.rs @@ -0,0 +1,446 @@ +//! `Registry` — central dispatch table mapping tool names to their implementations. +//! +//! Build-time workflow: +//! 1. Construct `Registry::new()`. +//! 2. Call `register(Arc::new(MyTool))` once per tool — duplicates fail. +//! 3. Wrap in `Arc` and share across async handlers. +//! +//! Dispatch-time workflow: +//! - Surfaces call `invoke(name, params, ctx)` to execute. +//! - `list()` / `get()` / `schema_of()` power `tools/list` and schema endpoints. +//! +//! RBAC is NOT enforced here — callers (HTTP middleware) gate on +//! `UserContext::is_operator`/`is_admin` vs `ToolCategory`. Keeping the Registry +//! dumb lets unauthenticated surfaces (local CLI, MCP stdio) reuse it verbatim. + +use crate::protocol::ToolError; +use crate::providers::hcloud::HcloudClient; +use crate::sources::{NclCache, OrchestratorClient, VaultSource}; +use crate::tool::{Tool, ToolMetadata}; +use crate::tools::{ + agents::{AgentList, AgentRun, AgentStatus}, + cluster::{ClusterCreate, ClusterDescribePod, ClusterList, ClusterPods, ClusterShow}, + component::{ComponentList, ComponentShow}, + dag::{DagExport, DagShow, DagValidate}, + dashboard::{DashboardAudit, DashboardServices, DashboardStatistics, DashboardWorkflows}, + extension::{ExtensionCapabilities, ExtensionList, ExtensionSearch, ExtensionShow}, + guidance::{ + GuidanceConfigValidate, GuidanceDocFind, GuidanceNextAction, GuidanceSystemStatus, + GuidanceTroubleshoot, + }, + infra::{InfraDetect, InfraReconcile, InfraStatus, InfraValidate}, + installer::{InstallerSettingsDefaults, InstallerSettingsGet, InstallerSettingsValidate}, + monitoring::{MonitoringHealth, MonitoringMetrics, MonitoringStatistics}, + ontology::{OntologyGraph, OntologyNode, OntologySearch}, + orchestrator::{ + OrchestratorJobCancel, OrchestratorJobShow, OrchestratorJobs, OrchestratorRunWorkflow, + }, + server::{ServerCreate, ServerDelete, ServerList, ServerShow}, + taskserv::TaskservDeploy, + vault::{VaultGet, VaultList, VaultSealStatus, VaultUnseal}, + volume::{VolumeList, VolumeShow}, + workspace::{ + WorkspaceActive, WorkspaceDag, WorkspaceList, WorkspaceRegister, WorkspaceShow, + WorkspaceValidate, + }, +}; +use std::collections::HashMap; +use std::sync::Arc; + +#[derive(Debug, thiserror::Error)] +pub enum RegistryError { + #[error("tool '{0}' already registered")] + Duplicate(&'static str), +} + +pub struct Registry { + tools: HashMap<&'static str, Arc>, + meta: HashMap<&'static str, ToolMetadata>, +} + +impl Registry { + pub fn new() -> Self { + Self { tools: HashMap::new(), meta: HashMap::new() } + } + + /// Register a tool. Fails if `name()` collides with a previously registered one. + /// + /// `schema()` is invoked exactly once here and cached in an `Arc` so that + /// `tools/list` and schema endpoints pay zero serialization cost per request. + pub fn register(&mut self, tool: Arc) -> Result<(), RegistryError> { + let name = tool.name(); + if self.tools.contains_key(name) { + return Err(RegistryError::Duplicate(name)); + } + let meta = ToolMetadata { + name, + description: tool.description(), + category: tool.category(), + schema: Arc::new(tool.schema()), + }; + self.tools.insert(name, tool); + self.meta.insert(name, meta); + Ok(()) + } + + pub fn len(&self) -> usize { + self.tools.len() + } + + pub fn is_empty(&self) -> bool { + self.tools.is_empty() + } + + pub fn get(&self, name: &str) -> Option> { + self.tools.get(name).cloned() + } + + pub fn metadata(&self, name: &str) -> Option<&ToolMetadata> { + self.meta.get(name) + } + + pub fn schema_of(&self, name: &str) -> Option> { + self.meta.get(name).map(|m| Arc::clone(&m.schema)) + } + + /// Return all tool metadata sorted alphabetically by name. + /// + /// Stable ordering matters for deterministic `tools/list` output and for + /// the G3 contract test (CLI↔HTTP↔MCP diff). + pub fn list(&self) -> Vec<&ToolMetadata> { + let mut items: Vec<&ToolMetadata> = self.meta.values().collect(); + items.sort_by_key(|m| m.name); + items + } + + /// Dispatch. Does not perform authorization — callers handle that. + pub async fn invoke( + &self, + name: &str, + params: serde_json::Value, + ctx: &crate::tool::Context, + ) -> Result { + let tool = self + .get(name) + .ok_or_else(|| ToolError::not_found(format!("tool '{name}'")))?; + tool.invoke(params, ctx).await + } +} + +impl Default for Registry { + fn default() -> Self { + Self::new() + } +} + +impl Registry { + /// Construct a `Registry` pre-loaded with every tool implementation. + /// + /// `vault` is optional — surfaces that start without a configured KMS backend + /// (local dev, CI) pass `None`; vault tools are skipped in that case. + pub fn with_all_tools( + cache: Arc, + orch: Arc, + hcloud: Arc, + vault: Option>, + ) -> Result { + let mut reg = Registry::new(); + + // agents (3) + reg.register(Arc::new(AgentList::new(Arc::clone(&orch))))?; + reg.register(Arc::new(AgentStatus::new(Arc::clone(&orch))))?; + reg.register(Arc::new(AgentRun::new()))?; + + // cluster (5) + reg.register(Arc::new(ClusterList::new(Arc::clone(&cache))))?; + reg.register(Arc::new(ClusterShow::new(Arc::clone(&cache))))?; + reg.register(Arc::new(ClusterCreate::new()))?; + reg.register(Arc::new(ClusterPods::new()))?; + reg.register(Arc::new(ClusterDescribePod::new()))?; + + // component (2) + reg.register(Arc::new(ComponentList::new(Arc::clone(&cache))))?; + reg.register(Arc::new(ComponentShow::new(Arc::clone(&cache))))?; + + // dag (3) + reg.register(Arc::new(DagShow::new(Arc::clone(&cache))))?; + reg.register(Arc::new(DagValidate::new(Arc::clone(&cache))))?; + reg.register(Arc::new(DagExport::new(Arc::clone(&cache))))?; + + // dashboard (4) + reg.register(Arc::new(DashboardServices::new()))?; + reg.register(Arc::new(DashboardWorkflows::new(Arc::clone(&orch))))?; + reg.register(Arc::new(DashboardAudit::new()))?; + reg.register(Arc::new(DashboardStatistics::new(Arc::clone(&orch))))?; + + // extension (4) + reg.register(Arc::new(ExtensionList::new(Arc::clone(&cache))))?; + reg.register(Arc::new(ExtensionShow::new(Arc::clone(&cache))))?; + reg.register(Arc::new(ExtensionSearch::new(Arc::clone(&cache))))?; + reg.register(Arc::new(ExtensionCapabilities::new(Arc::clone(&cache))))?; + + // guidance (5) + reg.register(Arc::new(GuidanceSystemStatus::new(Arc::clone(&orch))))?; + reg.register(Arc::new(GuidanceDocFind::new()))?; + reg.register(Arc::new(GuidanceNextAction::new(Arc::clone(&orch))))?; + let mut troubleshoot = GuidanceTroubleshoot::new(Arc::clone(&orch)); + if let Some(v) = &vault { + troubleshoot = troubleshoot.with_vault(Arc::clone(v)); + } + reg.register(Arc::new(troubleshoot))?; + reg.register(Arc::new(GuidanceConfigValidate::new(Arc::clone(&cache))))?; + + // infra (4) + reg.register(Arc::new(InfraStatus::new(Arc::clone(&orch))))?; + reg.register(Arc::new(InfraDetect::new()))?; + reg.register(Arc::new(InfraValidate::new(Arc::clone(&cache))))?; + reg.register(Arc::new(InfraReconcile::new()))?; + + // installer (3) + reg.register(Arc::new(InstallerSettingsGet::new()))?; + reg.register(Arc::new(InstallerSettingsValidate::new()))?; + reg.register(Arc::new(InstallerSettingsDefaults::new()))?; + + // monitoring (3) + reg.register(Arc::new(MonitoringMetrics::new(Arc::clone(&orch))))?; + reg.register(Arc::new(MonitoringHealth::new(Arc::clone(&orch))))?; + reg.register(Arc::new(MonitoringStatistics::new()))?; + + // ontology (3) + reg.register(Arc::new(OntologySearch::new(Arc::clone(&cache))))?; + reg.register(Arc::new(OntologyNode::new(Arc::clone(&cache))))?; + reg.register(Arc::new(OntologyGraph::new(Arc::clone(&cache))))?; + + // orchestrator (4) + reg.register(Arc::new(OrchestratorJobs::new(Arc::clone(&orch))))?; + reg.register(Arc::new(OrchestratorJobShow::new(Arc::clone(&orch))))?; + reg.register(Arc::new(OrchestratorJobCancel::new()))?; + reg.register(Arc::new(OrchestratorRunWorkflow::new()))?; + + // server (4) + reg.register(Arc::new(ServerList::new(Arc::clone(&hcloud))))?; + reg.register(Arc::new(ServerShow::new(Arc::clone(&hcloud))))?; + reg.register(Arc::new(ServerCreate::new()))?; + reg.register(Arc::new(ServerDelete::new(Arc::clone(&hcloud))))?; + + // volume (2) + reg.register(Arc::new(VolumeList::new(Arc::clone(&hcloud))))?; + reg.register(Arc::new(VolumeShow::new(Arc::clone(&hcloud))))?; + + // taskserv (1) + reg.register(Arc::new(TaskservDeploy::new()))?; + + // vault (4) — only when backend is available + if let Some(v) = vault { + reg.register(Arc::new(VaultSealStatus::new(Arc::clone(&v))))?; + reg.register(Arc::new(VaultList::new(Arc::clone(&v))))?; + reg.register(Arc::new(VaultGet::new(Arc::clone(&v))))?; + reg.register(Arc::new(VaultUnseal::new(Arc::clone(&v))))?; + } + + // workspace (6) + reg.register(Arc::new(WorkspaceList))?; + reg.register(Arc::new(WorkspaceShow::new(Arc::clone(&cache))))?; + reg.register(Arc::new(WorkspaceActive))?; + reg.register(Arc::new(WorkspaceRegister))?; + reg.register(Arc::new(WorkspaceDag::new(Arc::clone(&cache))))?; + reg.register(Arc::new(WorkspaceValidate::new(Arc::clone(&cache))))?; + + Ok(reg) + } +} + +impl std::fmt::Debug for Registry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Registry") + .field("tool_count", &self.tools.len()) + .field("tools", &self.meta.keys().collect::>()) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::{Environment, ToolCategory}; + use crate::tool::test_support::{EchoTool, FailingTool}; + use crate::tool::Context; + use serde_json::json; + + fn ctx() -> Context { + Context::new(Arc::new(Environment::default())) + } + + #[test] + fn register_and_list() { + let mut reg = Registry::new(); + reg.register(Arc::new(EchoTool)).unwrap(); + reg.register(Arc::new(FailingTool)).unwrap(); + let list = reg.list(); + assert_eq!(list.len(), 2); + assert_eq!(list[0].name, "echo"); + assert_eq!(list[1].name, "failing"); + assert_eq!(list[1].category, ToolCategory::Mutation); + } + + #[test] + fn duplicate_registration_fails() { + let mut reg = Registry::new(); + reg.register(Arc::new(EchoTool)).unwrap(); + let err = reg.register(Arc::new(EchoTool)).unwrap_err(); + assert!(matches!(err, RegistryError::Duplicate("echo"))); + } + + #[test] + fn schema_of_present_and_absent() { + let mut reg = Registry::new(); + reg.register(Arc::new(EchoTool)).unwrap(); + let s = reg.schema_of("echo").unwrap(); + assert_eq!(s["type"], "object"); + assert!(reg.schema_of("nonexistent").is_none()); + } + + #[tokio::test] + async fn invoke_unknown_returns_not_found() { + let reg = Registry::new(); + let err = reg.invoke("missing", json!({}), &ctx()).await.unwrap_err(); + assert!(matches!(err, ToolError::NotFound(_))); + } + + #[tokio::test] + async fn invoke_echo_returns_params() { + let mut reg = Registry::new(); + reg.register(Arc::new(EchoTool)).unwrap(); + let input = json!({"x": 1, "y": "z"}); + let out = reg.invoke("echo", input.clone(), &ctx()).await.unwrap(); + assert_eq!(out, input); + } + + #[tokio::test] + async fn invoke_failing_bubbles_error() { + let mut reg = Registry::new(); + reg.register(Arc::new(FailingTool)).unwrap(); + let err = reg.invoke("failing", json!({}), &ctx()).await.unwrap_err(); + assert!(matches!(err, ToolError::NotFound(_))); + } + + #[tokio::test] + async fn shared_registry_across_tasks() { + let mut reg = Registry::new(); + reg.register(Arc::new(EchoTool)).unwrap(); + let reg = Arc::new(reg); + let handles: Vec<_> = (0..8) + .map(|i| { + let reg = Arc::clone(®); + tokio::spawn(async move { + let out = reg + .invoke("echo", json!({"i": i}), &ctx()) + .await + .unwrap(); + assert_eq!(out["i"], i); + }) + }) + .collect(); + for h in handles { + h.await.unwrap(); + } + } +} + +// ── with_all_tools integration tests ───────────────────────────────────────── + +#[cfg(test)] +mod integration { + use super::*; + use crate::protocol::ToolCategory; + use crate::sources::NclCache; + + fn make_registry() -> Registry { + let cache = Arc::new(NclCache::new()); + let orch = Arc::new(OrchestratorClient::new("http://localhost:19999")); + let hcloud = Arc::new(HcloudClient::new()); + Registry::with_all_tools(cache, orch, hcloud, None).expect("with_all_tools failed") + } + + #[test] + fn with_all_tools_no_vault_registers_56_tools() { + let reg = make_registry(); + // 60 total - 4 vault = 56 when vault = None + assert_eq!(reg.len(), 56, "expected 56 tools (no vault); got {}", reg.len()); + } + + #[test] + fn all_tool_names_follow_domain_action_pattern() { + let reg = make_registry(); + for meta in reg.list() { + assert!( + meta.name.contains('_'), + "tool '{}' does not follow _ naming", + meta.name + ); + assert!( + meta.name.chars().all(|c| c.is_ascii_lowercase() || c == '_'), + "tool '{}' contains non-lowercase or non-underscore characters", + meta.name + ); + } + } + + #[test] + fn list_is_alphabetically_sorted() { + let reg = make_registry(); + let names: Vec<&str> = reg.list().iter().map(|m| m.name).collect(); + let mut sorted = names.clone(); + sorted.sort_unstable(); + assert_eq!(names, sorted, "registry list is not alphabetically sorted"); + } + + #[test] + fn all_tools_have_object_schema() { + let reg = make_registry(); + for meta in reg.list() { + let schema = reg.schema_of(meta.name).unwrap(); + assert_eq!( + schema["type"], "object", + "tool '{}' schema is not type:object", + meta.name + ); + } + } + + #[test] + fn no_duplicate_names_in_list() { + let reg = make_registry(); + let names: Vec<&str> = reg.list().iter().map(|m| m.name).collect(); + let unique: std::collections::HashSet<&str> = names.iter().cloned().collect(); + assert_eq!(names.len(), unique.len(), "duplicate tool names detected"); + } + + #[test] + fn read_tools_do_not_require_operator() { + let reg = make_registry(); + for meta in reg.list() { + if meta.category == ToolCategory::Read { + assert!( + !meta.category.requires_operator(), + "Read tool '{}' incorrectly requires_operator", + meta.name + ); + } + } + } + + #[test] + fn destructive_tools_require_admin() { + let reg = make_registry(); + for meta in reg.list() { + if meta.category == ToolCategory::Destructive { + assert!( + meta.category.requires_admin(), + "Destructive tool '{}' does not require_admin", + meta.name + ); + } + } + } +} diff --git a/crates/provisioning-core/src/sources/filesystem.rs b/crates/provisioning-core/src/sources/filesystem.rs new file mode 100644 index 0000000..a0b120a --- /dev/null +++ b/crates/provisioning-core/src/sources/filesystem.rs @@ -0,0 +1,305 @@ +//! Workspace directory discovery — async walkers over `Environment::workspaces_root`. +//! +//! All discovery is lazy (called per-tool-invocation) and non-cached intentionally: +//! the workspace filesystem changes infrequently and caching would complicate +//! invalidation. Tools that list workspaces pay one `read_dir` call per workspace. + +use crate::protocol::ToolError; +use std::path::{Path, PathBuf}; +use tokio::fs; + +// ── Public types ────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub struct WorkspaceEntry { + pub name: String, + pub path: PathBuf, +} + +#[derive(Debug, Clone)] +pub struct InfraEntry { + pub workspace: String, + pub environment: String, + pub path: PathBuf, +} + +#[derive(Debug, Clone)] +pub struct ComponentEntry { + pub workspace: String, + pub environment: String, + /// Stem of the NCL file (`fip`, `k0s`, …). + pub name: String, + pub path: PathBuf, +} + +#[derive(Debug, Clone)] +pub struct WorkflowEntry { + pub workspace: String, + pub environment: String, + pub name: String, + pub path: PathBuf, +} + +// ── Discovery functions ─────────────────────────────────────────────────────── + +/// Return all workspace entries (immediate subdirectories of `root`). +/// +/// Hidden directories (`.coder`, `.claude`, …) are skipped. +pub async fn list_workspaces(root: &Path) -> Result, ToolError> { + let mut entries = Vec::new(); + let mut dir = fs::read_dir(root).await.map_err(|e| { + ToolError::not_found(format!("workspaces_root {}: {e}", root.display())) + })?; + + while let Some(entry) = dir.next_entry().await.map_err(io_err)? { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = dir_name(&path); + if name.starts_with('.') { + continue; + } + entries.push(WorkspaceEntry { name, path }); + } + + entries.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(entries) +} + +/// Return the workspace entry for `name`, or `ToolError::NotFound`. +pub async fn get_workspace(root: &Path, name: &str) -> Result { + let path = root.join(name); + if !path.is_dir() { + return Err(ToolError::not_found(format!("workspace '{name}'"))); + } + Ok(WorkspaceEntry { name: name.to_owned(), path }) +} + +/// Return all `infra/` environments inside `workspace_path`. +pub async fn list_infra_envs( + workspace: &str, + workspace_path: &Path, +) -> Result, ToolError> { + let infra_root = workspace_path.join("infra"); + if !infra_root.is_dir() { + return Ok(Vec::new()); + } + + let mut entries = Vec::new(); + let mut dir = fs::read_dir(&infra_root).await.map_err(io_err)?; + while let Some(entry) = dir.next_entry().await.map_err(io_err)? { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let env_name = dir_name(&path); + if env_name.starts_with('.') { + continue; + } + entries.push(InfraEntry { + workspace: workspace.to_owned(), + environment: env_name, + path, + }); + } + + entries.sort_by(|a, b| a.environment.cmp(&b.environment)); + Ok(entries) +} + +/// Return all components (`infra//components/*.ncl`) for `env_path`. +pub async fn list_components( + workspace: &str, + environment: &str, + env_path: &Path, +) -> Result, ToolError> { + ncl_entries_in_dir( + env_path.join("components"), + |name, path| ComponentEntry { + workspace: workspace.to_owned(), + environment: environment.to_owned(), + name, + path, + }, + ) + .await +} + +/// Return all workflows (`infra//workflows/*.ncl`) for `env_path`. +pub async fn list_workflows( + workspace: &str, + environment: &str, + env_path: &Path, +) -> Result, ToolError> { + ncl_entries_in_dir( + env_path.join("workflows"), + |name, path| WorkflowEntry { + workspace: workspace.to_owned(), + environment: environment.to_owned(), + name, + path, + }, + ) + .await +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn dir_name(p: &Path) -> String { + p.file_name().and_then(|n| n.to_str()).unwrap_or("").to_owned() +} + +fn io_err(e: std::io::Error) -> ToolError { + ToolError::invocation(e.to_string()) +} + +async fn ncl_entries_in_dir(dir: PathBuf, make: F) -> Result, ToolError> +where + F: Fn(String, PathBuf) -> T, + T: Ord + Clone, + T: std::fmt::Debug, +{ + if !dir.is_dir() { + return Ok(Vec::new()); + } + + let mut entries = Vec::new(); + let mut read = fs::read_dir(&dir).await.map_err(io_err)?; + while let Some(entry) = read.next_entry().await.map_err(io_err)? { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("ncl") { + continue; + } + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_owned(); + if name.is_empty() || name.starts_with('.') { + continue; + } + entries.push(make(name, path)); + } + + Ok(entries) +} + +// ── Ord impls (needed for ncl_entries_in_dir bound) ────────────────────────── + +impl PartialEq for ComponentEntry { + fn eq(&self, other: &Self) -> bool { + self.path == other.path + } +} +impl Eq for ComponentEntry {} +impl PartialOrd for ComponentEntry { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for ComponentEntry { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.name.cmp(&other.name) + } +} + +impl PartialEq for WorkflowEntry { + fn eq(&self, other: &Self) -> bool { + self.path == other.path + } +} +impl Eq for WorkflowEntry {} +impl PartialOrd for WorkflowEntry { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for WorkflowEntry { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.name.cmp(&other.name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs as sfs; + use tempfile::TempDir; + + fn scaffold_workspace(root: &Path) { + // workspace: alpha + sfs::create_dir_all(root.join("alpha/infra/prod/components")).unwrap(); + sfs::create_dir_all(root.join("alpha/infra/prod/workflows")).unwrap(); + sfs::write(root.join("alpha/infra/prod/components/fip.ncl"), "{}").unwrap(); + sfs::write(root.join("alpha/infra/prod/components/k0s.ncl"), "{}").unwrap(); + sfs::write(root.join("alpha/infra/prod/workflows/deploy.ncl"), "{}").unwrap(); + sfs::write(root.join("alpha/infra/prod/servers.ncl"), "{}").unwrap(); + // hidden dir — must be skipped + sfs::create_dir_all(root.join(".git")).unwrap(); + // workspace: beta (no infra/) + sfs::create_dir_all(root.join("beta")).unwrap(); + } + + #[tokio::test] + async fn list_workspaces_skips_hidden() { + let dir = TempDir::new().unwrap(); + scaffold_workspace(dir.path()); + + let ws = list_workspaces(dir.path()).await.unwrap(); + let names: Vec<_> = ws.iter().map(|w| w.name.as_str()).collect(); + assert_eq!(names, &["alpha", "beta"]); + } + + #[tokio::test] + async fn get_workspace_missing_returns_not_found() { + let dir = TempDir::new().unwrap(); + let err = get_workspace(dir.path(), "nonexistent").await.unwrap_err(); + assert!(matches!(err, ToolError::NotFound(_))); + } + + #[tokio::test] + async fn list_infra_envs_returns_prod() { + let dir = TempDir::new().unwrap(); + scaffold_workspace(dir.path()); + let ws_path = dir.path().join("alpha"); + + let envs = list_infra_envs("alpha", &ws_path).await.unwrap(); + assert_eq!(envs.len(), 1); + assert_eq!(envs[0].environment, "prod"); + } + + #[tokio::test] + async fn list_components_returns_ncl_files() { + let dir = TempDir::new().unwrap(); + scaffold_workspace(dir.path()); + let env_path = dir.path().join("alpha/infra/prod"); + + let comps = list_components("alpha", "prod", &env_path).await.unwrap(); + let names: Vec<_> = comps.iter().map(|c| c.name.as_str()).collect(); + assert!(names.contains(&"fip")); + assert!(names.contains(&"k0s")); + assert_eq!(comps.len(), 2); + } + + #[tokio::test] + async fn list_workflows_returns_deploy() { + let dir = TempDir::new().unwrap(); + scaffold_workspace(dir.path()); + let env_path = dir.path().join("alpha/infra/prod"); + + let flows = list_workflows("alpha", "prod", &env_path).await.unwrap(); + assert_eq!(flows.len(), 1); + assert_eq!(flows[0].name, "deploy"); + } + + #[tokio::test] + async fn no_components_dir_returns_empty() { + let dir = TempDir::new().unwrap(); + let env_path = dir.path().join("alpha/infra/prod"); + sfs::create_dir_all(&env_path).unwrap(); + + let comps = list_components("alpha", "prod", &env_path).await.unwrap(); + assert!(comps.is_empty()); + } +} diff --git a/crates/provisioning-core/src/sources/mod.rs b/crates/provisioning-core/src/sources/mod.rs new file mode 100644 index 0000000..c8a3216 --- /dev/null +++ b/crates/provisioning-core/src/sources/mod.rs @@ -0,0 +1,19 @@ +//! Data sources consumed by tool implementations. +//! +//! Each module is a thin async adapter over an external system; tool code +//! calls these adapters rather than issuing raw subprocess or HTTP calls. + +pub mod filesystem; +pub mod ncl_cache; +pub mod orchestrator; +pub mod ssh; +pub mod vault; + +pub use filesystem::{ + ComponentEntry, InfraEntry, WorkflowEntry, WorkspaceEntry, + get_workspace, list_components, list_infra_envs, list_workspaces, list_workflows, +}; +pub use ncl_cache::NclCache; +pub use orchestrator::{OrchestratorClient, TaskState, TaskSummary}; +pub use ssh::{SshRequest, SshResponse, SshRetryPolicy, SshSource}; +pub use vault::VaultSource; diff --git a/crates/provisioning-core/src/sources/ncl_cache.rs b/crates/provisioning-core/src/sources/ncl_cache.rs new file mode 100644 index 0000000..8178715 --- /dev/null +++ b/crates/provisioning-core/src/sources/ncl_cache.rs @@ -0,0 +1,207 @@ +//! Cached `nickel export --format json` wrapper. +//! +//! Spawns `nickel export` as a subprocess and caches the result keyed by +//! canonical file path + last-modified time. Cache entries are invalidated +//! automatically when the source `.ncl` file is newer than the cached entry. + +use crate::protocol::ToolError; +use parking_lot::RwLock; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::SystemTime; +use tokio::process::Command; + +/// Per-path cache entry: the mtime at which the value was computed and the +/// parsed JSON value. +#[derive(Clone)] +struct Entry { + mtime: SystemTime, + value: Arc, +} + +/// Thread-safe, in-process cache for `nickel export` results. +/// +/// Wrap in `Arc` and share across async handlers — the inner +/// `RwLock` allows concurrent reads and serialises writes. +pub struct NclCache { + store: RwLock>, + import_path: Option>, +} + +impl NclCache { + pub fn new() -> Self { + Self { store: RwLock::new(HashMap::new()), import_path: None } + } + + pub fn with_import_path(import_path: impl Into>) -> Self { + Self { store: RwLock::new(HashMap::new()), import_path: Some(import_path.into()) } + } + + /// Return the JSON value for `path`, re-exporting only when the file's + /// mtime has changed since the last export. + /// + /// `extra_path` is prepended to the cache's global import path for this + /// export only — use it to add workspace-specific directories so relative + /// Nickel imports like `"infra/lib/..."` resolve correctly per workspace. + pub async fn get_with_path( + &self, + path: &Path, + extra_path: Option<&str>, + ) -> Result, ToolError> { + let canonical = path + .canonicalize() + .map_err(|e| ToolError::not_found(format!("{}: {e}", path.display())))?; + + let mtime = file_mtime(&canonical).await?; + + // Fast path: read lock, check freshness. + { + let store = self.store.read(); + if let Some(entry) = store.get(&canonical) { + if entry.mtime == mtime { + tracing::debug!(path = %canonical.display(), "ncl cache hit"); + return Ok(Arc::clone(&entry.value)); + } + } + } + + // Slow path: build effective import path, export, then write. + let effective_ip: Option = match (extra_path, self.import_path.as_deref()) { + (Some(extra), Some(base)) => Some(format!("{extra}:{base}")), + (Some(extra), None) => Some(extra.to_string()), + (None, base) => base.map(str::to_string), + }; + tracing::info!(path = %canonical.display(), import_path = effective_ip.as_deref().unwrap_or("(none)"), "ncl export"); + let value = nickel_export(&canonical, effective_ip.as_deref()).await?; + let entry = Entry { mtime, value: Arc::new(value) }; + { + let mut store = self.store.write(); + store.insert(canonical, entry.clone()); + } + Ok(entry.value) + } + + /// Return the JSON value for `path` using the cache's global import path. + pub async fn get(&self, path: &Path) -> Result, ToolError> { + self.get_with_path(path, None).await + } + + /// Remove all cached entries whose source file no longer exists. + pub async fn evict_missing(&self) { + let mut store = self.store.write(); + store.retain(|path, _| path.exists()); + } + + pub fn len(&self) -> usize { + self.store.read().len() + } + + pub fn is_empty(&self) -> bool { + self.store.read().is_empty() + } +} + +impl Default for NclCache { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Debug for NclCache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NclCache").field("entries", &self.store.read().len()).finish() + } +} + +async fn file_mtime(path: &Path) -> Result { + let meta = tokio::fs::metadata(path).await.map_err(|e| { + ToolError::not_found(format!("{}: {e}", path.display())) + })?; + meta.modified().map_err(|e| { + ToolError::invocation(format!("mtime unavailable for {}: {e}", path.display())) + }) +} + +async fn nickel_export(path: &Path, import_path: Option<&str>) -> Result { + let mut cmd = Command::new("nickel"); + cmd.args(["export", "--format", "json"]).arg(path); + if let Some(ip) = import_path { + cmd.env("NICKEL_IMPORT_PATH", ip); + } + let output = cmd.output() + .await + .map_err(|e| ToolError::invocation(format!("nickel export failed to spawn: {e}")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(ToolError::invocation(format!( + "nickel export {}: {stderr}", + path.display() + ))); + } + + serde_json::from_slice(&output.stdout).map_err(|e| { + ToolError::invocation(format!("nickel export output is not valid JSON: {e}")) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + /// Write a trivial Nickel file that exports `{"ok": true}` and verify + /// that `nickel` is available in PATH before running cache tests. + fn nickel_available() -> bool { + std::process::Command::new("nickel").arg("--version").output().is_ok() + } + + #[tokio::test] + async fn cache_miss_then_hit_on_unchanged_file() { + if !nickel_available() { + return; + } + + let mut f = NamedTempFile::new().unwrap(); + writeln!(f, "{{ ok = true }}").unwrap(); + let path = f.path().to_owned(); + + let cache = NclCache::new(); + assert!(cache.is_empty()); + + let v1 = cache.get(&path).await.unwrap(); + assert_eq!(cache.len(), 1); + + // Second call must return same Arc (cache hit — pointer equality). + let v2 = cache.get(&path).await.unwrap(); + assert!(Arc::ptr_eq(&v1, &v2)); + } + + #[tokio::test] + async fn not_found_returns_error() { + let cache = NclCache::new(); + let err = cache.get(Path::new("/nonexistent/file.ncl")).await.unwrap_err(); + assert!(matches!(err, ToolError::NotFound(_))); + } + + #[tokio::test] + async fn evict_missing_removes_stale_entries() { + if !nickel_available() { + return; + } + + let mut f = NamedTempFile::new().unwrap(); + writeln!(f, "{{ x = 1 }}").unwrap(); + let path = f.path().to_owned(); + + let cache = NclCache::new(); + cache.get(&path).await.unwrap(); + assert_eq!(cache.len(), 1); + + drop(f); // deletes the temp file + cache.evict_missing().await; + assert!(cache.is_empty()); + } +} diff --git a/crates/provisioning-core/src/sources/orchestrator.rs b/crates/provisioning-core/src/sources/orchestrator.rs new file mode 100644 index 0000000..56be8b5 --- /dev/null +++ b/crates/provisioning-core/src/sources/orchestrator.rs @@ -0,0 +1,238 @@ +//! HTTP client for the provisioning orchestrator API (default port 9011). +//! +//! Wire types are defined locally — provisioning-core must not depend on the +//! orchestrator crate to avoid pulling in its `#![allow(...)]` blanket and its +//! heavyweight dependency tree. + +use crate::protocol::ToolError; +use serde::{Deserialize, Serialize}; + +// ── Wire types ──────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "PascalCase")] +pub enum TaskState { + Pending, + Running, + Completed, + Failed, + Cancelled, +} + +impl std::fmt::Display for TaskState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Self::Pending => "Pending", + Self::Running => "Running", + Self::Completed => "Completed", + Self::Failed => "Failed", + Self::Cancelled => "Cancelled", + }; + f.write_str(s) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskSummary { + pub id: String, + pub name: String, + pub status: TaskState, + pub created_at: chrono::DateTime, + #[serde(default)] + pub started_at: Option>, + #[serde(default)] + pub completed_at: Option>, + #[serde(default)] + pub output: Option, + #[serde(default)] + pub error: Option, +} + +/// Envelope returned by every orchestrator endpoint. +#[derive(Debug, Deserialize)] +struct ApiResponse { + #[allow(dead_code)] + success: bool, + data: Option, + error: Option, +} + +impl ApiResponse { + fn into_data(self) -> Result { + match (self.data, self.error) { + (Some(d), _) => Ok(d), + (None, Some(e)) => Err(ToolError::invocation(format!("orchestrator: {e}"))), + (None, None) => Err(ToolError::invocation("orchestrator: empty response")), + } + } +} + +// ── Client ──────────────────────────────────────────────────────────────────── + +/// Thin async HTTP client scoped to the provisioning orchestrator. +/// +/// Construct once and share via `Arc`. All methods return +/// `ToolError` so tool implementations can use `?` directly. +#[derive(Debug, Clone)] +pub struct OrchestratorClient { + client: reqwest::Client, + base_url: String, +} + +impl OrchestratorClient { + /// `base_url` should be e.g. `"http://localhost:9011"` (no trailing slash). + pub fn new(base_url: impl Into) -> Self { + Self { + client: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .expect("reqwest client init cannot fail with these options"), + base_url: base_url.into(), + } + } + + /// `GET /health` — returns `Ok(())` when the orchestrator is reachable and healthy. + pub async fn health(&self) -> Result<(), ToolError> { + let url = format!("{}/health", self.base_url); + let resp = self + .client + .get(&url) + .send() + .await + .map_err(|e| ToolError::invocation(format!("orchestrator unreachable: {e}")))?; + if resp.status().is_success() { + Ok(()) + } else { + Err(ToolError::invocation(format!("orchestrator /health → {}", resp.status()))) + } + } + + /// `GET /tasks` — all tasks, most recent first. + pub async fn list_tasks(&self) -> Result, ToolError> { + let url = format!("{}/tasks", self.base_url); + self.get_json::>(&url).await + } + + /// `GET /tasks/{id}`. + pub async fn get_task(&self, id: &str) -> Result { + let url = format!("{}/tasks/{}", self.base_url, id); + self.get_json::(&url).await + } + + /// `GET /state/system/metrics` — raw JSON forwarded to callers. + pub async fn system_metrics(&self) -> Result { + let url = format!("{}/state/system/metrics", self.base_url); + self.get_json::(&url).await + } + + /// `GET /state/system/health` — structured health status forwarded as raw JSON. + pub async fn system_health(&self) -> Result { + let url = format!("{}/state/system/health", self.base_url); + self.get_json::(&url).await + } + + // ── internal ────────────────────────────────────────────────────────────── + + async fn get_json( + &self, + url: &str, + ) -> Result { + let resp = self + .client + .get(url) + .send() + .await + .map_err(|e| ToolError::invocation(format!("orchestrator request failed: {e}")))?; + + let status = resp.status(); + if status == reqwest::StatusCode::NOT_FOUND { + return Err(ToolError::not_found(url.to_string())); + } + if !status.is_success() { + return Err(ToolError::invocation(format!("orchestrator {url} → {status}"))); + } + + let envelope: ApiResponse = resp.json().await.map_err(|e| { + ToolError::invocation(format!("orchestrator response deserialisation: {e}")) + })?; + envelope.into_data() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mockito::Server; + + fn make_task() -> serde_json::Value { + serde_json::json!({ + "success": true, + "data": [{ + "id": "task-1", + "name": "create_server", + "status": "Pending", + "created_at": "2026-04-18T10:00:00Z" + }] + }) + } + + #[tokio::test] + async fn list_tasks_parses_response() { + let mut server = Server::new_async().await; + let mock = server + .mock("GET", "/tasks") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(make_task().to_string()) + .create_async() + .await; + + let client = OrchestratorClient::new(server.url()); + let tasks = client.list_tasks().await.unwrap(); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].id, "task-1"); + assert_eq!(tasks[0].status, TaskState::Pending); + + mock.assert_async().await; + } + + #[tokio::test] + async fn get_task_not_found_returns_not_found_error() { + let mut server = Server::new_async().await; + let mock = server + .mock("GET", "/tasks/missing") + .with_status(404) + .create_async() + .await; + + let client = OrchestratorClient::new(server.url()); + let err = client.get_task("missing").await.unwrap_err(); + assert!(matches!(err, ToolError::NotFound(_))); + + mock.assert_async().await; + } + + #[tokio::test] + async fn health_ok_returns_unit() { + let mut server = Server::new_async().await; + let mock = server + .mock("GET", "/health") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"success":true,"data":"Orchestrator is healthy"}"#) + .create_async() + .await; + + let client = OrchestratorClient::new(server.url()); + client.health().await.unwrap(); + + mock.assert_async().await; + } + + #[tokio::test] + async fn unreachable_host_returns_invocation_error() { + let client = OrchestratorClient::new("http://127.0.0.1:1"); // nothing listening + let err = client.health().await.unwrap_err(); + assert!(matches!(err, ToolError::Invocation(_))); + } +} diff --git a/crates/provisioning-core/src/sources/ssh.rs b/crates/provisioning-core/src/sources/ssh.rs new file mode 100644 index 0000000..9796474 --- /dev/null +++ b/crates/provisioning-core/src/sources/ssh.rs @@ -0,0 +1,86 @@ +//! `ToolError`-mapped wrapper over `platform_clients::machines::MachinesClient`. +//! +//! Re-exports the request/response types tools need so they don't have to +//! depend on `service-clients` directly. + +use crate::protocol::ToolError; +use platform_clients::machines::{ + ExecuteCommandRequest, ExecuteCommandResponse, MachineInfo, MachinePoolStatus, MachinesClient, +}; +use std::sync::Arc; + +pub use platform_clients::machines::{ + ExecuteCommandRequest as SshRequest, ExecuteCommandResponse as SshResponse, + MachineInfo as SshMachineInfo, RetryPolicy as SshRetryPolicy, +}; + +fn map_err(e: platform_clients::error::ServiceError) -> ToolError { + if e.is_not_found() { + return ToolError::not_found(e.to_string()); + } + ToolError::invocation(e.to_string()) +} + +/// Shareable SSH source backed by `MachinesClient`. +#[derive(Debug, Clone)] +pub struct SshSource { + inner: Arc, +} + +impl SshSource { + /// `base_url` — e.g. `"http://localhost:9013"` (machines service HTTP API). + pub fn new(base_url: impl Into) -> Result { + let client = MachinesClient::new(base_url) + .map_err(|e| ToolError::invocation(format!("ssh source init: {e}")))?; + Ok(Self { inner: Arc::new(client) }) + } + + /// Execute `command` on `host` with no retry. + pub async fn execute( + &self, + host: impl Into, + command: impl Into, + ) -> Result { + self.inner.execute_command(host, command).await.map_err(map_err) + } + + /// Execute with explicit retry policy. + pub async fn execute_with_retry( + &self, + request: ExecuteCommandRequest, + ) -> Result { + self.inner.execute_command_with_retry(request).await.map_err(map_err) + } + + /// List all registered machines. + pub async fn list_machines(&self) -> Result, ToolError> { + self.inner.list_machines().await.map_err(map_err) + } + + /// Get a single machine by name. + pub async fn get_machine(&self, name: &str) -> Result { + self.inner.get_machine(name).await.map_err(map_err) + } + + /// Machine pool status summary. + pub async fn pool_status(&self) -> Result { + self.inner.pool_status().await.map_err(map_err) + } + + /// Returns `Ok(true)` when the machines service is reachable. + pub async fn health(&self) -> Result { + self.inner.health_check().await.map_err(map_err) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn unreachable_service_returns_invocation_error() { + let src = SshSource::new("http://127.0.0.1:1").unwrap(); + let err = src.health().await.unwrap_err(); + assert!(!matches!(err, ToolError::NotFound(_))); + } +} diff --git a/crates/provisioning-core/src/sources/vault.rs b/crates/provisioning-core/src/sources/vault.rs new file mode 100644 index 0000000..d6c1878 --- /dev/null +++ b/crates/provisioning-core/src/sources/vault.rs @@ -0,0 +1,81 @@ +//! Thin `ToolError`-mapped wrapper over `vault_service::KmsService`. +//! +//! Tools call these methods and use `?` — no `anyhow` conversion needed at +//! call sites. The underlying `KmsError` is mapped to `ToolError::Backend`. + +use crate::protocol::ToolError; +use std::sync::Arc; +use vault_service::{DataKey, EncryptionContext, KmsService, KeySpec}; + +/// Shareable vault source. Construct once, wrap in `Arc`, pass to tools via +/// `Context` or directly in the tool's initialiser. +#[derive(Clone)] +pub struct VaultSource { + inner: Arc, +} + +impl std::fmt::Debug for VaultSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VaultSource") + .field("backend", &self.inner.get_backend_name()) + .finish() + } +} + +impl VaultSource { + pub fn new(service: KmsService) -> Self { + Self { inner: Arc::new(service) } + } + + pub fn from_arc(service: Arc) -> Self { + Self { inner: service } + } + + /// Encrypt `plaintext` under `context`. + pub async fn encrypt( + &self, + plaintext: &[u8], + context: &EncryptionContext, + ) -> Result, ToolError> { + self.inner + .encrypt(plaintext, context) + .await + .map_err(|e| ToolError::Backend(anyhow::anyhow!("vault encrypt: {e}"))) + } + + /// Decrypt `ciphertext` under `context`. + pub async fn decrypt( + &self, + ciphertext: &[u8], + context: &EncryptionContext, + ) -> Result, ToolError> { + self.inner + .decrypt(ciphertext, context) + .await + .map_err(|e| ToolError::Backend(anyhow::anyhow!("vault decrypt: {e}"))) + } + + /// Generate a new data key for `key_spec`. + pub async fn generate_data_key(&self, key_spec: &KeySpec) -> Result { + self.inner + .generate_data_key(key_spec) + .await + .map_err(|e| ToolError::Backend(anyhow::anyhow!("vault generate_data_key: {e}"))) + } + + /// Returns `Ok(true)` when the vault backend is reachable. + pub async fn health(&self) -> Result { + self.inner + .health_check() + .await + .map_err(|e| ToolError::Backend(anyhow::anyhow!("vault health: {e}"))) + } + + /// Backend name (e.g. `"age"`, `"aws-kms"`, …). + pub fn backend_name(&self) -> String { + self.inner.get_backend_name() + } +} + +// No unit tests here — KmsService requires a real backend (age keys, AWS, …). +// Integration tests live in the vault-service crate itself. diff --git a/crates/provisioning-core/src/tool.rs b/crates/provisioning-core/src/tool.rs new file mode 100644 index 0000000..e82f482 --- /dev/null +++ b/crates/provisioning-core/src/tool.rs @@ -0,0 +1,136 @@ +//! The `Tool` trait — contract for every invocable operation exposed to surfaces +//! (CLI, HTTP, MCP). Implementations are registered into a [`crate::registry::Registry`]. + +use crate::protocol::{Environment, ToolCategory, ToolError, UserContext}; +use async_trait::async_trait; +use serde::Serialize; +use std::sync::Arc; + +/// Per-invocation context passed to every `Tool::invoke`. +/// +/// `user` is `None` for unauthenticated surfaces (local CLI, MCP stdio — the invoking +/// process already has OS-level privileges). HTTP auth middleware populates it +/// from the JWT subject claims when `--auth` is active. +#[derive(Debug, Clone)] +pub struct Context { + pub user: Option, + pub env: Arc, +} + +impl Context { + pub fn new(env: Arc) -> Self { + Self { user: None, env } + } + + pub fn with_user(env: Arc, user: UserContext) -> Self { + Self { user: Some(user), env } + } +} + +/// Read-only metadata cached by the Registry at registration time. +/// +/// Surfaces consume `ToolMetadata` for `tools/list`-style endpoints without +/// paying the cost of re-serializing each tool's JSON Schema per request. +#[derive(Debug, Clone, Serialize)] +pub struct ToolMetadata { + pub name: &'static str, + pub description: &'static str, + pub category: ToolCategory, + pub schema: Arc, +} + +/// Contract implemented by every invocable operation. +/// +/// ### Invariants expected of implementations +/// - `name()` is unique across the entire Registry — enforced at registration. +/// - `name()` matches the pattern `_` (snake_case), see ADR-029. +/// - `schema()` returns a valid JSON Schema describing `params` accepted by `invoke`. +/// - `invoke` parses `params` itself; invalid shape → `ToolError::invalid_param`. +/// - Returned `serde_json::Value` conforms to the response contract documented in +/// `crate::protocol` (e.g. `Listing` for list endpoints, flat record for item). +#[async_trait] +pub trait Tool: Send + Sync + 'static { + /// Canonical name — e.g. `"workspace_list"`, `"server_create"`. + fn name(&self) -> &'static str; + + /// One-line human description (surfaces MCP `description` field and HTTP help). + fn description(&self) -> &'static str; + + /// JSON Schema for `params`. Called once by the Registry at registration. + fn schema(&self) -> serde_json::Value; + + /// RBAC category — consumed by HTTP auth middleware. + fn category(&self) -> ToolCategory; + + /// Execute the tool. + /// + /// `params` is the raw JSON body/args sent by the caller. Implementations parse it + /// into their typed param struct. `ctx` carries optional user identity and the + /// environment pointers (workspace root, orchestrator URL, cache dir). + async fn invoke( + &self, + params: serde_json::Value, + ctx: &Context, + ) -> Result; +} + +#[cfg(test)] +pub(crate) mod test_support { + //! Mock tools used by registry tests. + + use super::*; + use serde_json::json; + + pub struct EchoTool; + + #[async_trait] + impl Tool for EchoTool { + fn name(&self) -> &'static str { + "echo" + } + fn description(&self) -> &'static str { + "Return the input params unchanged" + } + fn schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "additionalProperties": true + }) + } + fn category(&self) -> ToolCategory { + ToolCategory::Read + } + async fn invoke( + &self, + params: serde_json::Value, + _ctx: &Context, + ) -> Result { + Ok(params) + } + } + + pub struct FailingTool; + + #[async_trait] + impl Tool for FailingTool { + fn name(&self) -> &'static str { + "failing" + } + fn description(&self) -> &'static str { + "Always returns NotFound" + } + fn schema(&self) -> serde_json::Value { + json!({"type": "object"}) + } + fn category(&self) -> ToolCategory { + ToolCategory::Mutation + } + async fn invoke( + &self, + _params: serde_json::Value, + _ctx: &Context, + ) -> Result { + Err(ToolError::not_found("nothing here")) + } + } +} diff --git a/crates/provisioning-core/src/tools/agents.rs b/crates/provisioning-core/src/tools/agents.rs new file mode 100644 index 0000000..77fdf1b --- /dev/null +++ b/crates/provisioning-core/src/tools/agents.rs @@ -0,0 +1,185 @@ +//! Agent management tools — list, status, dispatch. +//! +//! Agents in provisioning are orchestrator tasks whose name begins with "agent_". +//! There is no separate agent API; these tools are thin views over the task queue. + +use crate::protocol::{ToolCategory, ToolError}; +use crate::sources::OrchestratorClient; +use crate::tool::{Context, Tool}; +use async_trait::async_trait; +use serde_json::{Value, json}; +use std::sync::Arc; + +// ── agent_list ──────────────────────────────────────────────────────────────── + +pub struct AgentList { + client: Arc, +} +impl AgentList { + pub fn new(client: Arc) -> Self { Self { client } } +} + +#[async_trait] +impl Tool for AgentList { + fn name(&self) -> &'static str { "agent_list" } + fn description(&self) -> &'static str { + "List orchestrator tasks whose name begins with 'agent_'" + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["Pending", "Running", "Completed", "Failed", "Cancelled"], + "description": "Filter by task state" + } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, _ctx: &Context) -> Result { + let status_filter = params["status"].as_str(); + let tasks = self.client.list_tasks().await?; + + let items: Vec = tasks + .into_iter() + .filter(|t| { + t.name.starts_with("agent_") + && status_filter + .map(|f| t.status.to_string() == f) + .unwrap_or(true) + }) + .map(|t| serde_json::to_value(t).unwrap_or(Value::Null)) + .collect(); + + Ok(json!({ "items": items, "total": items.len() })) + } +} + +// ── agent_status ────────────────────────────────────────────────────────────── + +pub struct AgentStatus { + client: Arc, +} +impl AgentStatus { + pub fn new(client: Arc) -> Self { Self { client } } +} + +#[async_trait] +impl Tool for AgentStatus { + fn name(&self) -> &'static str { "agent_status" } + fn description(&self) -> &'static str { "Get status of a specific agent task by ID" } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "string", "description": "Agent task UUID" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, _ctx: &Context) -> Result { + let id = params["id"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("id", "required string"))?; + let task = self.client.get_task(id).await?; + + if !task.name.starts_with("agent_") { + return Err(ToolError::not_found(format!("agent task '{id}'"))); + } + + serde_json::to_value(task) + .map_err(|e| ToolError::invocation(format!("serialize task: {e}"))) + } +} + +// ── agent_run ───────────────────────────────────────────────────────────────── + +pub struct AgentRun; +impl AgentRun { + pub fn new() -> Self { Self } +} +impl Default for AgentRun { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl Tool for AgentRun { + fn name(&self) -> &'static str { "agent_run" } + fn description(&self) -> &'static str { + "Dispatch an agent taskserv via the orchestrator (creates an 'agent_' job)" + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["infra", "settings", "agent"], + "properties": { + "infra": { "type": "string", "description": "Infrastructure name" }, + "settings": { "type": "string", "description": "Settings NCL path" }, + "agent": { "type": "string", "description": "Agent taskserv name" }, + "operation": { "type": "string", "enum": ["create", "delete"], "default": "create" }, + "wait": { "type": "boolean", "default": false } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Mutation } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let infra = params["infra"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("infra", "required string"))?; + let settings = params["settings"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("settings", "required string"))?; + let agent = params["agent"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("agent", "required string"))?; + let operation = params["operation"].as_str().unwrap_or("create"); + let wait = params["wait"].as_bool().unwrap_or(false); + + let body = json!({ + "infra": infra, + "settings": settings, + "taskserv": format!("agent_{agent}"), + "operation": operation, + "check_mode": false, + "wait": wait, + }); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| ToolError::invocation(format!("http client: {e}")))?; + + let url = format!("{}/workflows/taskserv/create", ctx.env.orchestrator_url); + let resp = client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| ToolError::invocation(format!("orchestrator unreachable: {e}")))?; + + let status = resp.status(); + if !status.is_success() { + return Err(ToolError::invocation(format!( + "orchestrator returned {status} for agent run" + ))); + } + + let result: Value = resp + .json() + .await + .map_err(|e| ToolError::invocation(format!("parse response: {e}")))?; + + Ok(json!({ + "agent": agent, + "operation": operation, + "task_id": result["data"], + "status": "queued", + })) + } +} diff --git a/crates/provisioning-core/src/tools/cluster.rs b/crates/provisioning-core/src/tools/cluster.rs new file mode 100644 index 0000000..a5483a3 --- /dev/null +++ b/crates/provisioning-core/src/tools/cluster.rs @@ -0,0 +1,458 @@ +//! Cluster domain tools — list, show, create. +//! +//! Clusters are discovered via filesystem: each workspace environment may contain +//! a `clusters/.ncl` or a `servers.ncl` with cluster-typed entries. +//! Creation is enqueued as a `ClusterWorkflow` via the orchestrator. + +use crate::protocol::{ToolCategory, ToolError}; +use crate::sources::{NclCache, get_workspace, list_infra_envs}; +use crate::tool::{Context, Tool}; +use async_trait::async_trait; +use serde_json::{Value, json}; +use std::sync::Arc; + +// ── Cluster discovery ───────────────────────────────────────────────────────── + +fn is_cluster_entry(val: &Value) -> bool { + val.get("cluster_type").is_some() + || val.get("role").and_then(|r| r.as_str()) == Some("cluster") +} + +async fn collect_from_servers_ncl( + cache: &NclCache, + env_path: &std::path::Path, + ws_name: &str, + env: &str, + out: &mut Vec, +) { + let path = env_path.join("servers.ncl"); + if !path.exists() { + return; + } + if let Ok(v) = cache.get(&path).await { + if let Some(obj) = v.as_object() { + for (key, val) in obj { + if is_cluster_entry(val) { + out.push(json!({ + "name": key, + "workspace": ws_name, + "env": env, + "source": "servers.ncl", + "config": val, + })); + } + } + } + } +} + +/// Walk `/clusters/*.ncl` for cluster definitions. +/// Falls back to reading `servers.ncl` and extracting entries whose NCL type +/// indicates a k0s/cluster role (field `cluster_type` present). +async fn discover_clusters( + cache: &NclCache, + ws_name: &str, + env: &str, + env_path: &std::path::Path, +) -> Result, ToolError> { + let clusters_dir = env_path.join("clusters"); + let mut out: Vec = Vec::new(); + + if clusters_dir.is_dir() { + let mut dir = tokio::fs::read_dir(&clusters_dir) + .await + .map_err(|e| ToolError::invocation(e.to_string()))?; + while let Some(entry) = dir + .next_entry() + .await + .map_err(|e| ToolError::invocation(e.to_string()))? + { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("ncl") { + continue; + } + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_owned(); + if name.starts_with('_') || name.starts_with('.') { + continue; + } + let config = cache.get(&path).await.ok().map(|v| (*v).clone()); + out.push(json!({ + "name": name, + "workspace": ws_name, + "env": env, + "path": path.to_string_lossy(), + "config": config, + })); + } + } + + // Also inspect servers.ncl for cluster_type entries when no clusters/ dir + if out.is_empty() { + collect_from_servers_ncl(cache, env_path, ws_name, env, &mut out).await; + } + + out.sort_by(|a, b| { + a["name"].as_str().cmp(&b["name"].as_str()) + }); + Ok(out) +} + +// ── cluster_list ────────────────────────────────────────────────────────────── + +pub struct ClusterList { + cache: Arc, +} +impl ClusterList { + pub fn new(cache: Arc) -> Self { Self { cache } } +} + +#[async_trait] +impl Tool for ClusterList { + fn name(&self) -> &'static str { "cluster_list" } + fn description(&self) -> &'static str { "List clusters defined in a workspace environment" } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["workspace"], + "properties": { + "workspace": { "type": "string" }, + "env": { "type": "string", "description": "Filter to a specific environment" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let ws_name = params["workspace"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("workspace", "required string"))?; + let env_filter = params["env"].as_str(); + + let ws = get_workspace(&ctx.env.workspaces_root, ws_name).await?; + let envs = list_infra_envs(ws_name, &ws.path).await?; + + let mut items: Vec = Vec::new(); + for env in &envs { + if let Some(f) = env_filter { + if env.environment != f { + continue; + } + } + let clusters = + discover_clusters(&self.cache, ws_name, &env.environment, &env.path).await?; + items.extend(clusters); + } + + Ok(json!({ "items": items, "total": items.len() })) + } +} + +// ── cluster_show ────────────────────────────────────────────────────────────── + +pub struct ClusterShow { + cache: Arc, +} +impl ClusterShow { + pub fn new(cache: Arc) -> Self { Self { cache } } +} + +#[async_trait] +impl Tool for ClusterShow { + fn name(&self) -> &'static str { "cluster_show" } + fn description(&self) -> &'static str { "Show NCL config for a specific cluster" } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["workspace", "env", "name"], + "properties": { + "workspace": { "type": "string" }, + "env": { "type": "string" }, + "name": { "type": "string" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let ws_name = params["workspace"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("workspace", "required string"))?; + let env_name = params["env"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("env", "required string"))?; + let cluster_name = params["name"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("name", "required string"))?; + + let ws = get_workspace(&ctx.env.workspaces_root, ws_name).await?; + let env_path = ws.path.join("infra").join(env_name); + + let ncl_path = env_path.join("clusters").join(format!("{cluster_name}.ncl")); + if ncl_path.exists() { + let config = self.cache.get(&ncl_path).await?; + return Ok(json!({ + "name": cluster_name, + "workspace": ws_name, + "env": env_name, + "config": (*config).clone(), + })); + } + + // Fallback: check servers.ncl for a cluster_type entry + let servers_path = env_path.join("servers.ncl"); + if servers_path.exists() { + if let Ok(v) = self.cache.get(&servers_path).await { + if let Some(entry) = v.as_object().and_then(|o| o.get(cluster_name)) { + if entry.get("cluster_type").is_some() { + return Ok(json!({ + "name": cluster_name, + "workspace": ws_name, + "env": env_name, + "source": "servers.ncl", + "config": entry, + })); + } + } + } + } + + Err(ToolError::not_found(format!("cluster '{cluster_name}' in {ws_name}/{env_name}"))) + } +} + +// ── cluster_create ──────────────────────────────────────────────────────────── + +pub struct ClusterCreate; +impl ClusterCreate { + pub fn new() -> Self { Self } +} +impl Default for ClusterCreate { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl Tool for ClusterCreate { + fn name(&self) -> &'static str { "cluster_create" } + fn description(&self) -> &'static str { + "Enqueue a cluster creation workflow via the orchestrator" + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["infra", "settings", "cluster_type"], + "properties": { + "infra": { "type": "string", "description": "Infrastructure name" }, + "settings": { "type": "string", "description": "Settings NCL path" }, + "cluster_type": { + "type": "string", + "enum": ["k0s", "k3s", "rke2"], + "description": "Cluster distribution to provision" + }, + "operation": { "type": "string", "enum": ["create", "delete"], "default": "create" }, + "check_mode": { "type": "boolean", "default": false }, + "wait": { "type": "boolean", "default": true } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Mutation } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let infra = params["infra"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("infra", "required string"))?; + let settings = params["settings"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("settings", "required string"))?; + let cluster_type = params["cluster_type"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("cluster_type", "required string"))?; + let operation = params["operation"].as_str().unwrap_or("create"); + let check_mode = params["check_mode"].as_bool().unwrap_or(false); + let wait = params["wait"].as_bool().unwrap_or(true); + + let body = json!({ + "infra": infra, + "settings": settings, + "cluster_type": cluster_type, + "operation": operation, + "check_mode": check_mode, + "wait": wait, + }); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| ToolError::invocation(format!("http client: {e}")))?; + + let url = format!("{}/workflows/cluster/create", ctx.env.orchestrator_url); + let resp = client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| ToolError::invocation(format!("orchestrator unreachable: {e}")))?; + + let status = resp.status(); + if !status.is_success() { + return Err(ToolError::invocation(format!( + "orchestrator returned {status} for cluster {operation}" + ))); + } + + let result: Value = resp + .json() + .await + .map_err(|e| ToolError::invocation(format!("parse response: {e}")))?; + + Ok(json!({ + "infra": infra, + "cluster_type": cluster_type, + "operation": operation, + "task_id": result["data"], + "status": "queued", + })) + } +} + +// ── cluster_pods ────────────────────────────────────────────────────────────── + +pub struct ClusterPods; +impl ClusterPods { + pub fn new() -> Self { Self } +} +impl Default for ClusterPods { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl Tool for ClusterPods { + fn name(&self) -> &'static str { "cluster_pods" } + fn description(&self) -> &'static str { + "Fetch pod JSON from a k0s/k8s cluster controller via SSH (`k0s kubectl get pods -A -o json`)" + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["host"], + "properties": { + "host": { "type": "string", "description": "SSH target IP or hostname" }, + "user": { "type": "string", "description": "SSH user (default: root)" }, + "key": { "type": "string", "description": "Path to SSH private key (expanded)" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, _ctx: &Context) -> Result { + let host = params["host"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("host", "required string"))?; + let user = params["user"].as_str().unwrap_or("root"); + let key = params["key"].as_str(); + + let mut ssh_args: Vec = vec![ + "-o".into(), "BatchMode=yes".into(), + "-o".into(), "ConnectTimeout=10".into(), + "-o".into(), "StrictHostKeyChecking=no".into(), + "-o".into(), "UserKnownHostsFile=/dev/null".into(), + ]; + if let Some(k) = key { + ssh_args.extend(["-i".into(), k.into()]); + } + ssh_args.push(format!("{user}@{host}")); + ssh_args.push("k0s kubectl get pods --all-namespaces -o json".into()); + + let output = tokio::process::Command::new("ssh") + .args(&ssh_args) + .output() + .await + .map_err(|e| ToolError::invocation(format!("ssh exec: {e}")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(ToolError::invocation(format!("kubectl exit {}: {stderr}", output.status))); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let pods: Value = serde_json::from_str(&stdout) + .map_err(|e| ToolError::invocation(format!("parse pods JSON: {e}")))?; + + let items = pods["items"].as_array().cloned().unwrap_or_default(); + let total = items.len(); + Ok(json!({ "items": items, "total": total })) + } +} + +// ── cluster_describe_pod ────────────────────────────────────────────────────── + +pub struct ClusterDescribePod; +impl ClusterDescribePod { + pub fn new() -> Self { Self } +} +impl Default for ClusterDescribePod { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl Tool for ClusterDescribePod { + fn name(&self) -> &'static str { "cluster_describe_pod" } + fn description(&self) -> &'static str { + "Run `k0s kubectl describe pod -n ` on a cluster controller via SSH" + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["host", "namespace", "pod"], + "properties": { + "host": { "type": "string" }, + "namespace": { "type": "string" }, + "pod": { "type": "string" }, + "user": { "type": "string" }, + "key": { "type": "string", "description": "Path to SSH private key (expanded)" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, _ctx: &Context) -> Result { + let host = params["host"].as_str() + .ok_or_else(|| ToolError::invalid_param("host", "required string"))?; + let ns = params["namespace"].as_str() + .ok_or_else(|| ToolError::invalid_param("namespace", "required string"))?; + let pod = params["pod"].as_str() + .ok_or_else(|| ToolError::invalid_param("pod", "required string"))?; + let user = params["user"].as_str().unwrap_or("root"); + let key = params["key"].as_str(); + + let cmd = format!("k0s kubectl describe pod -n {ns} {pod}"); + let mut ssh_args: Vec = vec![ + "-o".into(), "BatchMode=yes".into(), + "-o".into(), "ConnectTimeout=10".into(), + "-o".into(), "StrictHostKeyChecking=no".into(), + "-o".into(), "UserKnownHostsFile=/dev/null".into(), + ]; + if let Some(k) = key { + ssh_args.extend(["-i".into(), k.into()]); + } + ssh_args.push(format!("{user}@{host}")); + ssh_args.push(cmd); + + let output = tokio::process::Command::new("ssh") + .args(&ssh_args) + .output() + .await + .map_err(|e| ToolError::invocation(format!("ssh exec: {e}")))?; + + let text = if output.status.success() { + String::from_utf8_lossy(&output.stdout).into_owned() + } else { + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + return Err(ToolError::invocation(format!("describe failed: {stderr}"))); + }; + Ok(json!({ "output": text, "pod": pod, "namespace": ns })) + } +} diff --git a/crates/provisioning-core/src/tools/component.rs b/crates/provisioning-core/src/tools/component.rs new file mode 100644 index 0000000..c8d84b8 --- /dev/null +++ b/crates/provisioning-core/src/tools/component.rs @@ -0,0 +1,142 @@ +//! Component domain tools — list, show per workspace environment. +//! +//! Components are workspace-level NCL files in `infra//components/*.ncl` +//! AND extension-level metadata in `extensions/components//metadata.ncl`. +//! These tools operate on the workspace view (what is deployed where). + +use crate::protocol::{ToolCategory, ToolError}; +use crate::sources::{NclCache, get_workspace, list_components, list_infra_envs}; +use crate::tool::{Context, Tool}; +use async_trait::async_trait; +use serde_json::{Value, json}; +use std::sync::Arc; + +// ── component_list ──────────────────────────────────────────────────────────── + +pub struct ComponentList { + cache: Arc, +} +impl ComponentList { + pub fn new(cache: Arc) -> Self { Self { cache } } +} + +#[async_trait] +impl Tool for ComponentList { + fn name(&self) -> &'static str { "component_list" } + fn description(&self) -> &'static str { "List components deployed in a workspace environment" } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["workspace"], + "properties": { + "workspace": { "type": "string" }, + "env": { "type": "string", "description": "Filter to a specific environment" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let ws_name = params["workspace"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("workspace", "required string"))?; + let env_filter = params["env"].as_str(); + + let ws = get_workspace(&ctx.env.workspaces_root, ws_name).await?; + let envs = list_infra_envs(ws_name, &ws.path).await?; + + let mut items: Vec = Vec::new(); + for env in &envs { + if let Some(f) = env_filter { + if env.environment != f { continue; } + } + let comps = list_components(ws_name, &env.environment, &env.path).await?; + for comp in comps { + // Try to load the component NCL for additional metadata + let details = self.cache.get(&comp.path).await.ok().map(|v| (*v).clone()); + items.push(json!({ + "name": comp.name, + "workspace": ws_name, + "env": env.environment, + "path": comp.path.to_string_lossy(), + "config": details, + })); + } + } + + Ok(json!({ "items": items, "total": items.len() })) + } +} + +// ── component_show ──────────────────────────────────────────────────────────── + +pub struct ComponentShow { + cache: Arc, +} +impl ComponentShow { + pub fn new(cache: Arc) -> Self { Self { cache } } +} + +#[async_trait] +impl Tool for ComponentShow { + fn name(&self) -> &'static str { "component_show" } + fn description(&self) -> &'static str { "Show a component's NCL config in a specific workspace environment" } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["workspace", "env", "name"], + "properties": { + "workspace": { "type": "string" }, + "env": { "type": "string" }, + "name": { "type": "string" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let ws_name = params["workspace"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("workspace", "required string"))?; + let env_name = params["env"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("env", "required string"))?; + let comp_name = params["name"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("name", "required string"))?; + + let ws = get_workspace(&ctx.env.workspaces_root, ws_name).await?; + let comp_path = ws.path + .join("infra") + .join(env_name) + .join("components") + .join(format!("{comp_name}.ncl")); + + if !comp_path.exists() { + return Err(ToolError::not_found(format!( + "component '{comp_name}' in {ws_name}/{env_name}" + ))); + } + + let config = self.cache.get(&comp_path).await?; + + // Cross-reference with extension metadata if available + let ext_meta_path = ctx.env.extensions_root + .join("components") + .join(comp_name) + .join("metadata.ncl"); + let ext_meta = if ext_meta_path.exists() { + self.cache.get(&ext_meta_path).await.ok().map(|v| (*v).clone()) + } else { + None + }; + + Ok(json!({ + "name": comp_name, + "workspace": ws_name, + "env": env_name, + "config": (*config).clone(), + "extension_metadata": ext_meta, + })) + } +} diff --git a/crates/provisioning-core/src/tools/dag.rs b/crates/provisioning-core/src/tools/dag.rs new file mode 100644 index 0000000..b3d6f7f --- /dev/null +++ b/crates/provisioning-core/src/tools/dag.rs @@ -0,0 +1,263 @@ +//! DAG domain tools — show, validate, export. + +use crate::protocol::{ToolCategory, ToolError}; +use crate::sources::{NclCache, get_workspace}; +use crate::tool::{Context, Tool}; +use async_trait::async_trait; +use serde_json::{Value, json}; +use std::sync::Arc; + +// ── dag_show ────────────────────────────────────────────────────────────────── + +pub struct DagShow { + cache: Arc, +} + +impl DagShow { + pub fn new(cache: Arc) -> Self { Self { cache } } +} + +#[async_trait] +impl Tool for DagShow { + fn name(&self) -> &'static str { "dag_show" } + fn description(&self) -> &'static str { "Show the composition DAG for a workspace environment" } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["workspace", "env"], + "properties": { + "workspace": { "type": "string" }, + "env": { "type": "string" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let (ws_name, env_name) = extract_ws_env(¶ms)?; + let dag_path = resolve_dag_path(ctx, ws_name, env_name).await?; + let dag = self.cache.get(&dag_path).await?; + Ok((*dag).clone()) + } +} + +// ── dag_validate ────────────────────────────────────────────────────────────── + +pub struct DagValidate { + cache: Arc, +} + +impl DagValidate { + pub fn new(cache: Arc) -> Self { Self { cache } } +} + +#[async_trait] +impl Tool for DagValidate { + fn name(&self) -> &'static str { "dag_validate" } + fn description(&self) -> &'static str { "Validate the DAG NCL for a workspace environment" } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["workspace", "env"], + "properties": { + "workspace": { "type": "string" }, + "env": { "type": "string" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let (ws_name, env_name) = extract_ws_env(¶ms)?; + let dag_path = resolve_dag_path(ctx, ws_name, env_name).await?; + + match self.cache.get(&dag_path).await { + Ok(dag) => { + let formula_count = dag["composition"]["formulas"] + .as_array() + .map(|a| a.len()) + .unwrap_or(0); + Ok(json!({ + "valid": true, + "workspace": ws_name, + "env": env_name, + "formula_count": formula_count, + })) + } + Err(e) => Ok(json!({ + "valid": false, + "workspace": ws_name, + "env": env_name, + "error": e.to_string(), + })), + } + } +} + +// ── dag_export ──────────────────────────────────────────────────────────────── + +pub struct DagExport { + cache: Arc, +} + +impl DagExport { + pub fn new(cache: Arc) -> Self { Self { cache } } +} + +#[async_trait] +impl Tool for DagExport { + fn name(&self) -> &'static str { "dag_export" } + fn description(&self) -> &'static str { "Export the DAG as a DOT graph or JSON" } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["workspace", "env"], + "properties": { + "workspace": { "type": "string" }, + "env": { "type": "string" }, + "format": { + "type": "string", + "enum": ["dot", "json"], + "default": "dot" + } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let (ws_name, env_name) = extract_ws_env(¶ms)?; + let format = params["format"].as_str().unwrap_or("dot"); + let dag_path = resolve_dag_path(ctx, ws_name, env_name).await?; + let dag = self.cache.get(&dag_path).await?; + + match format { + "json" => Ok((*dag).clone()), + "dot" => { + let dot = dag_to_dot(&dag, ws_name, env_name)?; + Ok(json!({ "format": "dot", "content": dot })) + } + f => Err(ToolError::invalid_param("format", format!("unknown format '{f}'"))), + } + } +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +fn extract_ws_env(params: &Value) -> Result<(&str, &str), ToolError> { + let ws = params["workspace"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("workspace", "required string"))?; + let env = params["env"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("env", "required string"))?; + Ok((ws, env)) +} + +async fn resolve_dag_path( + ctx: &Context, + workspace: &str, + env: &str, +) -> Result { + let ws = get_workspace(&ctx.env.workspaces_root, workspace).await?; + let dag_path = ws.path.join("infra").join(env).join("dag.ncl"); + if !dag_path.exists() { + return Err(ToolError::not_found(format!( + "dag.ncl not found for workspace '{workspace}' env '{env}'" + ))); + } + Ok(dag_path) +} + +/// Convert DAG JSON to a DOT digraph string. +/// +/// Expected JSON shape (from NCL export): +/// ```json +/// { "composition": { "formulas": [ { "formula_id": "x", "depends_on": [{"formula_id": "y", "condition": "Healthy"}] } ] } } +/// ``` +fn dag_to_dot(dag: &Value, workspace: &str, env: &str) -> Result { + let formulas = dag["composition"]["formulas"] + .as_array() + .ok_or_else(|| ToolError::invocation("dag.ncl missing composition.formulas array"))?; + + let mut lines = Vec::new(); + lines.push(format!( + r#"digraph "{workspace}_{env}" {{"# + )); + lines.push(r#" rankdir=LR;"#.to_owned()); + lines.push(r#" node [shape=box, style=filled, fillcolor=lightyellow];"#.to_owned()); + + for formula in formulas { + let id = formula["formula_id"].as_str().unwrap_or("?"); + // Sanitise id for DOT node name (replace - with _) + let node = sanitise_dot_id(id); + lines.push(format!(r#" {node} [label="{id}"];"#)); + + if let Some(deps) = formula["depends_on"].as_array() { + for dep in deps { + let dep_id = dep["formula_id"].as_str().unwrap_or("?"); + let dep_node = sanitise_dot_id(dep_id); + let condition = dep["condition"].as_str().unwrap_or("Done"); + lines.push(format!(r#" {dep_node} -> {node} [label="{condition}"];"#)); + } + } + } + + lines.push("}".to_owned()); + Ok(lines.join("\n")) +} + +fn sanitise_dot_id(s: &str) -> String { + s.chars() + .map(|c| if c.is_alphanumeric() || c == '_' { c } else { '_' }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dag_to_dot_single_node() { + let dag = json!({ + "composition": { + "formulas": [ + { "formula_id": "node-a", "depends_on": [] } + ] + } + }); + let dot = dag_to_dot(&dag, "ws", "prod").unwrap(); + assert!(dot.contains("digraph")); + assert!(dot.contains("node_a")); + } + + #[test] + fn dag_to_dot_dependency_edge() { + let dag = json!({ + "composition": { + "formulas": [ + { "formula_id": "a", "depends_on": [] }, + { + "formula_id": "b", + "depends_on": [{ "formula_id": "a", "condition": "Healthy" }] + } + ] + } + }); + let dot = dag_to_dot(&dag, "ws", "prod").unwrap(); + assert!(dot.contains("a -> b") || dot.contains("a -> b")); + assert!(dot.contains("Healthy")); + } + + #[test] + fn dag_to_dot_missing_formulas_returns_error() { + let dag = json!({ "composition": {} }); + let err = dag_to_dot(&dag, "ws", "prod").unwrap_err(); + assert!(matches!(err, ToolError::Invocation(_))); + } + + #[test] + fn sanitise_replaces_hyphens() { + assert_eq!(sanitise_dot_id("my-formula-1"), "my_formula_1"); + } +} diff --git a/crates/provisioning-core/src/tools/dashboard.rs b/crates/provisioning-core/src/tools/dashboard.rs new file mode 100644 index 0000000..194a442 --- /dev/null +++ b/crates/provisioning-core/src/tools/dashboard.rs @@ -0,0 +1,210 @@ +//! Dashboard tools — services list, active workflows, audit log query, statistics. + +use crate::protocol::{ToolCategory, ToolError}; +use crate::sources::OrchestratorClient; +use crate::tool::{Context, Tool}; +use async_trait::async_trait; +use serde_json::{Value, json}; +use std::sync::Arc; + +// ── dashboard_services ──────────────────────────────────────────────────────── + +pub struct DashboardServices; +impl DashboardServices { + pub fn new() -> Self { Self } +} +impl Default for DashboardServices { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl Tool for DashboardServices { + fn name(&self) -> &'static str { "dashboard_services" } + fn description(&self) -> &'static str { + "List platform services and their current status (GET /api/v1/services/list)" + } + fn schema(&self) -> Value { + json!({ "type": "object", "properties": {}, "additionalProperties": false }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, _params: Value, ctx: &Context) -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| ToolError::invocation(format!("http client: {e}")))?; + + let url = format!("{}/api/v1/services/list", ctx.env.orchestrator_url); + let resp = client + .get(&url) + .send() + .await + .map_err(|e| ToolError::invocation(format!("orchestrator unreachable: {e}")))?; + + if !resp.status().is_success() { + return Err(ToolError::invocation(format!( + "services/list returned {}", + resp.status() + ))); + } + + resp.json::() + .await + .map_err(|e| ToolError::invocation(format!("parse response: {e}"))) + } +} + +// ── dashboard_workflows ─────────────────────────────────────────────────────── + +pub struct DashboardWorkflows { + client: Arc, +} +impl DashboardWorkflows { + pub fn new(client: Arc) -> Self { Self { client } } +} + +#[async_trait] +impl Tool for DashboardWorkflows { + fn name(&self) -> &'static str { "dashboard_workflows" } + fn description(&self) -> &'static str { + "List active (Running + Pending) orchestrator workflows" + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "include_completed": { + "type": "boolean", + "default": false, + "description": "Include completed/failed jobs in the result" + } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, _ctx: &Context) -> Result { + let include_all = params["include_completed"].as_bool().unwrap_or(false); + let tasks = self.client.list_tasks().await?; + + let items: Vec = tasks + .into_iter() + .filter(|t| { + include_all || matches!(t.status.to_string().as_str(), "Running" | "Pending") + }) + .map(|t| serde_json::to_value(t).unwrap_or(Value::Null)) + .collect(); + + Ok(json!({ "items": items, "total": items.len() })) + } +} + +// ── dashboard_audit ─────────────────────────────────────────────────────────── + +pub struct DashboardAudit; +impl DashboardAudit { + pub fn new() -> Self { Self } +} +impl Default for DashboardAudit { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl Tool for DashboardAudit { + fn name(&self) -> &'static str { "dashboard_audit" } + fn description(&self) -> &'static str { + "Query orchestrator audit logs (POST /api/v1/audit/query)" + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "limit": { "type": "integer", "default": 50 }, + "user_id": { "type": "string" }, + "action": { "type": "string" }, + "since": { "type": "string", "description": "ISO-8601 timestamp lower bound" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let mut query = json!({ + "limit": params["limit"].as_i64().unwrap_or(50), + }); + + if let Some(uid) = params["user_id"].as_str() { + query["user_id"] = json!(uid); + } + if let Some(action) = params["action"].as_str() { + query["action"] = json!(action); + } + if let Some(since) = params["since"].as_str() { + query["since"] = json!(since); + } + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| ToolError::invocation(format!("http client: {e}")))?; + + let url = format!("{}/api/v1/audit/query", ctx.env.orchestrator_url); + let resp = client + .post(&url) + .json(&query) + .send() + .await + .map_err(|e| ToolError::invocation(format!("orchestrator unreachable: {e}")))?; + + if !resp.status().is_success() { + return Err(ToolError::invocation(format!( + "audit/query returned {}", + resp.status() + ))); + } + + resp.json::() + .await + .map_err(|e| ToolError::invocation(format!("parse response: {e}"))) + } +} + +// ── dashboard_statistics ────────────────────────────────────────────────────── + +pub struct DashboardStatistics { + client: Arc, +} +impl DashboardStatistics { + pub fn new(client: Arc) -> Self { Self { client } } +} + +#[async_trait] +impl Tool for DashboardStatistics { + fn name(&self) -> &'static str { "dashboard_statistics" } + fn description(&self) -> &'static str { + "Aggregate dashboard statistics: job counts + system metrics snapshot" + } + fn schema(&self) -> Value { + json!({ "type": "object", "properties": {}, "additionalProperties": false }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, _params: Value, _ctx: &Context) -> Result { + let (tasks_result, metrics_result) = + tokio::join!(self.client.list_tasks(), self.client.system_metrics()); + + let tasks = tasks_result.unwrap_or_default(); + let metrics = metrics_result.ok(); + + let counts = json!({ + "total": tasks.len(), + "pending": tasks.iter().filter(|t| t.status.to_string() == "Pending").count(), + "running": tasks.iter().filter(|t| t.status.to_string() == "Running").count(), + "completed": tasks.iter().filter(|t| t.status.to_string() == "Completed").count(), + "failed": tasks.iter().filter(|t| t.status.to_string() == "Failed").count(), + "cancelled": tasks.iter().filter(|t| t.status.to_string() == "Cancelled").count(), + }); + + Ok(json!({ "jobs": counts, "metrics": metrics })) + } +} diff --git a/crates/provisioning-core/src/tools/extension.rs b/crates/provisioning-core/src/tools/extension.rs new file mode 100644 index 0000000..f92532e --- /dev/null +++ b/crates/provisioning-core/src/tools/extension.rs @@ -0,0 +1,296 @@ +//! Extension domain tools — list, show, search, capabilities. +//! +//! Extensions are discovered from `Environment::extensions_root/components/` +//! and `extensions_root/taskservs/` by reading `metadata.ncl` files. +//! The extension-registry HTTP service (port 8084) is intentionally NOT used +//! here — filesystem discovery is authoritative for the local repo state. + +use crate::protocol::{ToolCategory, ToolError}; +use crate::sources::NclCache; +use crate::tool::{Context, Tool}; +use async_trait::async_trait; +use serde_json::{Value, json}; +use std::sync::Arc; + +// ── Shared discovery ────────────────────────────────────────────────────────── + +/// Walk `root` (non-recursively) for subdirs that contain a `metadata.ncl`. +/// Returns `(name, metadata_value)` pairs sorted by name. +async fn discover_metadata( + cache: &NclCache, + root: &std::path::Path, +) -> Result, ToolError> { + if !root.is_dir() { + return Ok(Vec::new()); + } + let mut dir = tokio::fs::read_dir(root) + .await + .map_err(|e| ToolError::invocation(e.to_string()))?; + let mut results = Vec::new(); + while let Some(entry) = dir + .next_entry() + .await + .map_err(|e| ToolError::invocation(e.to_string()))? + { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_owned(); + if name.starts_with('_') || name.starts_with('.') { + continue; + } + let meta_path = path.join("metadata.ncl"); + if !meta_path.exists() { + continue; + } + if let Ok(v) = cache.get(&meta_path).await { + results.push((name, (*v).clone())); + } + } + results.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(results) +} + +fn matches_query(meta: &Value, query: &str) -> bool { + let q = query.to_lowercase(); + let name = meta["name"].as_str().unwrap_or("").to_lowercase(); + let desc = meta["description"].as_str().unwrap_or("").to_lowercase(); + let tags: Vec<&str> = meta["tags"] + .as_array() + .map(|a| a.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + name.contains(&q) || desc.contains(&q) || tags.iter().any(|t| t.contains(&q)) +} + +// ── extension_list ──────────────────────────────────────────────────────────── + +pub struct ExtensionList { + cache: Arc, +} +impl ExtensionList { + pub fn new(cache: Arc) -> Self { Self { cache } } +} + +#[async_trait] +impl Tool for ExtensionList { + fn name(&self) -> &'static str { "extension_list" } + fn description(&self) -> &'static str { "List all available extensions (components and taskservs)" } + fn schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["component", "taskserv", "all"], "default": "all" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let kind = params["kind"].as_str().unwrap_or("all"); + let root = &ctx.env.extensions_root; + let mut items: Vec = Vec::new(); + + if kind == "component" || kind == "all" { + for (name, meta) in discover_metadata(&self.cache, &root.join("components")).await? { + let mut m = meta; + m["_kind"] = json!("component"); + m["_name"] = json!(name); + items.push(m); + } + } + if kind == "taskserv" || kind == "all" { + // taskservs are organised in subdirs by category; walk two levels + let ts_root = root.join("taskservs"); + if ts_root.is_dir() { + let mut cat_dir = tokio::fs::read_dir(&ts_root) + .await + .map_err(|e| ToolError::invocation(e.to_string()))?; + while let Some(cat) = cat_dir + .next_entry() + .await + .map_err(|e| ToolError::invocation(e.to_string()))? + { + if !cat.path().is_dir() { continue; } + for (name, mut meta) in + discover_metadata(&self.cache, &cat.path()).await? + { + meta["_kind"] = json!("taskserv"); + meta["_name"] = json!(name); + items.push(meta); + } + } + items.sort_by(|a, b| { + a["_name"].as_str().cmp(&b["_name"].as_str()) + }); + } + } + + Ok(json!({ "items": items, "total": items.len() })) + } +} + +// ── extension_show ──────────────────────────────────────────────────────────── + +pub struct ExtensionShow { + cache: Arc, +} +impl ExtensionShow { + pub fn new(cache: Arc) -> Self { Self { cache } } +} + +#[async_trait] +impl Tool for ExtensionShow { + fn name(&self) -> &'static str { "extension_show" } + fn description(&self) -> &'static str { "Show metadata for a specific extension" } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "kind": { "type": "string", "enum": ["component", "taskserv"], "default": "component" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let name = params["name"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("name", "required string"))?; + let kind = params["kind"].as_str().unwrap_or("component"); + let root = &ctx.env.extensions_root; + + let meta_path = match kind { + "taskserv" => { + // search in all taskserv category subdirs + find_taskserv_metadata(root, name).await? + } + _ => root.join("components").join(name).join("metadata.ncl"), + }; + + if !meta_path.exists() { + return Err(ToolError::not_found(format!("extension '{name}'"))); + } + let meta = self.cache.get(&meta_path).await?; + Ok((*meta).clone()) + } +} + +async fn find_taskserv_metadata( + root: &std::path::Path, + name: &str, +) -> Result { + let ts_root = root.join("taskservs"); + if !ts_root.is_dir() { + return Err(ToolError::not_found(format!("taskserv '{name}'"))); + } + let mut cat_dir = tokio::fs::read_dir(&ts_root) + .await + .map_err(|e| ToolError::invocation(e.to_string()))?; + while let Some(cat) = cat_dir + .next_entry() + .await + .map_err(|e| ToolError::invocation(e.to_string()))? + { + let candidate = cat.path().join(name).join("metadata.ncl"); + if candidate.exists() { + return Ok(candidate); + } + } + Err(ToolError::not_found(format!("taskserv '{name}'"))) +} + +// ── extension_search ────────────────────────────────────────────────────────── + +pub struct ExtensionSearch { + cache: Arc, +} +impl ExtensionSearch { + pub fn new(cache: Arc) -> Self { Self { cache } } +} + +#[async_trait] +impl Tool for ExtensionSearch { + fn name(&self) -> &'static str { "extension_search" } + fn description(&self) -> &'static str { "Search extensions by name, description, or tag" } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["query"], + "properties": { + "query": { "type": "string" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let query = params["query"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("query", "required string"))?; + let root = &ctx.env.extensions_root; + + let all = discover_metadata(&self.cache, &root.join("components")).await?; + let matched: Vec = all + .into_iter() + .filter(|(_, meta)| matches_query(meta, query)) + .map(|(_, meta)| meta) + .collect(); + + Ok(json!({ "items": matched, "total": matched.len(), "query": query })) + } +} + +// ── extension_capabilities ──────────────────────────────────────────────────── + +pub struct ExtensionCapabilities { + cache: Arc, +} +impl ExtensionCapabilities { + pub fn new(cache: Arc) -> Self { Self { cache } } +} + +#[async_trait] +impl Tool for ExtensionCapabilities { + fn name(&self) -> &'static str { "extension_capabilities" } + fn description(&self) -> &'static str { "Aggregate provides/requires/conflicts across all components" } + fn schema(&self) -> Value { + json!({ "type": "object", "properties": {}, "additionalProperties": false }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, _params: Value, ctx: &Context) -> Result { + let root = &ctx.env.extensions_root; + let all = discover_metadata(&self.cache, &root.join("components")).await?; + + let mut provides: Vec = Vec::new(); + let mut requires: Vec = Vec::new(); + let mut conflicts: Vec = Vec::new(); + + for (name, meta) in &all { + let wrap = |v: &Value| json!({ "extension": name, "entry": v }); + if let Some(arr) = meta["provides"].as_array() { + provides.extend(arr.iter().map(wrap)); + } + if let Some(arr) = meta["requires"].as_array() { + requires.extend(arr.iter().map(wrap)); + } + if let Some(arr) = meta["conflicts_with"].as_array() { + conflicts.extend(arr.iter().map(wrap)); + } + } + + Ok(json!({ + "total_extensions": all.len(), + "provides": provides, + "requires": requires, + "conflicts_with": conflicts, + })) + } +} diff --git a/crates/provisioning-core/src/tools/guidance.rs b/crates/provisioning-core/src/tools/guidance.rs new file mode 100644 index 0000000..2f3bf7d --- /dev/null +++ b/crates/provisioning-core/src/tools/guidance.rs @@ -0,0 +1,385 @@ +//! Guidance tools — system status, doc search, next-action, troubleshooting, config validation. + +use crate::protocol::{ToolCategory, ToolError}; +use crate::sources::{NclCache, OrchestratorClient}; +use crate::tool::{Context, Tool}; +use async_trait::async_trait; +use serde_json::{Value, json}; +use std::sync::Arc; + +// ── guidance_system_status ──────────────────────────────────────────────────── + +pub struct GuidanceSystemStatus { + client: Arc, +} +impl GuidanceSystemStatus { + pub fn new(client: Arc) -> Self { Self { client } } +} + +#[async_trait] +impl Tool for GuidanceSystemStatus { + fn name(&self) -> &'static str { "guidance_system_status" } + fn description(&self) -> &'static str { + "Check overall provisioning system health (orchestrator, vault, services)" + } + fn schema(&self) -> Value { + json!({ "type": "object", "properties": {}, "additionalProperties": false }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, _params: Value, _ctx: &Context) -> Result { + let orchestrator_ok = self.client.health().await.is_ok(); + let health = if orchestrator_ok { + self.client.system_health().await.ok() + } else { + None + }; + + Ok(json!({ + "orchestrator": { + "reachable": orchestrator_ok, + "health": health, + }, + })) + } +} + +// ── guidance_doc_find ───────────────────────────────────────────────────────── + +pub struct GuidanceDocFind; +impl GuidanceDocFind { + pub fn new() -> Self { Self } +} +impl Default for GuidanceDocFind { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl Tool for GuidanceDocFind { + fn name(&self) -> &'static str { "guidance_doc_find" } + fn description(&self) -> &'static str { + "Search documentation files by keyword under docs/ or .claude/" + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["query"], + "properties": { + "query": { "type": "string" }, + "root": { + "type": "string", + "description": "Directory to search under (default: docs)", + "default": "docs" + } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let query = params["query"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("query", "required string"))? + .to_lowercase(); + let root_suffix = params["root"].as_str().unwrap_or("docs"); + + // Anchor to workspaces_root's parent (project root) + let project_root = ctx + .env + .workspaces_root + .parent() + .unwrap_or(&ctx.env.workspaces_root); + let search_root = project_root.join(root_suffix); + + if !search_root.is_dir() { + return Ok(json!({ "items": [], "total": 0, "query": query })); + } + + let matches = search_markdown_files(&search_root, &query).await?; + let total = matches.len(); + Ok(json!({ "items": matches, "total": total, "query": query })) + } +} + +async fn search_markdown_files( + root: &std::path::Path, + query: &str, +) -> Result, ToolError> { + let mut results: Vec = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + + while let Some(dir) = stack.pop() { + let mut rd = tokio::fs::read_dir(&dir) + .await + .map_err(|e| ToolError::invocation(e.to_string()))?; + while let Some(entry) = rd + .next_entry() + .await + .map_err(|e| ToolError::invocation(e.to_string()))? + { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + continue; + } + if path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_lowercase(); + if name.contains(query) || path.to_string_lossy().to_lowercase().contains(query) { + results.push(json!({ "path": path.to_string_lossy() })); + } + } + } + + results.sort_by(|a, b| { + a["path"].as_str().cmp(&b["path"].as_str()) + }); + Ok(results) +} + +// ── guidance_next_action ────────────────────────────────────────────────────── + +pub struct GuidanceNextAction { + client: Arc, +} +impl GuidanceNextAction { + pub fn new(client: Arc) -> Self { Self { client } } +} + +#[async_trait] +impl Tool for GuidanceNextAction { + fn name(&self) -> &'static str { "guidance_next_action" } + fn description(&self) -> &'static str { + "Suggest the next provisioning action based on current system state" + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "context": { + "type": "string", + "description": "Optional context hint (e.g. 'deploying cluster', 'onboarding')" + } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, _ctx: &Context) -> Result { + let context_hint = params["context"].as_str().unwrap_or(""); + let orchestrator_ok = self.client.health().await.is_ok(); + + let mut suggestions: Vec = Vec::new(); + + if !orchestrator_ok { + suggestions.push(json!({ + "action": "start_orchestrator", + "priority": "high", + "reason": "orchestrator is not reachable", + "command": "just start-orchestrator", + })); + } else { + let tasks = self.client.list_tasks().await.unwrap_or_default(); + let running = tasks.iter().filter(|t| t.status.to_string() == "Running").count(); + let failed = tasks.iter().filter(|t| t.status.to_string() == "Failed").count(); + + if failed > 0 { + suggestions.push(json!({ + "action": "review_failed_jobs", + "priority": "high", + "reason": format!("{failed} job(s) have failed"), + "command": "provisioning orchestrator jobs --status Failed", + })); + } + if running > 0 { + suggestions.push(json!({ + "action": "monitor_running_jobs", + "priority": "medium", + "reason": format!("{running} job(s) in progress"), + "command": "provisioning orchestrator jobs --status Running", + })); + } + } + + if context_hint.contains("cluster") { + suggestions.push(json!({ + "action": "validate_cluster_config", + "priority": "medium", + "reason": "cluster deployment in progress", + "command": "provisioning workspace validate", + })); + } + + if suggestions.is_empty() { + suggestions.push(json!({ + "action": "check_workspace", + "priority": "low", + "reason": "system appears healthy", + "command": "provisioning workspace list", + })); + } + + Ok(json!({ + "suggestions": suggestions, + "orchestrator_healthy": orchestrator_ok, + })) + } +} + +// ── guidance_troubleshoot ───────────────────────────────────────────────────── + +pub struct GuidanceTroubleshoot { + client: Arc, + vault: Option>, +} +impl GuidanceTroubleshoot { + pub fn new(client: Arc) -> Self { + Self { client, vault: None } + } + pub fn with_vault(mut self, vault: Arc) -> Self { + self.vault = Some(vault); + self + } +} + +#[async_trait] +impl Tool for GuidanceTroubleshoot { + fn name(&self) -> &'static str { "guidance_troubleshoot" } + fn description(&self) -> &'static str { + "Diagnose common provisioning issues (orchestrator, vault, config)" + } + fn schema(&self) -> Value { + json!({ "type": "object", "properties": {}, "additionalProperties": false }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, _params: Value, _ctx: &Context) -> Result { + let mut checks: Vec = Vec::new(); + + // Orchestrator + let orch_ok = self.client.health().await.is_ok(); + checks.push(json!({ + "service": "orchestrator", + "ok": orch_ok, + "hint": if orch_ok { null_value() } else { json!("run: just start-orchestrator") }, + })); + + // Vault + if let Some(vault) = &self.vault { + let vault_ok = vault.health().await.unwrap_or(false); + checks.push(json!({ + "service": "vault", + "ok": vault_ok, + "backend": vault.backend_name(), + "hint": if vault_ok { null_value() } else { json!("check vault credentials and backend config") }, + })); + } + + // hcloud binary + let hcloud_present = which_binary("hcloud"); + checks.push(json!({ + "service": "hcloud_cli", + "ok": hcloud_present, + "hint": if hcloud_present { null_value() } else { json!("install hcloud CLI: https://github.com/hetznercloud/cli") }, + })); + + // nickel binary + let nickel_present = which_binary("nickel"); + checks.push(json!({ + "service": "nickel", + "ok": nickel_present, + "hint": if nickel_present { null_value() } else { json!("install nickel: https://nickel-lang.org") }, + })); + + let all_ok = checks.iter().all(|c| c["ok"].as_bool().unwrap_or(false)); + Ok(json!({ "all_ok": all_ok, "checks": checks })) + } +} + +fn null_value() -> Value { Value::Null } + +fn which_binary(name: &str) -> bool { + std::process::Command::new("which") + .arg(name) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +// ── guidance_config_validate ────────────────────────────────────────────────── + +pub struct GuidanceConfigValidate { + cache: Arc, +} +impl GuidanceConfigValidate { + pub fn new(cache: Arc) -> Self { Self { cache } } +} + +#[async_trait] +impl Tool for GuidanceConfigValidate { + fn name(&self) -> &'static str { "guidance_config_validate" } + fn description(&self) -> &'static str { + "Validate a Nickel config file or directory by running nickel export" + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["path"], + "properties": { + "path": { "type": "string", "description": "Absolute path to .ncl file or directory" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, _ctx: &Context) -> Result { + let raw_path = params["path"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("path", "required string"))?; + let path = std::path::PathBuf::from(raw_path); + + if path.is_file() { + return validate_single(&self.cache, &path).await; + } + if path.is_dir() { + return validate_directory(&self.cache, &path).await; + } + Err(ToolError::not_found(format!("path '{}' does not exist", path.display()))) + } +} + +async fn validate_single(cache: &NclCache, path: &std::path::Path) -> Result { + match cache.get(path).await { + Ok(_) => Ok(json!({ "valid": true, "path": path.to_string_lossy() })), + Err(e) => Ok(json!({ "valid": false, "path": path.to_string_lossy(), "error": e.to_string() })), + } +} + +async fn validate_directory(cache: &NclCache, dir: &std::path::Path) -> Result { + let mut rd = tokio::fs::read_dir(dir) + .await + .map_err(|e| ToolError::invocation(e.to_string()))?; + let mut results: Vec = Vec::new(); + while let Some(entry) = rd + .next_entry() + .await + .map_err(|e| ToolError::invocation(e.to_string()))? + { + let p = entry.path(); + if p.extension().and_then(|e| e.to_str()) == Some("ncl") { + results.push(validate_single(cache, &p).await?); + } + } + let valid_count = results.iter().filter(|r| r["valid"].as_bool().unwrap_or(false)).count(); + Ok(json!({ + "valid": valid_count == results.len(), + "total": results.len(), + "valid_count": valid_count, + "results": results, + })) +} diff --git a/crates/provisioning-core/src/tools/infra.rs b/crates/provisioning-core/src/tools/infra.rs new file mode 100644 index 0000000..9ddca22 --- /dev/null +++ b/crates/provisioning-core/src/tools/infra.rs @@ -0,0 +1,287 @@ +//! Infrastructure tools — status, technology detection, config validation, reconcile. + +use crate::protocol::{ToolCategory, ToolError}; +use crate::sources::{NclCache, OrchestratorClient, list_workspaces}; +use crate::tool::{Context, Tool}; +use async_trait::async_trait; +use serde_json::{Value, json}; +use std::sync::Arc; + +// ── infra_status ────────────────────────────────────────────────────────────── + +pub struct InfraStatus { + client: Arc, +} +impl InfraStatus { + pub fn new(client: Arc) -> Self { Self { client } } +} + +#[async_trait] +impl Tool for InfraStatus { + fn name(&self) -> &'static str { "infra_status" } + fn description(&self) -> &'static str { + "Overall infrastructure status: orchestrator health + workspace count" + } + fn schema(&self) -> Value { + json!({ "type": "object", "properties": {}, "additionalProperties": false }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, _params: Value, ctx: &Context) -> Result { + let orchestrator_ok = self.client.health().await.is_ok(); + let metrics = if orchestrator_ok { + self.client.system_metrics().await.ok() + } else { + None + }; + let workspaces = list_workspaces(&ctx.env.workspaces_root) + .await + .unwrap_or_default(); + + Ok(json!({ + "orchestrator": { "reachable": orchestrator_ok, "metrics": metrics }, + "workspaces": { "count": workspaces.len(), "names": workspaces.iter().map(|w| &w.name).collect::>() }, + })) + } +} + +// ── infra_detect ────────────────────────────────────────────────────────────── + +pub struct InfraDetect; +impl InfraDetect { + pub fn new() -> Self { Self } +} +impl Default for InfraDetect { + fn default() -> Self { Self::new() } +} + +/// Technology indicators: (file/dir pattern, technology name, confidence) +const TECH_INDICATORS: &[(&str, &str, f32)] = &[ + ("Cargo.toml", "rust", 0.95), + ("go.mod", "go", 0.95), + ("package.json", "nodejs", 0.90), + ("requirements.txt", "python", 0.85), + ("pyproject.toml", "python", 0.90), + ("Dockerfile", "docker", 0.90), + ("docker-compose.yml", "docker-compose", 0.90), + ("docker-compose.yaml", "docker-compose", 0.90), + ("*.ncl", "nickel-iac", 0.80), + ("*.nu", "nushell", 0.80), + ("justfile", "just", 0.75), + ("flake.nix", "nix", 0.90), + ("Chart.yaml", "helm", 0.90), + ("kustomization.yaml", "kustomize", 0.90), + (".terraform", "terraform", 0.90), +]; + +#[async_trait] +impl Tool for InfraDetect { + fn name(&self) -> &'static str { "infra_detect" } + fn description(&self) -> &'static str { + "Detect technologies and IaC tooling present in a directory" + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Directory to inspect (defaults to workspaces_root parent)" + } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let root = if let Some(p) = params["path"].as_str() { + std::path::PathBuf::from(p) + } else { + ctx.env + .workspaces_root + .parent() + .unwrap_or(&ctx.env.workspaces_root) + .to_path_buf() + }; + + if !root.is_dir() { + return Err(ToolError::not_found(format!("directory '{}'", root.display()))); + } + + let mut detections: Vec = Vec::new(); + for (pattern, tech, confidence) in TECH_INDICATORS { + let found = if pattern.contains('*') { + // Glob-style: check if any matching file exists + let ext = pattern.trim_start_matches("*."); + dir_contains_extension(&root, ext).await + } else { + root.join(pattern).exists() + }; + if found { + detections.push(json!({ + "technology": tech, + "confidence": confidence, + "indicator": pattern, + })); + } + } + + Ok(json!({ + "root": root.to_string_lossy(), + "detections": detections, + "total": detections.len(), + })) + } +} + +async fn dir_contains_extension(dir: &std::path::Path, ext: &str) -> bool { + let Ok(mut rd) = tokio::fs::read_dir(dir).await else { return false }; + while let Ok(Some(entry)) = rd.next_entry().await { + if entry.path().extension().and_then(|e| e.to_str()) == Some(ext) { + return true; + } + } + false +} + +// ── infra_validate ──────────────────────────────────────────────────────────── + +pub struct InfraValidate { + cache: Arc, +} +impl InfraValidate { + pub fn new(cache: Arc) -> Self { Self { cache } } +} + +#[async_trait] +impl Tool for InfraValidate { + fn name(&self) -> &'static str { "infra_validate" } + fn description(&self) -> &'static str { + "Validate all NCL config files in a workspace environment" + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["workspace", "env"], + "properties": { + "workspace": { "type": "string" }, + "env": { "type": "string" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let ws_name = params["workspace"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("workspace", "required string"))?; + let env_name = params["env"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("env", "required string"))?; + + let env_path = ctx.env.workspaces_root.join(ws_name).join("infra").join(env_name); + if !env_path.is_dir() { + return Err(ToolError::not_found(format!( + "workspace env '{ws_name}/{env_name}'" + ))); + } + + let mut results: Vec = Vec::new(); + let mut rd = tokio::fs::read_dir(&env_path) + .await + .map_err(|e| ToolError::invocation(e.to_string()))?; + while let Some(entry) = rd + .next_entry() + .await + .map_err(|e| ToolError::invocation(e.to_string()))? + { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("ncl") { + continue; + } + let r = match self.cache.get(&path).await { + Ok(_) => json!({ "path": path.to_string_lossy(), "valid": true }), + Err(e) => json!({ "path": path.to_string_lossy(), "valid": false, "error": e.to_string() }), + }; + results.push(r); + } + + let valid = results.iter().filter(|r| r["valid"].as_bool().unwrap_or(false)).count(); + Ok(json!({ + "workspace": ws_name, + "env": env_name, + "valid": valid == results.len(), + "valid_count": valid, + "total": results.len(), + "results": results, + })) + } +} + +// ── infra_reconcile ─────────────────────────────────────────────────────────── + +pub struct InfraReconcile; +impl InfraReconcile { + pub fn new() -> Self { Self } +} +impl Default for InfraReconcile { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl Tool for InfraReconcile { + fn name(&self) -> &'static str { "infra_reconcile" } + fn description(&self) -> &'static str { + "Trigger infrastructure reconciliation via POST /api/v1/infra/reconcile" + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["workspace"], + "properties": { + "workspace": { "type": "string" }, + "dry_run": { "type": "boolean", "default": false } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Mutation } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let workspace = params["workspace"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("workspace", "required string"))?; + let dry_run = params["dry_run"].as_bool().unwrap_or(false); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| ToolError::invocation(format!("http client: {e}")))?; + + let body = json!({ "workspace": workspace, "dry_run": dry_run }); + let url = format!("{}/api/v1/infra/reconcile", ctx.env.orchestrator_url); + let resp = client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| ToolError::invocation(format!("orchestrator unreachable: {e}")))?; + + let status = resp.status(); + if !status.is_success() { + return Err(ToolError::invocation(format!( + "orchestrator returned {status} for infra reconcile" + ))); + } + + let result: Value = resp + .json() + .await + .map_err(|e| ToolError::invocation(format!("parse response: {e}")))?; + + Ok(json!({ + "workspace": workspace, + "dry_run": dry_run, + "result": result, + })) + } +} diff --git a/crates/provisioning-core/src/tools/installer.rs b/crates/provisioning-core/src/tools/installer.rs new file mode 100644 index 0000000..c5f31ab --- /dev/null +++ b/crates/provisioning-core/src/tools/installer.rs @@ -0,0 +1,230 @@ +//! Installer / settings tools — query, validate, defaults for platform config. +//! +//! Reads the platform configuration via `platform-config` crate. +//! Settings live at `PROVISIONING_CONFIG_DIR` / `config.toml` or defaults. + +use crate::protocol::{ToolCategory, ToolError}; +use crate::tool::{Context, Tool}; +use async_trait::async_trait; +use serde_json::{Value, json}; + +const DEPLOYMENT_MODES: &[&str] = &["solo", "multiuser", "cicd", "enterprise"]; + +const REQUIRED_FIELDS: &[&str] = &[ + "orchestrator.port", + "storage.backend", + "vault.backend", +]; + +// ── installer_settings_get ──────────────────────────────────────────────────── + +pub struct InstallerSettingsGet; +impl InstallerSettingsGet { + pub fn new() -> Self { Self } +} +impl Default for InstallerSettingsGet { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl Tool for InstallerSettingsGet { + fn name(&self) -> &'static str { "installer_settings_get" } + fn description(&self) -> &'static str { + "Read current platform installer settings from PROVISIONING_CONFIG_DIR" + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Dot-notation key to read (e.g. 'orchestrator.port'). Omit for all." + } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, _ctx: &Context) -> Result { + let settings = load_platform_settings()?; + if let Some(key) = params["key"].as_str() { + let val = dot_get(&settings, key); + Ok(json!({ "key": key, "value": val })) + } else { + Ok(settings) + } + } +} + +// ── installer_settings_validate ─────────────────────────────────────────────── + +pub struct InstallerSettingsValidate; +impl InstallerSettingsValidate { + pub fn new() -> Self { Self } +} +impl Default for InstallerSettingsValidate { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl Tool for InstallerSettingsValidate { + fn name(&self) -> &'static str { "installer_settings_validate" } + fn description(&self) -> &'static str { + "Validate that required platform settings are present and well-formed" + } + fn schema(&self) -> Value { + json!({ "type": "object", "properties": {}, "additionalProperties": false }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, _params: Value, _ctx: &Context) -> Result { + let settings = match load_platform_settings() { + Ok(s) => s, + Err(e) => { + return Ok(json!({ + "valid": false, + "issues": [e.to_string()], + })); + } + }; + + let mut issues: Vec = Vec::new(); + for field in REQUIRED_FIELDS { + if dot_get(&settings, field).is_null() { + issues.push(format!("missing required field: {field}")); + } + } + + Ok(json!({ + "valid": issues.is_empty(), + "issues": issues, + })) + } +} + +// ── installer_settings_defaults ─────────────────────────────────────────────── + +pub struct InstallerSettingsDefaults; +impl InstallerSettingsDefaults { + pub fn new() -> Self { Self } +} +impl Default for InstallerSettingsDefaults { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl Tool for InstallerSettingsDefaults { + fn name(&self) -> &'static str { "installer_settings_defaults" } + fn description(&self) -> &'static str { + "Return mode-specific default settings (solo / multiuser / cicd / enterprise)" + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["solo", "multiuser", "cicd", "enterprise"], + "default": "solo" + } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, _ctx: &Context) -> Result { + let mode = params["mode"].as_str().unwrap_or("solo"); + if !DEPLOYMENT_MODES.contains(&mode) { + return Err(ToolError::invalid_param( + "mode", + format!("unknown mode '{mode}'; expected one of: {}", DEPLOYMENT_MODES.join(", ")), + )); + } + Ok(defaults_for(mode)) + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn load_platform_settings() -> Result { + let config_dir = std::env::var("PROVISIONING_CONFIG_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| { + dirs::config_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("provisioning") + .join("platform") + }); + + // Try TOML, then JSON + for name in &["config.toml", "orchestrator.toml", "orchestrator.json"] { + let path = config_dir.join(name); + if path.exists() { + let content = std::fs::read_to_string(&path) + .map_err(|e| ToolError::invocation(format!("read {}: {e}", path.display())))?; + if path.extension().and_then(|e| e.to_str()) == Some("toml") { + let t: toml::Value = toml::from_str(&content) + .map_err(|e| ToolError::invocation(format!("parse TOML: {e}")))?; + return toml_to_json(t); + } + return serde_json::from_str(&content) + .map_err(|e| ToolError::invocation(format!("parse JSON: {e}"))); + } + } + + Ok(defaults_for("solo")) +} + +fn dot_get(obj: &Value, key: &str) -> Value { + let mut cur = obj; + for part in key.split('.') { + match cur.get(part) { + Some(v) => cur = v, + None => return Value::Null, + } + } + cur.clone() +} + +fn toml_to_json(val: toml::Value) -> Result { + serde_json::to_value(val) + .map_err(|e| ToolError::invocation(format!("convert TOML→JSON: {e}"))) +} + +fn defaults_for(mode: &str) -> Value { + match mode { + "solo" => json!({ + "mode": "solo", + "orchestrator": { "port": 9011, "workers": 2 }, + "storage": { "backend": "filesystem", "path": "./data" }, + "vault": { "backend": "age" }, + "auth": { "solo_mode": true }, + "nats": { "enabled": false }, + }), + "multiuser" => json!({ + "mode": "multiuser", + "orchestrator": { "port": 9011, "workers": 4 }, + "storage": { "backend": "surrealdb" }, + "vault": { "backend": "rustyvault" }, + "auth": { "solo_mode": false, "jwt_required": true }, + "nats": { "enabled": true, "url": "nats://localhost:4222" }, + }), + "cicd" => json!({ + "mode": "cicd", + "orchestrator": { "port": 9011, "workers": 8 }, + "storage": { "backend": "surrealdb" }, + "vault": { "backend": "aws_kms" }, + "auth": { "solo_mode": false, "jwt_required": true }, + "nats": { "enabled": true }, + }), + "enterprise" => json!({ + "mode": "enterprise", + "orchestrator": { "port": 9011, "workers": 16 }, + "storage": { "backend": "surrealdb", "ha": true }, + "vault": { "backend": "cosmian" }, + "auth": { "solo_mode": false, "jwt_required": true, "mfa": true }, + "nats": { "enabled": true, "cluster": true }, + }), + other => json!({ "mode": other, "error": "unknown mode" }), + } +} diff --git a/crates/provisioning-core/src/tools/mod.rs b/crates/provisioning-core/src/tools/mod.rs new file mode 100644 index 0000000..f2242ce --- /dev/null +++ b/crates/provisioning-core/src/tools/mod.rs @@ -0,0 +1,22 @@ +//! Tool implementations — one module per domain. +//! +//! Each module exports concrete structs implementing `crate::tool::Tool`. +//! `Registry::with_all_tools()` (B11) constructs and registers all of them. + +pub mod agents; +pub mod cluster; +pub mod component; +pub mod dag; +pub mod dashboard; +pub mod extension; +pub mod guidance; +pub mod infra; +pub mod installer; +pub mod monitoring; +pub mod ontology; +pub mod orchestrator; +pub mod server; +pub mod taskserv; +pub mod vault; +pub mod volume; +pub mod workspace; diff --git a/crates/provisioning-core/src/tools/monitoring.rs b/crates/provisioning-core/src/tools/monitoring.rs new file mode 100644 index 0000000..064a593 --- /dev/null +++ b/crates/provisioning-core/src/tools/monitoring.rs @@ -0,0 +1,133 @@ +//! Monitoring tools — system metrics, aggregate health, statistics. + +use crate::protocol::{ToolCategory, ToolError}; +use crate::sources::OrchestratorClient; +use crate::tool::{Context, Tool}; +use async_trait::async_trait; +use serde_json::{Value, json}; +use std::sync::Arc; + +// ── monitoring_metrics ──────────────────────────────────────────────────────── + +pub struct MonitoringMetrics { + client: Arc, +} +impl MonitoringMetrics { + pub fn new(client: Arc) -> Self { Self { client } } +} + +#[async_trait] +impl Tool for MonitoringMetrics { + fn name(&self) -> &'static str { "monitoring_metrics" } + fn description(&self) -> &'static str { + "Retrieve system metrics from the orchestrator (GET /state/system/metrics)" + } + fn schema(&self) -> Value { + json!({ "type": "object", "properties": {}, "additionalProperties": false }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, _params: Value, _ctx: &Context) -> Result { + self.client.system_metrics().await + } +} + +// ── monitoring_health ───────────────────────────────────────────────────────── + +pub struct MonitoringHealth { + client: Arc, +} +impl MonitoringHealth { + pub fn new(client: Arc) -> Self { Self { client } } +} + +#[async_trait] +impl Tool for MonitoringHealth { + fn name(&self) -> &'static str { "monitoring_health" } + fn description(&self) -> &'static str { + "Aggregate health check: orchestrator + job queue state" + } + fn schema(&self) -> Value { + json!({ "type": "object", "properties": {}, "additionalProperties": false }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, _params: Value, _ctx: &Context) -> Result { + let orch_ok = self.client.health().await.is_ok(); + let detail = if orch_ok { + self.client.system_health().await.ok() + } else { + None + }; + + let tasks = if orch_ok { + self.client.list_tasks().await.unwrap_or_default() + } else { + Vec::new() + }; + + let pending = tasks.iter().filter(|t| t.status.to_string() == "Pending").count(); + let running = tasks.iter().filter(|t| t.status.to_string() == "Running").count(); + let failed = tasks.iter().filter(|t| t.status.to_string() == "Failed").count(); + + Ok(json!({ + "healthy": orch_ok && failed == 0, + "orchestrator": { + "reachable": orch_ok, + "detail": detail, + }, + "jobs": { + "pending": pending, + "running": running, + "failed": failed, + }, + })) + } +} + +// ── monitoring_statistics ───────────────────────────────────────────────────── + +pub struct MonitoringStatistics; +impl MonitoringStatistics { + pub fn new() -> Self { Self } +} +impl Default for MonitoringStatistics { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl Tool for MonitoringStatistics { + fn name(&self) -> &'static str { "monitoring_statistics" } + fn description(&self) -> &'static str { + "Retrieve orchestrator statistics (GET /state/statistics)" + } + fn schema(&self) -> Value { + json!({ "type": "object", "properties": {}, "additionalProperties": false }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, _params: Value, ctx: &Context) -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| ToolError::invocation(format!("http client: {e}")))?; + + let url = format!("{}/state/statistics", ctx.env.orchestrator_url); + let resp = client + .get(&url) + .send() + .await + .map_err(|e| ToolError::invocation(format!("orchestrator unreachable: {e}")))?; + + if !resp.status().is_success() { + return Err(ToolError::invocation(format!( + "orchestrator returned {} for statistics", + resp.status() + ))); + } + + resp.json::() + .await + .map_err(|e| ToolError::invocation(format!("parse response: {e}"))) + } +} diff --git a/crates/provisioning-core/src/tools/ontology.rs b/crates/provisioning-core/src/tools/ontology.rs new file mode 100644 index 0000000..3e35252 --- /dev/null +++ b/crates/provisioning-core/src/tools/ontology.rs @@ -0,0 +1,259 @@ +//! Ontology domain tools — search, node, graph. +//! +//! Reads the project-level ontology from `Environment::ontology_root` +//! (`core.ncl`, `state.ncl`, `manifest.ncl`). Returns provisioning's own +//! domain ontology — not ontoref-daemon's cross-project graph. + +use crate::protocol::{ToolCategory, ToolError}; +use crate::sources::NclCache; +use crate::tool::{Context, Tool}; +use async_trait::async_trait; +use serde_json::{Value, json}; +use std::sync::Arc; + +// ── Shared helpers ──────────────────────────────────────────────────────────── + +async fn load_core(cache: &NclCache, ctx: &Context) -> Result { + let path = ctx.env.ontology_root.join("core.ncl"); + if !path.exists() { + return Err(ToolError::not_found(format!( + "ontology core.ncl not found at {}", + ctx.env.ontology_root.display() + ))); + } + let v = cache.get(&path).await?; + Ok((*v).clone()) +} + +fn collect_nodes(core: &Value) -> Vec { + // Expected shape: { nodes: [ { id, kind, label, description, ... }, ... ] } + // Also handles flat record shape from ontoref: { "NodeId": { ... }, ... } + if let Some(arr) = core["nodes"].as_array() { + return arr.clone(); + } + // Flat record shape: top-level keys are node IDs + if let Some(obj) = core.as_object() { + return obj + .iter() + .filter(|(k, _)| !k.starts_with('_')) + .map(|(k, v)| { + let mut node = v.clone(); + if node["id"].is_null() { + node["id"] = json!(k); + } + node + }) + .collect(); + } + Vec::new() +} + +// ── ontology_search ─────────────────────────────────────────────────────────── + +pub struct OntologySearch { + cache: Arc, +} +impl OntologySearch { + pub fn new(cache: Arc) -> Self { Self { cache } } +} + +#[async_trait] +impl Tool for OntologySearch { + fn name(&self) -> &'static str { "ontology_search" } + fn description(&self) -> &'static str { + "Search ontology nodes by term (id, label, description, tags)" + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["query"], + "properties": { + "query": { "type": "string" }, + "kind": { "type": "string", "description": "Filter by node kind" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let query = params["query"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("query", "required string"))? + .to_lowercase(); + let kind_filter = params["kind"].as_str(); + + let core = load_core(&self.cache, ctx).await?; + let nodes = collect_nodes(&core); + + let matched: Vec<&Value> = nodes + .iter() + .filter(|n| { + if let Some(k) = kind_filter { + if n["kind"].as_str().unwrap_or("") != k { + return false; + } + } + let id = n["id"].as_str().unwrap_or("").to_lowercase(); + let label = n["label"].as_str().unwrap_or("").to_lowercase(); + let desc = n["description"].as_str().unwrap_or("").to_lowercase(); + id.contains(&query) || label.contains(&query) || desc.contains(&query) + }) + .collect(); + + Ok(json!({ "items": matched, "total": matched.len(), "query": query })) + } +} + +// ── ontology_node ───────────────────────────────────────────────────────────── + +pub struct OntologyNode { + cache: Arc, +} +impl OntologyNode { + pub fn new(cache: Arc) -> Self { Self { cache } } +} + +#[async_trait] +impl Tool for OntologyNode { + fn name(&self) -> &'static str { "ontology_node" } + fn description(&self) -> &'static str { "Get a single ontology node by id" } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "string" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let id = params["id"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("id", "required string"))?; + + let core = load_core(&self.cache, ctx).await?; + + // Try flat record first (key = id), then nodes array + if let Some(obj) = core.as_object() { + if let Some(node) = obj.get(id) { + let mut n = node.clone(); + n["id"] = json!(id); + return Ok(n); + } + } + if let Some(arr) = core["nodes"].as_array() { + if let Some(n) = arr.iter().find(|n| n["id"].as_str() == Some(id)) { + return Ok(n.clone()); + } + } + + Err(ToolError::not_found(format!("ontology node '{id}'"))) + } +} + +// ── ontology_graph ──────────────────────────────────────────────────────────── + +pub struct OntologyGraph { + cache: Arc, +} +impl OntologyGraph { + pub fn new(cache: Arc) -> Self { Self { cache } } +} + +#[async_trait] +impl Tool for OntologyGraph { + fn name(&self) -> &'static str { "ontology_graph" } + fn description(&self) -> &'static str { + "Return the ontology as a graph (nodes + edges) for visualisation" + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "format": { "type": "string", "enum": ["json", "dot"], "default": "json" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let format = params["format"].as_str().unwrap_or("json"); + let core = load_core(&self.cache, ctx).await?; + + match format { + "dot" => { + let dot = ontology_to_dot(&core); + Ok(json!({ "format": "dot", "content": dot })) + } + _ => { + let nodes = collect_nodes(&core); + let edges = core["edges"].as_array().cloned().unwrap_or_default(); + Ok(json!({ "nodes": nodes, "edges": edges, "total_nodes": nodes.len() })) + } + } + } +} + +fn ontology_to_dot(core: &Value) -> String { + let nodes = collect_nodes(core); + let edges = core["edges"].as_array().cloned().unwrap_or_default(); + + let mut lines = vec!["digraph ontology {".to_owned(), " rankdir=LR;".to_owned()]; + + for node in &nodes { + let id = node["id"].as_str().unwrap_or("?"); + let label = node["label"].as_str().or(node["description"].as_str()).unwrap_or(id); + let safe_id = id.replace(['-', '.', ' '], "_"); + lines.push(format!(r#" {safe_id} [label="{label}"];"#)); + } + for edge in &edges { + let from = edge["from"].as_str().or(edge["source"].as_str()).unwrap_or("?"); + let to = edge["to"].as_str().or(edge["target"].as_str()).unwrap_or("?"); + let rel = edge["relation"].as_str().or(edge["label"].as_str()).unwrap_or(""); + let f = from.replace(['-', '.', ' '], "_"); + let t = to.replace(['-', '.', ' '], "_"); + lines.push(format!(r#" {f} -> {t} [label="{rel}"];"#)); + } + lines.push("}".to_owned()); + lines.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collect_nodes_from_array() { + let core = json!({ "nodes": [{ "id": "a", "label": "A" }] }); + let nodes = collect_nodes(&core); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0]["id"], "a"); + } + + #[test] + fn collect_nodes_from_flat_record() { + let core = json!({ "practice_a": { "label": "Practice A" } }); + let nodes = collect_nodes(&core); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0]["id"], "practice_a"); + } + + #[test] + fn ontology_to_dot_produces_digraph() { + let core = json!({ + "nodes": [ + { "id": "n1", "label": "Node 1" }, + { "id": "n2", "label": "Node 2" } + ], + "edges": [ + { "from": "n1", "to": "n2", "relation": "uses" } + ] + }); + let dot = ontology_to_dot(&core); + assert!(dot.contains("digraph")); + assert!(dot.contains("n1 -> n2")); + assert!(dot.contains("uses")); + } +} diff --git a/crates/provisioning-core/src/tools/orchestrator.rs b/crates/provisioning-core/src/tools/orchestrator.rs new file mode 100644 index 0000000..28d3e98 --- /dev/null +++ b/crates/provisioning-core/src/tools/orchestrator.rs @@ -0,0 +1,256 @@ +//! Orchestrator domain tools — job inspection and workflow dispatch. +//! +//! Read tools (list/show) use the shared `OrchestratorClient`. +//! `orchestrator_run_workflow` dispatches to the correct workflow endpoint by type. +//! `orchestrator_job_cancel` attempts `POST /tasks/{id}/cancel`; returns an invocation +//! error if the orchestrator does not support the endpoint (graceful degradation). + +use crate::protocol::{ToolCategory, ToolError}; +use crate::sources::OrchestratorClient; +use crate::tool::{Context, Tool}; +use async_trait::async_trait; +use serde_json::{Value, json}; +use std::sync::Arc; + +// ── orchestrator_jobs ───────────────────────────────────────────────────────── + +pub struct OrchestratorJobs { + client: Arc, +} +impl OrchestratorJobs { + pub fn new(client: Arc) -> Self { Self { client } } +} + +#[async_trait] +impl Tool for OrchestratorJobs { + fn name(&self) -> &'static str { "orchestrator_jobs" } + fn description(&self) -> &'static str { "List recent orchestrator jobs (tasks)" } + fn schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["Pending", "Running", "Completed", "Failed", "Cancelled"], + "description": "Filter by task state" + } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, _ctx: &Context) -> Result { + let status_filter = params["status"].as_str(); + let mut tasks = self.client.list_tasks().await?; + + if let Some(f) = status_filter { + tasks.retain(|t| t.status.to_string() == f); + } + tasks.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + + let items: Vec = tasks + .into_iter() + .map(|t| serde_json::to_value(t).unwrap_or(Value::Null)) + .collect(); + + Ok(json!({ "items": items, "total": items.len() })) + } +} + +// ── orchestrator_job_show ───────────────────────────────────────────────────── + +pub struct OrchestratorJobShow { + client: Arc, +} +impl OrchestratorJobShow { + pub fn new(client: Arc) -> Self { Self { client } } +} + +#[async_trait] +impl Tool for OrchestratorJobShow { + fn name(&self) -> &'static str { "orchestrator_job_show" } + fn description(&self) -> &'static str { "Show details of a specific orchestrator job" } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "string", "description": "Task UUID" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, _ctx: &Context) -> Result { + let id = params["id"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("id", "required string"))?; + let task = self.client.get_task(id).await?; + serde_json::to_value(task) + .map_err(|e| ToolError::invocation(format!("serialize task: {e}"))) + } +} + +// ── orchestrator_job_cancel ─────────────────────────────────────────────────── + +pub struct OrchestratorJobCancel; +impl OrchestratorJobCancel { + pub fn new() -> Self { Self } +} +impl Default for OrchestratorJobCancel { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl Tool for OrchestratorJobCancel { + fn name(&self) -> &'static str { "orchestrator_job_cancel" } + fn description(&self) -> &'static str { + "Cancel a running orchestrator job (POST /tasks/{id}/cancel)" + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "string", "description": "Task UUID to cancel" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Mutation } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let id = params["id"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("id", "required string"))?; + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| ToolError::invocation(format!("http client: {e}")))?; + + let url = format!("{}/tasks/{id}/cancel", ctx.env.orchestrator_url); + let resp = client + .post(&url) + .send() + .await + .map_err(|e| ToolError::invocation(format!("orchestrator unreachable: {e}")))?; + + let status = resp.status(); + if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::METHOD_NOT_ALLOWED { + return Err(ToolError::invocation( + "orchestrator does not support job cancellation — kill the process manually", + )); + } + if !status.is_success() { + return Err(ToolError::invocation(format!( + "orchestrator returned {status} cancelling task {id}" + ))); + } + + Ok(json!({ "id": id, "cancelled": true })) + } +} + +// ── orchestrator_run_workflow ───────────────────────────────────────────────── + +pub struct OrchestratorRunWorkflow; +impl OrchestratorRunWorkflow { + pub fn new() -> Self { Self } +} +impl Default for OrchestratorRunWorkflow { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl Tool for OrchestratorRunWorkflow { + fn name(&self) -> &'static str { "orchestrator_run_workflow" } + fn description(&self) -> &'static str { + "Dispatch an arbitrary workflow to the orchestrator (servers, cluster, or taskserv)" + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["kind", "infra", "settings"], + "properties": { + "kind": { + "type": "string", + "enum": ["servers", "cluster", "taskserv"], + "description": "Workflow type" + }, + "infra": { "type": "string" }, + "settings": { "type": "string" }, + "payload": { + "type": "object", + "description": "Extra fields merged into the workflow body (e.g. taskserv, cluster_type, servers)", + "additionalProperties": true + } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Mutation } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let kind = params["kind"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("kind", "required string"))?; + let infra = params["infra"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("infra", "required string"))?; + let settings = params["settings"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("settings", "required string"))?; + + let endpoint = match kind { + "servers" => "workflows/servers/create", + "cluster" => "workflows/cluster/create", + "taskserv" => "workflows/taskserv/create", + other => { + return Err(ToolError::invalid_param( + "kind", + format!("unknown workflow kind '{other}'"), + )) + } + }; + + let mut body = params["payload"] + .as_object() + .cloned() + .map(serde_json::Value::Object) + .unwrap_or_else(|| json!({})); + + body["infra"] = json!(infra); + body["settings"] = json!(settings); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| ToolError::invocation(format!("http client: {e}")))?; + + let url = format!("{}/{endpoint}", ctx.env.orchestrator_url); + let resp = client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| ToolError::invocation(format!("orchestrator unreachable: {e}")))?; + + let status = resp.status(); + if !status.is_success() { + return Err(ToolError::invocation(format!( + "orchestrator returned {status} for {kind} workflow" + ))); + } + + let result: Value = resp + .json() + .await + .map_err(|e| ToolError::invocation(format!("parse response: {e}")))?; + + Ok(json!({ + "kind": kind, + "infra": infra, + "task_id": result["data"], + "status": "queued", + })) + } +} diff --git a/crates/provisioning-core/src/tools/server.rs b/crates/provisioning-core/src/tools/server.rs new file mode 100644 index 0000000..cc8d881 --- /dev/null +++ b/crates/provisioning-core/src/tools/server.rs @@ -0,0 +1,197 @@ +//! Server domain tools — list, show, create, delete. +//! +//! Read operations use the injected `HcloudClient` (hcloud CLI over JSON output). +//! Create is enqueued via the orchestrator workflow API (`POST /workflows/servers/create`). +//! Delete is a synchronous Destructive call through the hcloud CLI — the orchestrator +//! has no server-delete workflow endpoint. + +use crate::protocol::{ToolCategory, ToolError}; +use crate::providers::hcloud::HcloudClient; +use crate::tool::{Context, Tool}; +use async_trait::async_trait; +use serde_json::{Value, json}; +use std::sync::Arc; + +// ── server_list ─────────────────────────────────────────────────────────────── + +pub struct ServerList { + hcloud: Arc, +} +impl ServerList { + pub fn new(hcloud: Arc) -> Self { Self { hcloud } } +} + +#[async_trait] +impl Tool for ServerList { + fn name(&self) -> &'static str { "server_list" } + fn description(&self) -> &'static str { "List all Hetzner Cloud servers in the active context" } + fn schema(&self) -> Value { + json!({ "type": "object", "properties": {} }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, _params: Value, _ctx: &Context) -> Result { + let servers = self.hcloud.server_list().await?; + let items = servers.as_array().cloned().unwrap_or_default(); + Ok(json!({ "items": items, "total": items.len() })) + } +} + +// ── server_show ─────────────────────────────────────────────────────────────── + +pub struct ServerShow { + hcloud: Arc, +} +impl ServerShow { + pub fn new(hcloud: Arc) -> Self { Self { hcloud } } +} + +#[async_trait] +impl Tool for ServerShow { + fn name(&self) -> &'static str { "server_show" } + fn description(&self) -> &'static str { "Show details of a specific Hetzner Cloud server" } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string", "description": "Server name or ID" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, _ctx: &Context) -> Result { + let name = params["name"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("name", "required string"))?; + self.hcloud.server_describe(name).await + } +} + +// ── server_create ───────────────────────────────────────────────────────────── + +pub struct ServerCreate; +impl ServerCreate { + pub fn new() -> Self { Self } +} +impl Default for ServerCreate { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl Tool for ServerCreate { + fn name(&self) -> &'static str { "server_create" } + fn description(&self) -> &'static str { + "Enqueue a server creation workflow via the orchestrator" + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["infra", "settings"], + "properties": { + "infra": { "type": "string", "description": "Infrastructure name" }, + "settings": { "type": "string", "description": "Settings NCL path" }, + "servers": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Specific server names; empty means all in infra" + }, + "check_mode": { "type": "boolean", "default": false }, + "wait": { "type": "boolean", "default": true } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Mutation } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let infra = params["infra"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("infra", "required string"))?; + let settings = params["settings"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("settings", "required string"))?; + let servers: Vec = params["servers"] + .as_array() + .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()) + .unwrap_or_default(); + let check_mode = params["check_mode"].as_bool().unwrap_or(false); + let wait = params["wait"].as_bool().unwrap_or(true); + + let body = json!({ + "infra": infra, + "settings": settings, + "servers": servers, + "check_mode": check_mode, + "wait": wait, + }); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| ToolError::invocation(format!("http client: {e}")))?; + + let url = format!("{}/workflows/servers/create", ctx.env.orchestrator_url); + let resp = client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| ToolError::invocation(format!("orchestrator unreachable: {e}")))?; + + let status = resp.status(); + if !status.is_success() { + return Err(ToolError::invocation(format!( + "orchestrator returned {status} for server create" + ))); + } + + let result: Value = resp + .json() + .await + .map_err(|e| ToolError::invocation(format!("parse response: {e}")))?; + + Ok(json!({ + "infra": infra, + "servers": servers, + "task_id": result["data"], + "status": "queued", + })) + } +} + +// ── server_delete ───────────────────────────────────────────────────────────── + +pub struct ServerDelete { + hcloud: Arc, +} +impl ServerDelete { + pub fn new(hcloud: Arc) -> Self { Self { hcloud } } +} + +#[async_trait] +impl Tool for ServerDelete { + fn name(&self) -> &'static str { "server_delete" } + fn description(&self) -> &'static str { + "Delete a Hetzner Cloud server (irreversible — disables protection first)" + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string", "description": "Server name or ID" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Destructive } + + async fn invoke(&self, params: Value, _ctx: &Context) -> Result { + let name = params["name"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("name", "required string"))?; + self.hcloud.server_delete(name).await?; + Ok(json!({ "name": name, "deleted": true })) + } +} diff --git a/crates/provisioning-core/src/tools/taskserv.rs b/crates/provisioning-core/src/tools/taskserv.rs new file mode 100644 index 0000000..ee637ef --- /dev/null +++ b/crates/provisioning-core/src/tools/taskserv.rs @@ -0,0 +1,105 @@ +//! Taskserv domain tool — deploy via orchestrator. + +use crate::protocol::{ToolCategory, ToolError}; +use crate::tool::{Context, Tool}; +use async_trait::async_trait; +use serde_json::{Value, json}; + +// ── taskserv_deploy ─────────────────────────────────────────────────────────── + +pub struct TaskservDeploy; + +impl TaskservDeploy { + pub fn new() -> Self { Self } +} + +impl Default for TaskservDeploy { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl Tool for TaskservDeploy { + fn name(&self) -> &'static str { "taskserv_deploy" } + fn description(&self) -> &'static str { + "Deploy a taskserv via the orchestrator (create, delete, generate, or check-updates)" + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["infra", "settings", "taskserv", "operation"], + "properties": { + "infra": { "type": "string", "description": "Infrastructure name" }, + "settings": { "type": "string", "description": "Settings NCL path" }, + "taskserv": { "type": "string", "description": "Taskserv name" }, + "operation": { + "type": "string", + "enum": ["create", "delete", "generate", "check-updates"], + "description": "Operation to perform" + }, + "check_mode": { "type": "boolean", "default": false }, + "wait": { "type": "boolean", "default": true } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Mutation } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let infra = params["infra"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("infra", "required string"))?; + let settings = params["settings"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("settings", "required string"))?; + let taskserv = params["taskserv"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("taskserv", "required string"))?; + let operation = params["operation"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("operation", "required string"))?; + let check_mode = params["check_mode"].as_bool().unwrap_or(false); + let wait = params["wait"].as_bool().unwrap_or(true); + + // Enqueue via orchestrator POST /workflows/taskserv/create + let body = json!({ + "infra": infra, + "settings": settings, + "taskserv": taskserv, + "operation": operation, + "check_mode": check_mode, + "wait": wait, + }); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| ToolError::invocation(format!("http client: {e}")))?; + + let url = format!("{}/workflows/taskserv/create", ctx.env.orchestrator_url); + let resp = client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| ToolError::invocation(format!("orchestrator unreachable: {e}")))?; + + let status = resp.status(); + if !status.is_success() { + return Err(ToolError::invocation(format!( + "orchestrator returned {status} for taskserv deploy" + ))); + } + + let result: Value = resp + .json() + .await + .map_err(|e| ToolError::invocation(format!("parse response: {e}")))?; + + Ok(json!({ + "taskserv": taskserv, + "operation": operation, + "infra": infra, + "task_id": result["data"], + "status": "queued", + })) + } +} diff --git a/crates/provisioning-core/src/tools/vault.rs b/crates/provisioning-core/src/tools/vault.rs new file mode 100644 index 0000000..4df0120 --- /dev/null +++ b/crates/provisioning-core/src/tools/vault.rs @@ -0,0 +1,177 @@ +//! Vault domain tools — health/seal status, key operations. +//! +//! These tools expose the KMS backend (Age/Cosmian/RustyVault/SecretumVault) through +//! the common Tool interface. "Seal/unseal" maps to health-check semantics — the +//! underlying backends use different auth models, so unseal is best-effort. + +use crate::protocol::{ToolCategory, ToolError}; +use crate::sources::VaultSource; +use crate::tool::{Context, Tool}; +use async_trait::async_trait; +use serde_json::{Value, json}; +use std::sync::Arc; + +// ── vault_seal_status ───────────────────────────────────────────────────────── + +pub struct VaultSealStatus { + vault: Arc, +} +impl VaultSealStatus { + pub fn new(vault: Arc) -> Self { Self { vault } } +} + +#[async_trait] +impl Tool for VaultSealStatus { + fn name(&self) -> &'static str { "vault_seal_status" } + fn description(&self) -> &'static str { + "Check if the KMS vault backend is reachable (sealed = unreachable)" + } + fn schema(&self) -> Value { + json!({ "type": "object", "properties": {}, "additionalProperties": false }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, _params: Value, _ctx: &Context) -> Result { + match self.vault.health().await { + Ok(healthy) => Ok(json!({ + "sealed": !healthy, + "healthy": healthy, + "backend": self.vault.backend_name(), + })), + Err(_) => Ok(json!({ + "sealed": true, + "healthy": false, + "backend": self.vault.backend_name(), + })), + } + } +} + +// ── vault_list ──────────────────────────────────────────────────────────────── + +pub struct VaultList { + vault: Arc, +} +impl VaultList { + pub fn new(vault: Arc) -> Self { Self { vault } } +} + +#[async_trait] +impl Tool for VaultList { + fn name(&self) -> &'static str { "vault_list" } + fn description(&self) -> &'static str { + "List vault backend info and available KMS operations" + } + fn schema(&self) -> Value { + json!({ "type": "object", "properties": {}, "additionalProperties": false }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, _params: Value, _ctx: &Context) -> Result { + let healthy = self.vault.health().await.unwrap_or(false); + Ok(json!({ + "backend": self.vault.backend_name(), + "healthy": healthy, + "operations": ["encrypt", "decrypt", "generate_data_key"], + })) + } +} + +// ── vault_get ───────────────────────────────────────────────────────────────── + +pub struct VaultGet { + vault: Arc, +} +impl VaultGet { + pub fn new(vault: Arc) -> Self { Self { vault } } +} + +#[async_trait] +impl Tool for VaultGet { + fn name(&self) -> &'static str { "vault_get" } + fn description(&self) -> &'static str { + "Decrypt a ciphertext using the KMS backend (hex-encoded input and output)" + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["ciphertext"], + "properties": { + "ciphertext": { + "type": "string", + "description": "Hex-encoded ciphertext to decrypt" + }, + "namespace": { + "type": "string", + "description": "Encryption namespace / context label", + "default": "default" + } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, _ctx: &Context) -> Result { + use vault_service::EncryptionContext; + + let hex = params["ciphertext"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("ciphertext", "required string"))?; + let namespace = params["namespace"].as_str().unwrap_or("default"); + + let ciphertext = hex::decode(hex).map_err(|e| { + ToolError::invalid_param("ciphertext", format!("invalid hex: {e}")) + })?; + + let mut ctx = EncryptionContext::new(); + ctx.add("namespace", namespace); + + let plaintext = self.vault.decrypt(&ciphertext, &ctx).await?; + let result_hex = hex::encode(&plaintext); + + Ok(json!({ + "namespace": namespace, + "plaintext": result_hex, + "backend": self.vault.backend_name(), + })) + } +} + +// ── vault_unseal ────────────────────────────────────────────────────────────── + +pub struct VaultUnseal { + vault: Arc, +} +impl VaultUnseal { + pub fn new(vault: Arc) -> Self { Self { vault } } +} + +#[async_trait] +impl Tool for VaultUnseal { + fn name(&self) -> &'static str { "vault_unseal" } + fn description(&self) -> &'static str { + "Attempt to unseal the vault backend — verifies connectivity and generates a probe data key" + } + fn schema(&self) -> Value { + json!({ "type": "object", "properties": {}, "additionalProperties": false }) + } + fn category(&self) -> ToolCategory { ToolCategory::Destructive } + + async fn invoke(&self, _params: Value, _ctx: &Context) -> Result { + use vault_service::KeySpec; + + let backend = self.vault.backend_name(); + + let spec = KeySpec::Aes256; + match self.vault.generate_data_key(&spec).await { + Ok(_) => Ok(json!({ + "unsealed": true, + "backend": backend, + })), + Err(e) => { + // Connectivity failure — backend may truly be sealed / unreachable + Err(ToolError::invocation(format!("unseal probe failed: {e}"))) + } + } + } +} diff --git a/crates/provisioning-core/src/tools/volume.rs b/crates/provisioning-core/src/tools/volume.rs new file mode 100644 index 0000000..3eeab30 --- /dev/null +++ b/crates/provisioning-core/src/tools/volume.rs @@ -0,0 +1,65 @@ +//! Volume domain tools — list and show Hetzner Cloud volumes. + +use crate::protocol::{ToolCategory, ToolError}; +use crate::providers::hcloud::HcloudClient; +use crate::tool::{Context, Tool}; +use async_trait::async_trait; +use serde_json::{Value, json}; +use std::sync::Arc; + +// ── volume_list ─────────────────────────────────────────────────────────────── + +pub struct VolumeList { + hcloud: Arc, +} +impl VolumeList { + pub fn new(hcloud: Arc) -> Self { Self { hcloud } } +} + +#[async_trait] +impl Tool for VolumeList { + fn name(&self) -> &'static str { "volume_list" } + fn description(&self) -> &'static str { "List all Hetzner Cloud volumes in the active context" } + fn schema(&self) -> Value { + json!({ "type": "object", "properties": {} }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, _params: Value, _ctx: &Context) -> Result { + let volumes = self.hcloud.volume_list().await?; + let items = volumes.as_array().cloned().unwrap_or_default(); + Ok(json!({ "items": items, "total": items.len() })) + } +} + +// ── volume_show ─────────────────────────────────────────────────────────────── + +pub struct VolumeShow { + hcloud: Arc, +} +impl VolumeShow { + pub fn new(hcloud: Arc) -> Self { Self { hcloud } } +} + +#[async_trait] +impl Tool for VolumeShow { + fn name(&self) -> &'static str { "volume_show" } + fn description(&self) -> &'static str { "Show details of a specific Hetzner Cloud volume" } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string", "description": "Volume name or ID" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, _ctx: &Context) -> Result { + let name = params["name"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("name", "required string"))?; + self.hcloud.volume_describe(name).await + } +} diff --git a/crates/provisioning-core/src/tools/workspace.rs b/crates/provisioning-core/src/tools/workspace.rs new file mode 100644 index 0000000..a0d7cd4 --- /dev/null +++ b/crates/provisioning-core/src/tools/workspace.rs @@ -0,0 +1,358 @@ +//! Workspace domain tools — list, show, active, register, dag, validate. + +use crate::protocol::{SmartResponse, ToolCategory, ToolError}; +use crate::sources::{ + NclCache, get_workspace, list_components, list_infra_envs, list_workspaces, list_workflows, +}; +use crate::tool::{Context, Tool}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::path::PathBuf; +use std::sync::Arc; + +// ── Response types ──────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceSummary { + pub name: String, + pub path: String, + pub environments: Vec, +} + +impl SmartResponse for WorkspaceSummary { + fn summary(&self) -> String { + format!("{} ({} env(s))", self.name, self.environments.len()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct UserConfig { + #[serde(default)] + active_workspace: Option, +} + +// ── User config helpers ─────────────────────────────────────────────────────── + +fn user_config_path() -> Option { + // Honour explicit override first (mirrors Nushell get-user-config-dir logic). + if let Ok(dir) = std::env::var("PROVISIONING_USER_CONFIG_DIR") { + return Some(PathBuf::from(dir).join("user_config.yaml")); + } + dirs::config_dir().map(|d| d.join("provisioning").join("user_config.yaml")) +} + +fn read_user_config() -> UserConfig { + let path = match user_config_path() { + Some(p) if p.exists() => p, + _ => return UserConfig { active_workspace: None }, + }; + let contents = std::fs::read_to_string(&path).unwrap_or_default(); + serde_yaml::from_str(&contents).unwrap_or(UserConfig { active_workspace: None }) +} + +fn write_user_config(cfg: &UserConfig) -> Result<(), ToolError> { + let path = user_config_path() + .ok_or_else(|| ToolError::invocation("cannot resolve user config path"))?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| ToolError::invocation(format!("create config dir: {e}")))?; + } + let yaml = serde_yaml::to_string(cfg) + .map_err(|e| ToolError::invocation(format!("serialize user config: {e}")))?; + std::fs::write(&path, yaml) + .map_err(|e| ToolError::invocation(format!("write user config: {e}"))) +} + +// ── workspace_list ──────────────────────────────────────────────────────────── + +pub struct WorkspaceList; + +#[async_trait] +impl Tool for WorkspaceList { + fn name(&self) -> &'static str { "workspace_list" } + fn description(&self) -> &'static str { "List all registered workspaces" } + fn schema(&self) -> Value { + json!({ "type": "object", "properties": {}, "additionalProperties": false }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, _params: Value, ctx: &Context) -> Result { + let root = &ctx.env.workspaces_root; + let entries = list_workspaces(root).await?; + let active = read_user_config().active_workspace; + + let mut summaries = Vec::with_capacity(entries.len()); + for ws in &entries { + let envs = list_infra_envs(&ws.name, &ws.path).await?; + summaries.push(json!({ + "name": ws.name, + "path": ws.path.to_string_lossy(), + "active": active.as_deref() == Some(ws.name.as_str()), + "environments": envs.iter().map(|e| &e.environment).collect::>(), + })); + } + + Ok(json!({ "items": summaries, "total": summaries.len() })) + } +} + +// ── workspace_show ──────────────────────────────────────────────────────────── + +pub struct WorkspaceShow { + cache: Arc, +} + +impl WorkspaceShow { + pub fn new(cache: Arc) -> Self { Self { cache } } +} + +#[async_trait] +impl Tool for WorkspaceShow { + fn name(&self) -> &'static str { "workspace_show" } + fn description(&self) -> &'static str { "Show workspace details including infra environments and NCL settings" } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string", "description": "Workspace name" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let name = params["name"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("name", "required string"))?; + + let root = &ctx.env.workspaces_root; + let ws = get_workspace(root, name).await?; + let envs = list_infra_envs(name, &ws.path).await?; + let active = read_user_config().active_workspace; + + let mut env_details = Vec::with_capacity(envs.len()); + for env in &envs { + let comps = list_components(name, &env.environment, &env.path).await?; + let flows = list_workflows(name, &env.environment, &env.path).await?; + + // Try to export settings.ncl if present + let settings_path = env.path.join("settings.ncl"); + let settings = if settings_path.exists() { + self.cache.get(&settings_path).await.ok().map(|v| (*v).clone()) + } else { + None + }; + + env_details.push(json!({ + "name": env.environment, + "components": comps.iter().map(|c| &c.name).collect::>(), + "workflows": flows.iter().map(|w| &w.name).collect::>(), + "settings": settings, + })); + } + + Ok(json!({ + "name": name, + "path": ws.path.to_string_lossy(), + "active": active.as_deref() == Some(name), + "environments": env_details, + })) + } +} + +// ── workspace_active ────────────────────────────────────────────────────────── + +pub struct WorkspaceActive; + +#[async_trait] +impl Tool for WorkspaceActive { + fn name(&self) -> &'static str { "workspace_active" } + fn description(&self) -> &'static str { "Show the currently active workspace" } + fn schema(&self) -> Value { + json!({ "type": "object", "properties": {}, "additionalProperties": false }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, _params: Value, ctx: &Context) -> Result { + let cfg = read_user_config(); + match cfg.active_workspace { + None => Ok(json!({ "active": null })), + Some(name) => { + let root = &ctx.env.workspaces_root; + let exists = root.join(&name).is_dir(); + Ok(json!({ "active": name, "exists": exists })) + } + } + } +} + +// ── workspace_register ──────────────────────────────────────────────────────── + +pub struct WorkspaceRegister; + +#[async_trait] +impl Tool for WorkspaceRegister { + fn name(&self) -> &'static str { "workspace_register" } + fn description(&self) -> &'static str { "Set the active workspace (writes to user config)" } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string", "description": "Workspace name to activate" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Mutation } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let name = params["name"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("name", "required string"))?; + + // Verify workspace exists on disk before registering. + get_workspace(&ctx.env.workspaces_root, name).await?; + + let mut cfg = read_user_config(); + let previous = cfg.active_workspace.take(); + cfg.active_workspace = Some(name.to_owned()); + write_user_config(&cfg)?; + + Ok(json!({ + "activated": name, + "previous": previous, + })) + } +} + +// ── workspace_dag ───────────────────────────────────────────────────────────── + +pub struct WorkspaceDag { + cache: Arc, +} + +impl WorkspaceDag { + pub fn new(cache: Arc) -> Self { Self { cache } } +} + +#[async_trait] +impl Tool for WorkspaceDag { + fn name(&self) -> &'static str { "workspace_dag" } + fn description(&self) -> &'static str { "List DAG files for a workspace, optionally filtered by environment" } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "env": { "type": "string", "description": "Filter to a specific environment" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let name = params["name"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("name", "required string"))?; + let env_filter = params["env"].as_str(); + + let root = &ctx.env.workspaces_root; + let ws = get_workspace(root, name).await?; + let envs = list_infra_envs(name, &ws.path).await?; + + let mut dags = Vec::new(); + for env in &envs { + if let Some(f) = env_filter { + if env.environment != f { + continue; + } + } + let dag_path = env.path.join("dag.ncl"); + if !dag_path.exists() { + continue; + } + // Prepend the workspace directory so non-relative imports like + // "infra/lib/..." resolve against the workspace root. + let ws_ip = ws.path.to_string_lossy(); + let content = self.cache.get_with_path(&dag_path, Some(ws_ip.as_ref())).await.ok().map(|v| (*v).clone()); + dags.push(json!({ + "workspace": name, + "env": env.environment, + "path": dag_path.to_string_lossy(), + "dag": content, + })); + } + + Ok(json!({ "items": dags, "total": dags.len() })) + } +} + +// ── workspace_validate ──────────────────────────────────────────────────────── + +pub struct WorkspaceValidate { + cache: Arc, +} + +impl WorkspaceValidate { + pub fn new(cache: Arc) -> Self { Self { cache } } +} + +#[async_trait] +impl Tool for WorkspaceValidate { + fn name(&self) -> &'static str { "workspace_validate" } + fn description(&self) -> &'static str { "Validate NCL config files in a workspace" } + fn schema(&self) -> Value { + json!({ + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" } + } + }) + } + fn category(&self) -> ToolCategory { ToolCategory::Read } + + async fn invoke(&self, params: Value, ctx: &Context) -> Result { + let name = params["name"] + .as_str() + .ok_or_else(|| ToolError::invalid_param("name", "required string"))?; + + let root = &ctx.env.workspaces_root; + let ws = get_workspace(root, name).await?; + let envs = list_infra_envs(name, &ws.path).await?; + + let mut errors: Vec = Vec::new(); + let mut checked = 0u32; + + for env in &envs { + // Check every .ncl file in the env directory (non-recursive, top level only) + let mut dir = tokio::fs::read_dir(&env.path).await + .map_err(|e| ToolError::invocation(e.to_string()))?; + while let Some(entry) = dir.next_entry().await + .map_err(|e| ToolError::invocation(e.to_string()))? + { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("ncl") { + continue; + } + checked += 1; + if let Err(e) = self.cache.get(&path).await { + errors.push(json!({ + "file": path.to_string_lossy(), + "error": e.to_string(), + })); + } + } + } + + Ok(json!({ + "workspace": name, + "valid": errors.is_empty(), + "files_checked": checked, + "errors": errors, + })) + } +} diff --git a/crates/provisioning-daemon/Cargo.toml b/crates/provisioning-daemon/Cargo.toml new file mode 100644 index 0000000..ad76db3 --- /dev/null +++ b/crates/provisioning-daemon/Cargo.toml @@ -0,0 +1,42 @@ +[package] +authors.workspace = true +edition.workspace = true +license.workspace = true +name = "provisioning-daemon" +repository.workspace = true +version.workspace = true +description = "Registry-backed HTTP+NATS daemon for the provisioning platform" + +[[bin]] +name = "provisioning-daemon" +path = "src/main.rs" + +[dependencies] +provisioning-core = { path = "../provisioning-core" } +platform-config = { path = "../platform-config" } +platform-nats = { path = "../platform-nats" } + +tokio = { workspace = true } +axum = { workspace = true } +tower-http = { workspace = true, features = ["cors", "trace", "timeout"] } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +chrono = { workspace = true } +uuid = { workspace = true } +notify = { workspace = true } +jsonwebtoken = { workspace = true } +reqwest = { workspace = true } +clap = { workspace = true } +tera = { workspace = true } +shellexpand = { workspace = true } +rust-embed = "8.5" +mime_guess = "2.0" + +[dev-dependencies] +tokio-test = { workspace = true } +tempfile = { workspace = true } +rustls = { version = "0.23", features = ["aws_lc_rs"] } diff --git a/crates/provisioning-daemon/admin/app.js b/crates/provisioning-daemon/admin/app.js new file mode 100644 index 0000000..d818c4a --- /dev/null +++ b/crates/provisioning-daemon/admin/app.js @@ -0,0 +1,73 @@ +'use strict'; + +const tokenInput = document.getElementById('token'); +const panels = { + health: document.getElementById('health'), + tools: document.getElementById('tools'), + stats: document.getElementById('stats'), + recent: document.getElementById('recent'), +}; + +function headers() { + const t = tokenInput.value.trim(); + return t ? { 'Authorization': `Bearer ${t}` } : {}; +} + +async function fetchJson(path) { + const res = await fetch(path, { headers: headers() }); + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + return res.json(); +} + +function render(panel, html) { + panels[panel].innerHTML = html; +} + +async function showHealth() { + try { + const data = await fetchJson('/health'); + render('health', `
${JSON.stringify(data, null, 2)}
`); + } catch (e) { render('health', `

${e.message}

`); } +} + +async function showTools() { + try { + const data = await fetchJson('/api/v1/tools'); + const rows = data.tools.map(t => + `${t.name}${t.category}${t.description}` + ).join(''); + render('tools', `${rows}
NameCategoryDescription
`); + } catch (e) { render('tools', `

${e.message}

`); } +} + +async function showStats() { + try { + const data = await fetchJson('/api/v1/state/stats'); + render('stats', `
${JSON.stringify(data, null, 2)}
`); + } catch (e) { render('stats', `

${e.message}

`); } +} + +async function showRecent() { + try { + const data = await fetchJson('/api/v1/state/recent'); + const rows = data.invocations.map(r => + `${r.invoked_at}${r.tool}${r.duration_ms}ms${r.outcome.status}` + ).join(''); + render('recent', `${rows}
WhenToolDurationOutcome
`); + } catch (e) { render('recent', `

${e.message}

`); } +} + +const loaders = { health: showHealth, tools: showTools, stats: showStats, recent: showRecent }; + +document.querySelectorAll('nav button').forEach(btn => { + btn.addEventListener('click', () => { + const target = btn.dataset.panel; + document.querySelectorAll('nav button').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + Object.values(panels).forEach(p => p.hidden = true); + panels[target].hidden = false; + loaders[target](); + }); +}); + +document.querySelector('nav button[data-panel="health"]').click(); diff --git a/crates/provisioning-daemon/admin/index.html b/crates/provisioning-daemon/admin/index.html new file mode 100644 index 0000000..b2d1ff0 --- /dev/null +++ b/crates/provisioning-daemon/admin/index.html @@ -0,0 +1,29 @@ + + + + + Provisioning Daemon — Admin + + + +
+

Provisioning Daemon

+ +
+ +
+
+
+ + + + +
+ + + diff --git a/crates/provisioning-daemon/admin/style.css b/crates/provisioning-daemon/admin/style.css new file mode 100644 index 0000000..1deed7e --- /dev/null +++ b/crates/provisioning-daemon/admin/style.css @@ -0,0 +1,19 @@ +* { box-sizing: border-box; margin: 0; padding: 0; } +body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #111; color: #eee; } +header { padding: 1rem 1.5rem; background: #1a1a1a; border-bottom: 1px solid #333; display: flex; align-items: center; gap: 1.5rem; } +header h1 { font-size: 1.1rem; font-weight: 600; color: #7aa6ff; } +nav { display: flex; gap: 0.5rem; } +nav button { background: #222; color: #ccc; border: 1px solid #333; padding: 0.4rem 0.9rem; cursor: pointer; border-radius: 4px; font-size: 0.85rem; } +nav button:hover, nav button.active { background: #2a3a5a; color: #fff; border-color: #4a6aa0; } +#auth input { background: #222; color: #ccc; border: 1px solid #333; padding: 0.4rem 0.6rem; border-radius: 4px; font-size: 0.85rem; width: 260px; } +main { padding: 1.5rem; } +section { background: #1a1a1a; border: 1px solid #2a2a2a; border-radius: 6px; padding: 1rem; } +pre { font-family: "SF Mono", Menlo, monospace; font-size: 0.8rem; white-space: pre-wrap; color: #b8d4b8; } +.error { color: #ff7a7a; } +table { width: 100%; border-collapse: collapse; font-size: 0.85rem; } +th, td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid #2a2a2a; } +th { color: #7aa6ff; font-weight: 600; } +td.cat-read { color: #8fb8ff; } +td.cat-mutation { color: #ffd97a; } +td.cat-destructive { color: #ff9a7a; } +td.cat-admin { color: #ff7ab8; } diff --git a/crates/provisioning-daemon/ontology_templates/overview.md.tera b/crates/provisioning-daemon/ontology_templates/overview.md.tera new file mode 100644 index 0000000..449440e --- /dev/null +++ b/crates/provisioning-daemon/ontology_templates/overview.md.tera @@ -0,0 +1,14 @@ +# {{ project }} — Ontology Overview + +**Generated**: {{ generated_at }} + +## Tools +{% for tool in tools -%} +- `{{ tool.name }}` ({{ tool.category }}) — {{ tool.description }} +{% endfor %} +## State +- Total invocations: **{{ stats.total_invocations }}** +- Active sessions: **{{ stats.active_sessions }}** +{% if stats.secs_since_config_reload -%} +- Last config reload: {{ stats.secs_since_config_reload }}s ago +{% endif %} diff --git a/crates/provisioning-daemon/src/auth/jwt.rs b/crates/provisioning-daemon/src/auth/jwt.rs new file mode 100644 index 0000000..c6567c0 --- /dev/null +++ b/crates/provisioning-daemon/src/auth/jwt.rs @@ -0,0 +1,213 @@ +//! HS256 JWT validation and role-based access control for provisioning-daemon. + +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use provisioning_core::protocol::ToolCategory; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AuthError { + #[error("missing or malformed Authorization header")] + MissingToken, + #[error("invalid token: {0}")] + Invalid(String), + #[error("insufficient role: need {required}, got {actual:?}")] + Forbidden { required: &'static str, actual: Vec }, + #[error("HS256 secret must be at least 32 bytes (got {0})")] + WeakSecret(usize), +} + +/// RFC 7518 §3.2 — HS256 HMAC key length must be >= hash output (32 bytes). +const MIN_HS256_SECRET_BYTES: usize = 32; + +// ── Claims ──────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Claims { + /// Subject — user or service identity. + pub sub: String, + /// Expiry (Unix timestamp). + pub exp: u64, + /// Roles granted to this principal. + #[serde(default)] + pub roles: Vec, +} + +impl Claims { + pub fn has_role(&self, role: &str) -> bool { + self.roles.iter().any(|r| r == role) + } +} + +// ── JwtConfig ───────────────────────────────────────────────────────────────── + +#[derive(Clone)] +pub struct JwtConfig { + encoding_key: EncodingKey, + decoding_key: DecodingKey, + validation: Validation, +} + +impl JwtConfig { + /// Build from a shared HMAC-SHA256 secret. Rejects secrets shorter than 32 bytes + /// per RFC 7518 §3.2 (HMAC key length must match hash output). + pub fn from_secret(secret: &[u8]) -> Result { + if secret.len() < MIN_HS256_SECRET_BYTES { + return Err(AuthError::WeakSecret(secret.len())); + } + let mut validation = Validation::new(Algorithm::HS256); + validation.validate_exp = true; + Ok(Self { + encoding_key: EncodingKey::from_secret(secret), + decoding_key: DecodingKey::from_secret(secret), + validation, + }) + } + + pub fn sign(&self, claims: &Claims) -> Result { + encode(&Header::default(), claims, &self.encoding_key) + .map_err(|e| AuthError::Invalid(e.to_string())) + } + + pub fn validate(&self, token: &str) -> Result { + decode::(token, &self.decoding_key, &self.validation) + .map(|td| td.claims) + .map_err(|e| AuthError::Invalid(e.to_string())) + } + + /// Extract Bearer token from an `Authorization` header value. + /// Scheme match is case-insensitive per RFC 6750 §2.1. + pub fn extract_bearer(header: &str) -> Result<&str, AuthError> { + let (scheme, token) = header.split_once(' ').ok_or(AuthError::MissingToken)?; + if !scheme.eq_ignore_ascii_case("bearer") || token.is_empty() { + return Err(AuthError::MissingToken); + } + Ok(token) + } +} + +// ── RbacPolicy ──────────────────────────────────────────────────────────────── + +/// Maps `ToolCategory` to the minimum required role. +pub struct RbacPolicy; + +impl RbacPolicy { + /// Returns `Ok(())` when `claims` satisfies the category's required role. + pub fn check(claims: &Claims, category: ToolCategory) -> Result<(), AuthError> { + let required = Self::required_role(category); + if required.is_empty() || claims.has_role(required) || claims.has_role("admin") { + Ok(()) + } else { + Err(AuthError::Forbidden { + required, + actual: claims.roles.clone(), + }) + } + } + + fn required_role(category: ToolCategory) -> &'static str { + match category { + ToolCategory::Read => "", // any authenticated caller + ToolCategory::Mutation => "operator", + ToolCategory::Destructive => "admin", + ToolCategory::Admin => "admin", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + sync::OnceLock, + time::{SystemTime, UNIX_EPOCH}, + }; + + // Both ring and aws-lc-rs are in the dep tree (surrealdb pulls both); + // jsonwebtoken panics unless exactly one CryptoProvider is installed. + static CRYPTO: OnceLock<()> = OnceLock::new(); + fn setup_crypto() { + CRYPTO.get_or_init(|| { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + }); + } + + fn now_plus(secs: u64) -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + secs + } + + fn cfg() -> JwtConfig { + JwtConfig::from_secret(b"test-secret-key-long-enough-for-hs256-32b").expect("32+ bytes") + } + + fn claims(roles: &[&str]) -> Claims { + Claims { sub: "test".into(), exp: now_plus(3600), roles: roles.iter().map(|r| r.to_string()).collect() } + } + + #[test] + fn sign_and_validate_roundtrip() { + setup_crypto(); + let cfg = cfg(); + let c = claims(&["operator"]); + let token = cfg.sign(&c).unwrap(); + let back = cfg.validate(&token).unwrap(); + assert_eq!(back.sub, "test"); + assert!(back.has_role("operator")); + } + + #[test] + fn expired_token_rejected() { + setup_crypto(); + let cfg = cfg(); + let c = Claims { sub: "t".into(), exp: 1, roles: vec![] }; + let token = cfg.sign(&c).unwrap(); + assert!(cfg.validate(&token).is_err()); + } + + #[test] + fn rbac_read_allows_any() { + let c = claims(&[]); + assert!(RbacPolicy::check(&c, ToolCategory::Read).is_ok()); + } + + #[test] + fn rbac_mutation_requires_operator() { + let c_plain = claims(&[]); + let c_operator = claims(&["operator"]); + assert!(RbacPolicy::check(&c_plain, ToolCategory::Mutation).is_err()); + assert!(RbacPolicy::check(&c_operator, ToolCategory::Mutation).is_ok()); + } + + #[test] + fn rbac_destructive_requires_admin() { + let c_operator = claims(&["operator"]); + let c_admin = claims(&["admin"]); + assert!(RbacPolicy::check(&c_operator, ToolCategory::Destructive).is_err()); + assert!(RbacPolicy::check(&c_admin, ToolCategory::Destructive).is_ok()); + } + + #[test] + fn extract_bearer_strips_prefix() { + assert_eq!(JwtConfig::extract_bearer("Bearer abc123").unwrap(), "abc123"); + assert!(JwtConfig::extract_bearer("Basic xyz").is_err()); + assert!(JwtConfig::extract_bearer("Bearer ").is_err()); + } + + #[test] + fn extract_bearer_is_case_insensitive() { + assert_eq!(JwtConfig::extract_bearer("bearer tok").unwrap(), "tok"); + assert_eq!(JwtConfig::extract_bearer("BEARER tok").unwrap(), "tok"); + assert_eq!(JwtConfig::extract_bearer("BeArEr tok").unwrap(), "tok"); + } + + #[test] + fn from_secret_rejects_short_key() { + let result = JwtConfig::from_secret(b"too-short"); + assert!(matches!(result.err(), Some(AuthError::WeakSecret(9)))); + } + + #[test] + fn from_secret_accepts_32_byte_key() { + assert!(JwtConfig::from_secret(&[0u8; 32]).is_ok()); + } +} diff --git a/crates/provisioning-daemon/src/auth/mod.rs b/crates/provisioning-daemon/src/auth/mod.rs new file mode 100644 index 0000000..d445f0d --- /dev/null +++ b/crates/provisioning-daemon/src/auth/mod.rs @@ -0,0 +1,3 @@ +pub mod jwt; + +pub use jwt::{Claims, JwtConfig, RbacPolicy}; diff --git a/crates/provisioning-daemon/src/domain_state.rs b/crates/provisioning-daemon/src/domain_state.rs new file mode 100644 index 0000000..6d2e387 --- /dev/null +++ b/crates/provisioning-daemon/src/domain_state.rs @@ -0,0 +1,214 @@ +//! In-memory domain state shared across the HTTP router and background workers. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{ + collections::{HashMap, VecDeque}, + sync::{Arc, RwLock}, + time::Instant, +}; +use uuid::Uuid; + +const MAX_RECENT: usize = 100; + +// ── Records ─────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolRecord { + pub tool: String, + pub params: Value, + pub outcome: InvocationOutcome, + pub duration_ms: u64, + pub invoked_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum InvocationOutcome { + Ok { value: Value }, + Err { code: i32, message: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionInfo { + pub id: String, + pub opened_at: DateTime, + pub tool_count: u32, + /// Subject (`Claims.sub`) of the principal who opened this session. + /// Used to prevent cross-session counter tampering. + pub opened_by: String, +} + +// ── DomainState ─────────────────────────────────────────────────────────────── + +struct Inner { + recent: VecDeque, + sessions: HashMap, + last_config_reload: Option, + total_invocations: u64, +} + +/// Cheap-clone shared state for provisioning-daemon — wrap in `Arc` once, +/// pass clones to axum `State` and background tasks. +#[derive(Clone)] +pub struct DomainState(Arc>); + +impl DomainState { + pub fn new() -> Self { + Self(Arc::new(RwLock::new(Inner { + recent: VecDeque::with_capacity(MAX_RECENT), + sessions: HashMap::new(), + last_config_reload: None, + total_invocations: 0, + }))) + } + + /// Record a completed tool invocation (success or failure). + pub fn record_invocation(&self, record: ToolRecord) { + let mut g = self.0.write().expect("domain state poisoned"); + g.total_invocations += 1; + if g.recent.len() >= MAX_RECENT { + g.recent.pop_front(); + } + g.recent.push_back(record); + } + + /// Return the `n` most recent invocations (newest last). + pub fn recent(&self, n: usize) -> Vec { + let g = self.0.read().expect("domain state poisoned"); + g.recent.iter().rev().take(n).cloned().collect::>().into_iter().rev().collect() + } + + pub fn open_session(&self, opened_by: impl Into) -> String { + let id = Uuid::new_v4().to_string(); + let mut g = self.0.write().expect("domain state poisoned"); + g.sessions.insert( + id.clone(), + SessionInfo { + id: id.clone(), + opened_at: Utc::now(), + tool_count: 0, + opened_by: opened_by.into(), + }, + ); + id + } + + pub fn close_session(&self, id: &str) { + let mut g = self.0.write().expect("domain state poisoned"); + g.sessions.remove(id); + } + + /// Increment the tool counter for `id` only when `principal` matches the + /// session's opener. Returns `true` on a successful increment, `false` + /// when the session is unknown or owned by a different principal + /// (prevents cross-session counter tampering). + pub fn increment_session_tool_count(&self, id: &str, principal: &str) -> bool { + let mut g = self.0.write().expect("domain state poisoned"); + match g.sessions.get_mut(id) { + Some(s) if s.opened_by == principal => { + s.tool_count += 1; + true + } + _ => false, + } + } + + pub fn sessions(&self) -> Vec { + let g = self.0.read().expect("domain state poisoned"); + g.sessions.values().cloned().collect() + } + + pub fn mark_config_reload(&self) { + let mut g = self.0.write().expect("domain state poisoned"); + g.last_config_reload = Some(Instant::now()); + } + + /// Seconds since last config reload, or `None` if never reloaded. + pub fn secs_since_reload(&self) -> Option { + let g = self.0.read().expect("domain state poisoned"); + g.last_config_reload.map(|t| t.elapsed().as_secs()) + } + + pub fn total_invocations(&self) -> u64 { + let g = self.0.read().expect("domain state poisoned"); + g.total_invocations + } +} + +impl Default for DomainState { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn ok_record(tool: &str) -> ToolRecord { + ToolRecord { + tool: tool.to_owned(), + params: json!({}), + outcome: InvocationOutcome::Ok { value: json!({"ok": true}) }, + duration_ms: 10, + invoked_at: Utc::now(), + } + } + + #[test] + fn record_and_recent() { + let state = DomainState::new(); + state.record_invocation(ok_record("tool_a")); + state.record_invocation(ok_record("tool_b")); + let recent = state.recent(10); + assert_eq!(recent.len(), 2); + assert_eq!(recent[0].tool, "tool_a"); + assert_eq!(recent[1].tool, "tool_b"); + } + + #[test] + fn recent_capped_at_max() { + let state = DomainState::new(); + for i in 0..=MAX_RECENT { + state.record_invocation(ok_record(&format!("tool_{i}"))); + } + assert_eq!(state.recent(MAX_RECENT + 10).len(), MAX_RECENT); + } + + #[test] + fn session_lifecycle() { + let state = DomainState::new(); + let id = state.open_session("alice"); + assert_eq!(state.sessions().len(), 1); + assert!(state.increment_session_tool_count(&id, "alice")); + state.close_session(&id); + assert!(state.sessions().is_empty()); + } + + #[test] + fn cross_principal_increment_rejected() { + let state = DomainState::new(); + let id = state.open_session("alice"); + assert!(!state.increment_session_tool_count(&id, "mallory")); + assert!(!state.increment_session_tool_count("nonexistent", "alice")); + } + + #[test] + fn total_invocations_increments() { + let state = DomainState::new(); + state.record_invocation(ok_record("t")); + state.record_invocation(ok_record("t")); + assert_eq!(state.total_invocations(), 2); + } + + #[test] + fn config_reload_marks_time() { + let state = DomainState::new(); + assert!(state.secs_since_reload().is_none()); + state.mark_config_reload(); + assert!(state.secs_since_reload().is_some()); + } +} diff --git a/crates/provisioning-daemon/src/events.rs b/crates/provisioning-daemon/src/events.rs new file mode 100644 index 0000000..27a8967 --- /dev/null +++ b/crates/provisioning-daemon/src/events.rs @@ -0,0 +1,143 @@ +//! Typed NATS event bus for provisioning-daemon domain events. + +use chrono::{DateTime, Utc}; +use platform_nats::NatsBridge; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::PathBuf; + +// ── Event types ─────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum DaemonEvent { + ToolInvoked(ToolInvokedEvent), + ConfigChanged(ConfigChangedEvent), + SessionOpened { session_id: String, at: DateTime }, + SessionClosed { session_id: String, at: DateTime }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolInvokedEvent { + pub tool: String, + pub params: Value, + pub result: ToolOutcome, + pub duration_ms: u64, + pub at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum ToolOutcome { + Ok { value: Value }, + Err { code: i32, message: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigChangedEvent { + pub path: PathBuf, + /// The type of filesystem change. Named `change` (not `kind`) to avoid + /// collision with the `#[serde(tag = "kind")]` discriminant on `DaemonEvent`. + pub change: ChangeKind, + pub at: DateTime, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ChangeKind { + Create, + Modify, + Remove, +} + +// ── EventBus ───────────────────────────────────────────────────────────────── + +/// Publishes domain events to NATS JetStream. +/// +/// All subjects are relative to the bridge's configured prefix +/// (default: `provisioning.daemon.*`). +#[derive(Clone)] +pub struct EventBus { + bridge: NatsBridge, +} + +impl EventBus { + pub fn new(bridge: NatsBridge) -> Self { + Self { bridge } + } + + pub async fn publish(&self, event: &DaemonEvent) -> anyhow::Result<()> { + let subject = match event { + DaemonEvent::ToolInvoked(_) => "daemon.tools.invoked", + DaemonEvent::ConfigChanged(_) => "daemon.config.changed", + DaemonEvent::SessionOpened { .. } => "daemon.sessions.opened", + DaemonEvent::SessionClosed { .. } => "daemon.sessions.closed", + }; + self.bridge + .publish_json(subject, event) + .await + .map(|_| ()) + .map_err(|e| anyhow::anyhow!("NATS publish to {subject}: {e}")) + } + + /// Build a `ToolInvokedEvent` from a successful invocation. + pub fn tool_ok(tool: &str, params: Value, value: Value, duration_ms: u64) -> DaemonEvent { + DaemonEvent::ToolInvoked(ToolInvokedEvent { + tool: tool.to_owned(), + params, + result: ToolOutcome::Ok { value }, + duration_ms, + at: Utc::now(), + }) + } + + /// Build a `ToolInvokedEvent` from a failed invocation. + pub fn tool_err( + tool: &str, + params: Value, + code: i32, + message: String, + duration_ms: u64, + ) -> DaemonEvent { + DaemonEvent::ToolInvoked(ToolInvokedEvent { + tool: tool.to_owned(), + params, + result: ToolOutcome::Err { code, message }, + duration_ms, + at: Utc::now(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn tool_ok_event_serializes() { + let ev = EventBus::tool_ok("workspace_list", json!({}), json!({"workspaces":[]}), 42); + let s = serde_json::to_string(&ev).unwrap(); + assert!(s.contains("\"kind\":\"tool_invoked\"")); + assert!(s.contains("\"status\":\"ok\"")); + } + + #[test] + fn tool_err_event_serializes() { + let ev = EventBus::tool_err("workspace_list", json!({}), -32000, "fail".into(), 5); + let s = serde_json::to_string(&ev).unwrap(); + assert!(s.contains("\"status\":\"err\"")); + } + + #[test] + fn config_changed_event_roundtrips() { + let ev = DaemonEvent::ConfigChanged(ConfigChangedEvent { + path: PathBuf::from("/tmp/test.ncl"), + change: ChangeKind::Modify, + at: Utc::now(), + }); + let s = serde_json::to_string(&ev).unwrap(); + let back: DaemonEvent = serde_json::from_str(&s).unwrap(); + assert!(matches!(back, DaemonEvent::ConfigChanged(_))); + } +} diff --git a/crates/provisioning-daemon/src/http/admin.rs b/crates/provisioning-daemon/src/http/admin.rs new file mode 100644 index 0000000..f1690e0 --- /dev/null +++ b/crates/provisioning-daemon/src/http/admin.rs @@ -0,0 +1,80 @@ +//! Compile-time embedded admin UI served under `/admin/*`. +//! +//! Assets live in `admin/` at the crate root and are baked into the binary +//! by `rust-embed` — no runtime filesystem dependency. + +use axum::{ + extract::Path, + http::{StatusCode, header}, + response::{IntoResponse, Redirect, Response}, + routing::get, + Router, +}; +use rust_embed::RustEmbed; + +#[derive(RustEmbed)] +#[folder = "admin/"] +struct AdminAssets; + +pub fn admin_router() -> Router { + Router::new() + .route("/admin", get(|| async { Redirect::permanent("/admin/") })) + .route("/admin/", get(serve_index)) + .route("/admin/{*path}", get(serve_asset)) +} + +async fn serve_index() -> Response { + serve_named("index.html") +} + +async fn serve_asset(Path(path): Path) -> Response { + // Guard against traversal — RustEmbed already normalises, but reject + // explicit `..` segments defensively. + if path.split('/').any(|seg| seg == "..") { + return (StatusCode::BAD_REQUEST, "invalid path").into_response(); + } + serve_named(&path) +} + +fn serve_named(name: &str) -> Response { + match AdminAssets::get(name) { + Some(file) => { + let mime = mime_guess::from_path(name).first_or_octet_stream(); + ( + StatusCode::OK, + [(header::CONTENT_TYPE, mime.as_ref().to_owned())], + file.data.into_owned(), + ) + .into_response() + } + None => (StatusCode::NOT_FOUND, "not found").into_response(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn index_html_is_embedded() { + let f = AdminAssets::get("index.html").expect("index.html must be embedded"); + let body = std::str::from_utf8(&f.data).unwrap(); + assert!(body.contains("Provisioning Daemon")); + } + + #[test] + fn js_and_css_embedded() { + assert!(AdminAssets::get("app.js").is_some()); + assert!(AdminAssets::get("style.css").is_some()); + } + + #[test] + fn traversal_path_rejected_before_lookup() { + // serve_asset explicitly rejects ".." segments. The lookup itself + // would also fail, but the guard gives a clearer 400 vs 404. + let segments = ["..", "etc/passwd"]; + for s in segments { + assert!(s == ".." || !s.contains("..")); + } + } +} diff --git a/crates/provisioning-daemon/src/http/middleware.rs b/crates/provisioning-daemon/src/http/middleware.rs new file mode 100644 index 0000000..cb7f560 --- /dev/null +++ b/crates/provisioning-daemon/src/http/middleware.rs @@ -0,0 +1,74 @@ +//! Axum extractors and middleware for JWT auth + RBAC. + +use axum::{ + body::Body, + extract::State, + http::{Request, StatusCode, header::AUTHORIZATION}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use serde_json::json; + +use crate::AppState; +use crate::auth::{Claims, RbacPolicy}; + +/// Axum middleware: validate Bearer JWT and attach `Claims` as an extension. +/// +/// Routes that opt in via `.layer(from_fn_with_state(state, auth_layer))` +/// can then extract `Extension` to access the caller identity and roles. +pub async fn auth_layer( + State(app): State, + mut req: Request, + next: Next, +) -> Response { + let Some(ref jwt) = app.jwt else { + // Solo mode: inject synthetic admin Claims so downstream handlers that + // extract `Extension` don't 500 on MissingExtension. Expiry is + // u64::MAX so time-based validation (if ever re-checked) never trips. + let claims = crate::auth::Claims { + sub: "solo".into(), + exp: u64::MAX, + roles: vec!["admin".into()], + }; + req.extensions_mut().insert(claims); + return next.run(req).await; + }; + + let auth_header = req + .headers() + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()); + + let Some(header_val) = auth_header else { + return error_response(StatusCode::UNAUTHORIZED, "missing Authorization header"); + }; + + let token = match crate::auth::JwtConfig::extract_bearer(header_val) { + Ok(t) => t, + Err(_) => return error_response(StatusCode::UNAUTHORIZED, "malformed Authorization header"), + }; + + let claims = match jwt.validate(token) { + Ok(c) => c, + Err(e) => return error_response(StatusCode::UNAUTHORIZED, &e.to_string()), + }; + + req.extensions_mut().insert(claims); + next.run(req).await +} + +/// Utility: check RBAC for a resolved tool category. +/// +/// Returns `Ok(())` when the caller has sufficient privileges, or a +/// `403 Forbidden` response body when they do not. +pub fn check_rbac( + claims: &Claims, + category: provisioning_core::protocol::ToolCategory, +) -> Result<(), Box> { + RbacPolicy::check(claims, category) + .map_err(|e| Box::new(error_response(StatusCode::FORBIDDEN, &e.to_string()))) +} + +fn error_response(status: StatusCode, msg: &str) -> Response { + (status, axum::Json(json!({"error": msg}))).into_response() +} diff --git a/crates/provisioning-daemon/src/http/mod.rs b/crates/provisioning-daemon/src/http/mod.rs new file mode 100644 index 0000000..11b00c6 --- /dev/null +++ b/crates/provisioning-daemon/src/http/mod.rs @@ -0,0 +1,5 @@ +pub mod admin; +pub mod middleware; +pub mod routes; + +pub use routes::{raw_router, router}; diff --git a/crates/provisioning-daemon/src/http/routes.rs b/crates/provisioning-daemon/src/http/routes.rs new file mode 100644 index 0000000..2538ccf --- /dev/null +++ b/crates/provisioning-daemon/src/http/routes.rs @@ -0,0 +1,235 @@ +//! Axum router for the provisioning-daemon REST API. +//! +//! Endpoints: +//! GET /health — liveness probe +//! GET /api/v1/tools — list all registered tools +//! POST /api/v1/tools/{name} — invoke a tool (auth required) +//! GET /api/v1/state/recent — recent invocations (auth required) +//! GET /api/v1/state/sessions — active sessions (auth required) +//! GET /api/v1/state/stats — aggregate statistics (auth required) + +use axum::{ + Extension, + Router, + extract::{Path, State}, + http::{StatusCode, header}, + middleware, + response::{IntoResponse, Json, Response}, + routing::{get, post}, +}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::time::Instant; + +use crate::{ + AppState, + domain_state::{InvocationOutcome, ToolRecord}, + auth::Claims, + http::middleware::{auth_layer, check_rbac}, +}; + +/// Build the API router without calling `with_state` — callers can merge +/// this with other `Router` routers before applying state. +pub fn raw_router(state: &AppState) -> Router { + let protected = Router::new() + .route("/api/v1/tools/{name}", post(invoke_tool)) + .route("/api/v1/state/recent", get(recent_invocations)) + .route("/api/v1/state/sessions", get(active_sessions)) + .route("/api/v1/state/stats", get(stats)) + .route("/api/v1/ontology/templates", get(list_ontology_templates)) + .route("/api/v1/ontology/render/{template}", get(render_ontology)) + .layer(middleware::from_fn_with_state(state.clone(), auth_layer)); + + Router::new() + .route("/health", get(health)) + .route("/api/v1/tools", get(list_tools)) + .merge(protected) + .merge(crate::http::admin::admin_router()) +} + +pub fn router(state: AppState) -> Router { + raw_router(&state).with_state(state) +} + +// ── Handlers ────────────────────────────────────────────────────────────────── + +async fn health(State(app): State) -> impl IntoResponse { + Json(json!({ + "status": "ok", + "tools": app.registry.len(), + "invocations": app.state.total_invocations(), + })) +} + +async fn list_tools(State(app): State) -> impl IntoResponse { + let tools: Vec = app + .registry + .list() + .into_iter() + .map(|m| json!({ "name": m.name, "description": m.description, "category": format!("{:?}", m.category) })) + .collect(); + Json(json!({ "tools": tools, "total": tools.len() })) +} + +#[derive(Deserialize)] +struct InvokePayload { + #[serde(default)] + params: Value, + /// Caller's session ID (optional). + session_id: Option, +} + +async fn invoke_tool( + State(app): State, + Extension(claims): Extension, + Path(name): Path, + Json(body): Json, +) -> impl IntoResponse { + // Resolve metadata to check RBAC before execution. + let meta = match app.registry.get(&name) { + Some(m) => m, + None => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": format!("tool '{}' not found", name)})), + ).into_response() + } + }; + + if let Err(resp) = check_rbac(&claims, meta.category()) { + return *resp; + } + + let ctx = provisioning_core::tool::Context::new(std::sync::Arc::clone(&app.env)); + let t0 = Instant::now(); + let outcome = app.registry.invoke(&name, body.params.clone(), &ctx).await; + let duration_ms = t0.elapsed().as_millis() as u64; + + let (record_outcome, resp) = match outcome { + Ok(value) => ( + InvocationOutcome::Ok { value: value.clone() }, + (StatusCode::OK, Json(json!({ "result": value }))).into_response(), + ), + Err(e) => { + let (code, msg) = tool_error_code(&e); + ( + InvocationOutcome::Err { code, message: msg.clone() }, + (StatusCode::UNPROCESSABLE_ENTITY, Json(json!({ "error": msg, "code": code }))).into_response(), + ) + } + }; + + let record = ToolRecord { + tool: name.clone(), + params: body.params, + outcome: record_outcome, + duration_ms, + invoked_at: chrono::Utc::now(), + }; + + app.state.record_invocation(record.clone()); + + if let Some(ref sid) = body.session_id { + // Silently ignore when the session doesn't belong to the caller — + // don't leak whether the session exists. + let _ = app.state.increment_session_tool_count(sid, &claims.sub); + } + + if let Some(ref bus) = app.bus { + let ev = match &record.outcome { + InvocationOutcome::Ok { value } => { + crate::events::EventBus::tool_ok(&name, record.params.clone(), value.clone(), duration_ms) + } + InvocationOutcome::Err { code, message } => { + crate::events::EventBus::tool_err(&name, record.params.clone(), *code, message.clone(), duration_ms) + } + }; + if let Err(e) = bus.publish(&ev).await { + tracing::warn!("failed to publish tool event: {e}"); + } + } + + resp +} + +async fn recent_invocations( + State(app): State, + Extension(_claims): Extension, +) -> impl IntoResponse { + let records = app.state.recent(50); + Json(json!({ "invocations": records, "count": records.len() })) +} + +async fn active_sessions( + State(app): State, + Extension(_claims): Extension, +) -> impl IntoResponse { + let sessions = app.state.sessions(); + Json(json!({ "sessions": sessions, "count": sessions.len() })) +} + +async fn stats( + State(app): State, + Extension(_claims): Extension, +) -> impl IntoResponse { + Json(json!({ + "total_invocations": app.state.total_invocations(), + "active_sessions": app.state.sessions().len(), + "secs_since_config_reload": app.state.secs_since_reload(), + })) +} + +// ── Ontology handlers ───────────────────────────────────────────────────────── + +async fn list_ontology_templates( + State(app): State, + Extension(_claims): Extension, +) -> impl IntoResponse { + match app.ontology.as_ref() { + Some(r) => Json(json!({ "templates": r.template_names() })).into_response(), + None => ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({"error": "ontology templates not loaded"})), + ) + .into_response(), + } +} + +async fn render_ontology( + State(app): State, + Extension(_claims): Extension, + Path(template): Path, +) -> Response { + let Some(renderer) = app.ontology.as_ref() else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({"error": "ontology templates not loaded"})), + ) + .into_response(); + }; + let ctx = crate::ontology::build_context(&app.project_name, &app.registry, &app.state); + match renderer.render(&template, &ctx) { + Ok(text) => ( + StatusCode::OK, + [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], + text, + ) + .into_response(), + Err(e) => ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(json!({"error": e.to_string()})), + ) + .into_response(), + } +} + +// ── Error mapping ───────────────────────────────────────────────────────────── + +fn tool_error_code(e: &provisioning_core::ToolError) -> (i32, String) { + match e { + provisioning_core::ToolError::NotFound(_) => (-32001, e.to_string()), + provisioning_core::ToolError::InvalidParam { .. } => (-32602, e.to_string()), + provisioning_core::ToolError::Unauthorized(_) => (-32003, e.to_string()), + _ => (-32000, e.to_string()), + } +} diff --git a/crates/provisioning-daemon/src/lib.rs b/crates/provisioning-daemon/src/lib.rs new file mode 100644 index 0000000..7a8845e --- /dev/null +++ b/crates/provisioning-daemon/src/lib.rs @@ -0,0 +1,121 @@ +//! Registry-backed HTTP+NATS daemon for the provisioning platform. +//! +//! Wraps `provisioning-core::Registry` in an Axum HTTP server with JWT auth, +//! RBAC, NATS event publication, domain-state tracking, and config-file watching. + +pub mod auth; +pub mod domain_state; +pub mod events; +pub mod http; +pub mod ontology; +pub mod ui; +pub mod watcher; + +use provisioning_core::{Environment, Registry}; +use std::{path::PathBuf, sync::Arc}; +use ui::session::SessionStore; + +/// Shared application state injected into every Axum handler via `State`. +#[derive(Clone)] +pub struct AppState { + pub registry: Arc, + pub env: Arc, + pub state: domain_state::DomainState, + /// Present when NATS is configured. + pub bus: Option, + /// Present when JWT is enabled (absent in solo mode). + pub jwt: Option, + /// Present when ontology document templates were loaded successfully. + pub ontology: Option>, + /// Present when the Tera SSR admin UI was built successfully. + pub ui: Option>, + pub project_name: String, + /// Root of the provisioning repo on disk (for .ontology/, reflection/, etc.). + pub project_root: Option>, + /// NICKEL_IMPORT_PATH passed to `nickel export` invocations. + pub nickel_import_path: Option>, + /// Path to the `provisioning` CLI binary for mode execution. + pub provisioning_bin: Option>, + /// UI session store (in-memory, reset on restart). + pub sessions: SessionStore, + /// Static password for solo/dev UI login (absent = any non-empty string accepted in open mode). + pub ui_password: Option>, + /// Control-center URL for delegated auth verification. + pub control_center_url: Option>, +} + +impl AppState { + pub fn new(registry: Registry, env: Environment) -> Self { + Self { + registry: Arc::new(registry), + env: Arc::new(env), + state: domain_state::DomainState::new(), + bus: None, + jwt: None, + ontology: None, + ui: None, + project_name: "provisioning".into(), + project_root: None, + nickel_import_path: None, + provisioning_bin: None, + sessions: SessionStore::default(), + ui_password: None, + control_center_url: None, + } + } + + pub fn with_bus(mut self, bus: events::EventBus) -> Self { + self.bus = Some(bus); + self + } + + pub fn with_jwt(mut self, jwt: auth::JwtConfig) -> Self { + self.jwt = Some(jwt); + self + } + + pub fn with_ontology(mut self, renderer: ontology::OntologyRenderer) -> Self { + self.ontology = Some(Arc::new(renderer)); + self + } + + pub fn with_ui(mut self, renderer: ui::UiRenderer) -> Self { + self.ui = Some(Arc::new(renderer)); + self + } + + pub fn with_project_name(mut self, name: impl Into) -> Self { + self.project_name = name.into(); + self + } + + pub fn with_project_root(mut self, root: PathBuf) -> Self { + self.project_root = Some(Arc::new(root)); + self + } + + pub fn with_nickel_import_path(mut self, path: impl Into>) -> Self { + self.nickel_import_path = Some(path.into()); + self + } + + pub fn with_provisioning_bin(mut self, bin: impl Into>) -> Self { + self.provisioning_bin = Some(bin.into()); + self + } + + pub fn with_ui_password(mut self, password: impl Into>) -> Self { + self.ui_password = Some(password.into()); + self + } + + pub fn with_control_center_url(mut self, url: impl Into>) -> Self { + self.control_center_url = Some(url.into()); + self + } + + pub fn with_session_persistence(mut self, file: PathBuf) -> Self { + self.sessions = ui::session::SessionStore::with_persistence(file); + self + } +} diff --git a/crates/provisioning-daemon/src/main.rs b/crates/provisioning-daemon/src/main.rs new file mode 100644 index 0000000..8609146 --- /dev/null +++ b/crates/provisioning-daemon/src/main.rs @@ -0,0 +1,569 @@ +//! `provisioning-daemon` — HTTP+NATS server backed by the provisioning-core Registry. + +use anyhow::{Context as _, Result}; +use clap::Parser; +use platform_config::load_service_config_from_ncl_with_overrides; +use provisioning_core::{ + Environment, + providers::hcloud::HcloudClient, + sources::{NclCache, OrchestratorClient}, + Registry, +}; +use provisioning_daemon::{ + AppState, auth::JwtConfig, events::EventBus, ontology::OntologyRenderer, + ui::UiRenderer, +}; +use std::sync::Arc; +use tokio::net::TcpListener; + +#[derive(Parser)] +#[command(name = "provisioning-daemon", about = "Provisioning HTTP+NATS daemon", version)] +struct Cli { + /// Config file path — overrides all other config sources. + /// Supports .ncl, .toml, .json. Env: PROVISIONING_DAEMON_CONFIG + #[arg(short = 'c', long, env = "PROVISIONING_DAEMON_CONFIG")] + config: Option, + + /// Config directory — searches for provisioning-daemon[.{mode}].ncl|toml. + /// Env: PROVISIONING_CONFIG_DIR + #[arg(long, env = "PROVISIONING_CONFIG_DIR")] + config_dir: Option, + + /// Deployment mode (solo, multiuser, enterprise). + /// Selects provisioning-daemon.{mode}.ncl when searching config directories. + /// Env: PROVISIONING_DAEMON_MODE + #[arg(short = 'm', long, env = "PROVISIONING_DAEMON_MODE")] + mode: Option, + + // --- Runtime overrides (take priority over NCL config) --- + + #[arg(long, env = "PROVISIONING_DAEMON_BIND", default_value = "0.0.0.0:9014")] + bind: String, + + #[arg(long, env = "ORCHESTRATOR_URL", default_value = "http://localhost:9011")] + orchestrator_url: String, + + #[arg(long, env = "NATS_URL", default_value = "nats://127.0.0.1:4222")] + nats_url: String, + + /// HS256 JWT secret (omit to disable JWT — solo/dev mode). + #[arg(long, env = "PROVISIONING_JWT_SECRET")] + jwt_secret: Option, + + /// Config paths to watch for live-reload (comma-separated). + #[arg(long, env = "PROVISIONING_WATCH_PATHS", value_delimiter = ',')] + watch_paths: Vec, + + /// Directory of Tera templates for `/api/v1/ontology/render/*`. + #[arg(long, env = "PROVISIONING_ONTOLOGY_TEMPLATES")] + ontology_templates: Option, + + /// Project name exposed in the ontology render context. + #[arg(long, env = "PROVISIONING_PROJECT_NAME", default_value = "provisioning")] + project_name: String, + + /// Root of the provisioning repo on disk — enables /ui/ontology and /ui/modes. + #[arg(long, env = "PROVISIONING_PROJECT_ROOT")] + project_root: Option, + + /// Override directory for Tera SSR UI templates (default: embedded). + #[arg(long, env = "PROVISIONING_UI_TEMPLATES_DIR")] + ui_templates_dir: Option, + + /// Nickel import path for `nickel export` calls (NICKEL_IMPORT_PATH). + #[arg(long, env = "NICKEL_IMPORT_PATH")] + nickel_import_path: Option, + + /// Path to the `provisioning` CLI binary (for /ui/modes run). + #[arg(long, env = "PROVISIONING_BIN", default_value = "provisioning")] + provisioning_bin: String, + + /// Workspaces root directory (default: {project-root}/../workspaces). + #[arg(long, env = "PROVISIONING_WORKSPACES_ROOT")] + workspaces_root: Option, + + /// Extensions root directory (default: {project-root}/extensions). + #[arg(long, env = "PROVISIONING_EXTENSIONS_ROOT")] + extensions_root: Option, + + /// Static password for UI login in solo/dev mode. + #[arg(long, env = "PROVISIONING_UI_PASSWORD")] + ui_password: Option, + + /// Control-center URL for delegated auth (POST {url}/api/v1/auth/verify). + #[arg(long, env = "PROVISIONING_CONTROL_CENTER_URL")] + control_center_url: Option, +} + +#[derive(Debug, serde::Deserialize)] +struct ServerConfig { + #[serde(default = "defaults::server_host")] + host: String, + #[serde(default = "defaults::server_port")] + port: u16, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { host: defaults::server_host(), port: defaults::server_port() } + } +} + +/// Effective daemon configuration, resolved from NCL → env → CLI defaults. +#[derive(Debug, serde::Deserialize)] +struct DaemonSettings { + #[serde(default)] + server: ServerConfig, + #[serde(default = "defaults::orchestrator_url")] + orchestrator_url: String, + #[serde(default = "defaults::nats_url")] + nats_url: String, + #[serde(default = "defaults::project_name")] + project_name: String, + #[serde(default = "defaults::provisioning_bin")] + provisioning_bin: String, + #[serde(default)] + watch_paths: Vec, + nickel_import_path: Option, + project_root: Option, + workspaces_root: Option, + extensions_root: Option, + ontology_templates: Option, + ui_templates_dir: Option, + control_center_url: Option, +} + +impl DaemonSettings { + fn bind_addr(&self) -> String { + format!("{}:{}", self.server.host, self.server.port) + } +} + +mod defaults { + pub fn server_host() -> String { "0.0.0.0".into() } + pub fn server_port() -> u16 { 9014 } + pub fn orchestrator_url() -> String { "http://localhost:9011".into() } + pub fn nats_url() -> String { "nats://127.0.0.1:4222".into() } + pub fn project_name() -> String { "provisioning".into() } + pub fn provisioning_bin() -> String { "provisioning".into() } +} + +impl Default for DaemonSettings { + fn default() -> Self { + Self { + server: ServerConfig::default(), + orchestrator_url: defaults::orchestrator_url(), + nats_url: defaults::nats_url(), + project_name: defaults::project_name(), + provisioning_bin: defaults::provisioning_bin(), + watch_paths: vec![], + nickel_import_path: None, + project_root: None, + workspaces_root: None, + extensions_root: None, + ontology_templates: None, + ui_templates_dir: None, + control_center_url: None, + } + } +} + +/// Builds the NCL overrides JSON from environment variables. +/// +/// Only env vars that are explicitly set override NCL defaults. Clap's +/// built-in default_value fields are intentionally excluded so NCL values +/// win over built-in defaults. +fn build_ncl_overrides() -> serde_json::Value { + let mut daemon = serde_json::Map::new(); + + let env_string = |key: &str| std::env::var(key).ok().map(serde_json::Value::String); + + // PROVISIONING_DAEMON_BIND="host:port" → server.host + server.port + if let Ok(bind) = std::env::var("PROVISIONING_DAEMON_BIND") { + if let Some((host, port_str)) = bind.rsplit_once(':') { + if let Ok(port) = port_str.parse::() { + daemon.insert( + "server".into(), + serde_json::json!({"host": host, "port": port}), + ); + } + } + } + + for (env_key, ncl_key) in [ + ("ORCHESTRATOR_URL", "orchestrator_url"), + ("NATS_URL", "nats_url"), + ("PROVISIONING_PROJECT_NAME", "project_name"), + ("PROVISIONING_BIN", "provisioning_bin"), + // NICKEL_IMPORT_PATH is intentionally excluded: it is additive (prepended) + // and already resolved via cli.nickel_import_path in resolve(). Injecting it + // as a priority-1 NCL override would replace the config's nickel_import_path + // (which may list ontoref or other paths) instead of prepending to it. + ("PROVISIONING_CONTROL_CENTER_URL", "control_center_url"), + ("PROVISIONING_PROJECT_ROOT", "project_root"), + ("PROVISIONING_WORKSPACES_ROOT", "workspaces_root"), + ("PROVISIONING_EXTENSIONS_ROOT", "extensions_root"), + ("PROVISIONING_ONTOLOGY_TEMPLATES", "ontology_templates"), + ("PROVISIONING_UI_TEMPLATES_DIR", "ui_templates_dir"), + ] { + if let Some(v) = env_string(env_key) { + daemon.insert(ncl_key.into(), v); + } + } + + if daemon.is_empty() { + serde_json::Value::Null + } else { + serde_json::json!({ "provisioning_daemon": serde_json::Value::Object(daemon) }) + } +} + +/// Loads `DaemonSettings` from the NCL config hierarchy. +/// +/// Priority: +/// 1. `-c/--config` explicit file path +/// 2. `--config-dir` directory with mode-aware file search +/// 3. Standard platform location: `~/Library/Application Support/provisioning/platform/config/provisioning-daemon.ncl` +/// 4. Built-in defaults (when NCL is unavailable or not configured) +fn load_daemon_settings(cli: &Cli) -> DaemonSettings { + let overrides = build_ncl_overrides(); + + let json_result: Result = if let Some(ref path) = cli.config { + platform_config::format::load_config_with_overrides(path, &overrides) + .map_err(|e| anyhow::anyhow!("{e}")) + } else if let Some(ref dir) = cli.config_dir { + let mode = cli.mode.as_deref().unwrap_or("solo"); + // Honour both `config_dir/provisioning-daemon.ncl` and the platform + // convention of keeping service configs one level deeper in `config/`. + let config_sub = dir.join("config"); + let base = if config_sub.is_dir() { config_sub } else { dir.clone() }; + let mode_path = base.join(format!("provisioning-daemon.{mode}.ncl")); + let plain_path = base.join("provisioning-daemon.ncl"); + let search = if mode_path.exists() { mode_path } else { plain_path }; + platform_config::format::load_config_with_overrides(search, &overrides) + .map_err(|e| anyhow::anyhow!("{e}")) + } else { + load_service_config_from_ncl_with_overrides("provisioning-daemon", &overrides) + .map_err(|e| anyhow::anyhow!("{e}")) + }; + + match json_result { + Ok(json) => { + let section = json + .get("provisioning_daemon") + .cloned() + .unwrap_or(json); + serde_json::from_value(section).unwrap_or_else(|e| { + tracing::warn!("daemon config schema mismatch ({e}), using defaults"); + DaemonSettings::default() + }) + } + Err(e) => { + tracing::debug!("NCL config not loaded ({e}), using CLI/env defaults"); + DaemonSettings::default() + } + } +} + +/// Merges `DaemonSettings` (from NCL) with explicit CLI Option fields. +/// +/// NCL values are the authoritative defaults. CLI `Option` fields override +/// when the user explicitly provided them. String-with-defaults fields in the +/// Cli struct are intentionally shadowed by NCL when the user hasn't set the +/// corresponding env var. +fn resolve(ncl: DaemonSettings, cli: &Cli) -> DaemonSettings { + DaemonSettings { + server: ncl.server, + orchestrator_url: ncl.orchestrator_url, + nats_url: ncl.nats_url, + project_name: ncl.project_name, + provisioning_bin: ncl.provisioning_bin, + watch_paths: if !cli.watch_paths.is_empty() { + cli.watch_paths.clone() + } else { + ncl.watch_paths + }, + // NICKEL_IMPORT_PATH env/CLI is additive: prepend to the NCL-configured path + // so both the project repo and any user-specified paths (ontoref, etc.) are included. + nickel_import_path: match (cli.nickel_import_path.clone(), ncl.nickel_import_path) { + (Some(cli_ip), Some(ncl_ip)) => Some(format!("{cli_ip}:{ncl_ip}")), + (Some(cli_ip), None) => Some(cli_ip), + (None, ncl_ip) => ncl_ip, + }, + project_root: cli.project_root.clone().or(ncl.project_root), + workspaces_root: cli.workspaces_root.clone().or(ncl.workspaces_root), + extensions_root: cli.extensions_root.clone().or(ncl.extensions_root), + ontology_templates: cli.ontology_templates.clone().or(ncl.ontology_templates), + ui_templates_dir: cli.ui_templates_dir.clone().or(ncl.ui_templates_dir), + control_center_url: cli.control_center_url.clone().or(ncl.control_center_url), + } +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + // Load NCL config first (best-effort), then merge CLI Option fields on top. + let cfg = resolve(load_daemon_settings(&cli), &cli); + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive(tracing::Level::INFO.into()), + ) + .with_writer(std::io::stderr) + .init(); + + tracing::info!( + bind = %cfg.bind_addr(), + orchestrator_url = %cfg.orchestrator_url, + nats_url = %cfg.nats_url, + "provisioning-daemon config resolved" + ); + + // Build the effective NICKEL_IMPORT_PATH: + // project_root (for schemas/ + extensions/) + nickel_import_path (for ontoref etc.) + // Deduplicate entries to avoid Nickel complaining about repeated paths. + let effective_import_path: Option = { + let mut parts: Vec = Vec::new(); + if let Some(ref root) = cfg.project_root { + parts.push(root.display().to_string()); + } + let extra_segments = cfg.nickel_import_path.as_deref().unwrap_or("").split(':'); + for segment in extra_segments { + let s = segment.to_string(); + if !s.is_empty() && !parts.contains(&s) { + parts.push(s); + } + } + if parts.is_empty() { None } else { Some(parts.join(":")) } + }; + + // Propagate the resolved path back into the process environment so that + // internal nickel::export_to_json calls (e.g. ontology context exports) use + // the same combined path instead of whatever was inherited from the shell. + if let Some(ref ip) = effective_import_path { + std::env::set_var("NICKEL_IMPORT_PATH", ip); + } + + // Registry + let cache = Arc::new(match effective_import_path.as_deref() { + Some(ip) => NclCache::with_import_path(ip), + None => NclCache::new(), + }); + tracing::info!( + import_path = effective_import_path.as_deref().unwrap_or("(none)"), + "nickel import path" + ); + let orch = Arc::new(OrchestratorClient::new(&cfg.orchestrator_url)); + let hcloud = Arc::new(HcloudClient::new()); + let registry = Registry::with_all_tools(cache, orch, hcloud, None) + .context("registry init")?; + + // Environment + let mut env = Environment { + orchestrator_url: cfg.orchestrator_url.clone(), + ..Default::default() + }; + + // Derive workspaces_root and extensions_root from project_root when not explicit. + if let Some(ref ws) = cfg.workspaces_root { + env.workspaces_root = ws.clone(); + } else if let Some(ref root) = cfg.project_root { + if let Some(parent) = root.parent() { + let candidate = parent.join("workspaces"); + if candidate.exists() { + env.workspaces_root = candidate; + } + } + } + + if let Some(ref ext) = cfg.extensions_root { + env.extensions_root = ext.clone(); + } else if let Some(ref root) = cfg.project_root { + let candidate = root.join("extensions"); + if candidate.exists() { + env.extensions_root = candidate; + } + } + + if !env.workspaces_root.as_os_str().is_empty() { + tracing::info!(path = %env.workspaces_root.display(), "workspaces root"); + } else { + tracing::warn!("workspaces_root not set — workspace_list tool will return empty"); + } + if !env.extensions_root.as_os_str().is_empty() { + tracing::info!(path = %env.extensions_root.display(), "extensions root"); + } + + // Capture for startup banner before env is moved into AppState. + let banner_ws = if env.workspaces_root.as_os_str().is_empty() { + "(not set)".to_string() + } else { + env.workspaces_root.display().to_string() + }; + let banner_ext = if env.extensions_root.as_os_str().is_empty() { + "(not set)".to_string() + } else { + env.extensions_root.display().to_string() + }; + + // Session persistence path: XDG_DATA_HOME/provisioning/daemon-sessions.json + let session_file = { + let base = std::env::var("XDG_DATA_HOME") + .unwrap_or_else(|_| format!("{}/.local/share", env!("HOME"))); + std::path::PathBuf::from(base) + .join("provisioning") + .join("daemon-sessions.json") + }; + + // App state + let mut app = AppState::new(registry, env) + .with_project_name(cfg.project_name.clone()) + .with_provisioning_bin(cfg.provisioning_bin.as_str()) + .with_session_persistence(session_file); + + if let Some(ref path) = effective_import_path { + app = app.with_nickel_import_path(path.as_str()); + } + + // Project root (enables ontology graph + modes) + if let Some(ref root) = cfg.project_root { + let canonical = root.canonicalize() + .with_context(|| format!("project-root {}: canonicalize failed", root.display()))?; + app = app.with_project_root(canonical); + tracing::info!(root = %root.display(), "project root set"); + } + + // SSR admin UI + let ui = match &cfg.ui_templates_dir { + Some(dir) => { + match UiRenderer::from_dir(dir) { + Ok(r) => { tracing::info!(dir = %dir.display(), "ui templates loaded from disk"); r } + Err(e) => { + tracing::warn!("ui templates from disk failed ({e}), falling back to embedded"); + UiRenderer::from_embedded().context("embedded ui templates")? + } + } + } + None => UiRenderer::from_embedded().context("embedded ui templates")?, + }; + app = app.with_ui(ui); + tracing::info!("ui ready at /ui/"); + + // Optional ontology document templates + if let Some(ref dir) = cfg.ontology_templates { + match OntologyRenderer::from_dir(dir) { + Ok(renderer) => { + let n = renderer.template_names().len(); + app = app.with_ontology(renderer); + tracing::info!(count = n, dir = %dir.display(), "ontology templates loaded"); + } + Err(e) => tracing::warn!("ontology templates failed to load from {}: {e}", dir.display()), + } + } + + // Optional JWT (CLI-only — not in NCL, sensitive) + if let Some(ref secret) = cli.jwt_secret { + let jwt = JwtConfig::from_secret(secret.as_bytes()) + .context("PROVISIONING_JWT_SECRET rejected (must be >= 32 bytes for HS256)")?; + app = app.with_jwt(jwt); + tracing::info!("JWT auth enabled"); + } else { + tracing::warn!("JWT disabled — running in open/solo mode"); + } + + // UI auth (CLI-only — sensitive) + if let Some(ref pw) = cli.ui_password { + app = app.with_ui_password(pw.as_str()); + tracing::info!("UI password auth configured"); + } + if let Some(ref url) = cfg.control_center_url { + app = app.with_control_center_url(url.as_str()); + tracing::info!(url = %url, "control-center auth delegation configured"); + } + + // Optional NATS event bus + let nats_cfg = platform_nats::NatsConfig { + url: cfg.nats_url.clone(), + ..Default::default() + }; + match platform_nats::NatsBridge::connect(&nats_cfg).await { + Ok(bridge) => { + app = app.with_bus(EventBus::new(bridge)); + tracing::info!(url = %cfg.nats_url, "NATS connected"); + } + Err(e) => { + tracing::warn!("NATS unavailable ({e}) — events disabled"); + } + } + + // Resolve bind address before watch_paths is moved + let bind_addr_str = cfg.bind_addr(); + + // Config file watcher + let watch_paths_display = cfg.watch_paths.iter() + .map(|p| p.display().to_string()) + .collect::>(); + if !cfg.watch_paths.is_empty() { + match provisioning_daemon::watcher::spawn_watcher(cfg.watch_paths) { + Ok((rx, _watcher)) => { + let state = app.state.clone(); + let bus = app.bus.clone(); + tokio::spawn(provisioning_daemon::watcher::run_config_watch_loop(rx, state, bus)); + tracing::info!("config watcher started"); + } + Err(e) => tracing::warn!("config watcher failed to start: {e}"), + } + } + + // HTTP server + let addr: std::net::SocketAddr = bind_addr_str.parse().context("invalid bind address")?; + let listener = TcpListener::bind(addr).await?; + + // Emit a single resolved-config banner so operators can verify state at a glance. + { + let ws = &banner_ws; + let ext = &banner_ext; + let root_display = cfg.project_root + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "(not set)".to_string()); + let nickel_ip = effective_import_path.as_deref().unwrap_or("(none)"); + let auth_mode = if cli.jwt_secret.is_some() { "jwt" } else { "solo/open" }; + let ui_src = cfg.ui_templates_dir + .as_ref() + .map(|p| format!("disk:{}", p.display())) + .unwrap_or_else(|| "embedded".to_string()); + + eprintln!(); + let watch_display = if watch_paths_display.is_empty() { + "(none)".to_string() + } else { + watch_paths_display.join(", ") + }; + eprintln!("-- provisioning-daemon ready --------------------------------"); + eprintln!(" url http://{bind_addr_str}"); + eprintln!(" project_root {root_display}"); + eprintln!(" workspaces_root {ws}"); + eprintln!(" extensions_root {ext}"); + eprintln!(" orchestrator {}", cfg.orchestrator_url); + eprintln!(" nats {}", cfg.nats_url); + eprintln!(" nickel_path {nickel_ip}"); + eprintln!(" ui {ui_src}"); + eprintln!(" auth {auth_mode}"); + eprintln!(" watch_paths {watch_display}"); + eprintln!("------------------------------------------------------------"); + eprintln!(); + } + + tracing::info!("provisioning-daemon listening on http://{addr}"); + + let app_router = provisioning_daemon::ui::ui_router(&app) + .route("/", axum::routing::get(|| async { axum::response::Redirect::permanent("/ui/") })) + .merge(provisioning_daemon::http::raw_router(&app)) + .with_state(app); + + axum::serve(listener, app_router).await?; + Ok(()) +} diff --git a/crates/provisioning-daemon/src/ontology/mod.rs b/crates/provisioning-daemon/src/ontology/mod.rs new file mode 100644 index 0000000..b4bd6ef --- /dev/null +++ b/crates/provisioning-daemon/src/ontology/mod.rs @@ -0,0 +1,116 @@ +//! Tera-based ontology template rendering. +//! +//! Templates are loaded from a directory (default: `ontology_templates/` in the +//! crate root, overridable via `PROVISIONING_ONTOLOGY_TEMPLATES`) and +//! re-parsed on demand. Context data is built from the live Registry and +//! DomainState. + +use anyhow::{Context as _, Result}; +use provisioning_core::Registry; +use serde::Serialize; +use serde_json::json; +use std::path::Path; +use tera::{Context, Tera}; + +use crate::domain_state::DomainState; + +// ── Context builders ────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct ToolView { + name: &'static str, + description: &'static str, + category: String, +} + +/// Build a Tera context populated with live registry + domain state snapshots. +pub fn build_context(project: &str, registry: &Registry, state: &DomainState) -> Context { + let tools: Vec = registry + .list() + .into_iter() + .map(|m| ToolView { + name: m.name, + description: m.description, + category: format!("{:?}", m.category), + }) + .collect(); + + let stats = json!({ + "total_invocations": state.total_invocations(), + "active_sessions": state.sessions().len(), + "secs_since_config_reload": state.secs_since_reload(), + }); + + let mut ctx = Context::new(); + ctx.insert("project", project); + ctx.insert("generated_at", &chrono::Utc::now().to_rfc3339()); + ctx.insert("tools", &tools); + ctx.insert("stats", &stats); + ctx +} + +// ── Renderer ────────────────────────────────────────────────────────────────── + +pub struct OntologyRenderer { + tera: Tera, +} + +impl OntologyRenderer { + /// Build a renderer from `dir/*.tera`. Returns a parse error if any + /// template fails to compile. + pub fn from_dir(dir: &Path) -> Result { + let pattern = format!("{}/**/*.tera", dir.display()); + let tera = Tera::new(&pattern) + .with_context(|| format!("tera parse {}", dir.display()))?; + Ok(Self { tera }) + } + + pub fn template_names(&self) -> Vec { + self.tera.get_template_names().map(|s| s.to_owned()).collect() + } + + pub fn render(&self, template: &str, ctx: &Context) -> Result { + self.tera + .render(template, ctx) + .with_context(|| format!("render {template}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn from_dir_parses_templates() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("hello.md.tera"), "Hi {{ name }}!").unwrap(); + let r = OntologyRenderer::from_dir(dir.path()).unwrap(); + assert!(r.template_names().iter().any(|n| n == "hello.md.tera")); + } + + #[test] + fn render_substitutes_variables() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("t.tera"), "value: {{ v }}").unwrap(); + let r = OntologyRenderer::from_dir(dir.path()).unwrap(); + let mut ctx = Context::new(); + ctx.insert("v", &42); + assert_eq!(r.render("t.tera", &ctx).unwrap(), "value: 42"); + } + + #[test] + fn unknown_template_errors() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("t.tera"), "x").unwrap(); + let r = OntologyRenderer::from_dir(dir.path()).unwrap(); + assert!(r.render("no_such.tera", &Context::new()).is_err()); + } + + #[test] + fn malformed_template_fails_at_parse() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("bad.tera"), "{% if %}broken").unwrap(); + assert!(OntologyRenderer::from_dir(dir.path()).is_err()); + } +} diff --git a/crates/provisioning-daemon/src/ui/context.rs b/crates/provisioning-daemon/src/ui/context.rs new file mode 100644 index 0000000..b4c8dc1 --- /dev/null +++ b/crates/provisioning-daemon/src/ui/context.rs @@ -0,0 +1,92 @@ +use anyhow::{Context as _, Result}; +use serde_json::{json, Value}; +use std::path::Path; + +use crate::AppState; +use super::error::UiError; + +// ── Tool invocation ─────────────────────────────────────────────────────────── + +pub async fn invoke_tool(app: &AppState, name: &str, params: Value) -> Result { + let ctx = provisioning_core::tool::Context::new(app.env.clone()); + app.registry + .invoke(name, params, &ctx) + .await + .map_err(|e| UiError::Tool(format!("{name}: {e}"))) +} + +// ── Nickel export ───────────────────────────────────────────────────────────── + +pub async fn ncl_export(path: &Path, import_path: Option<&str>) -> Value { + let mut cmd = tokio::process::Command::new("nickel"); + cmd.arg("export").arg(path); + if let Some(ip) = import_path { + cmd.env("NICKEL_IMPORT_PATH", ip); + } + match cmd.output().await { + Ok(out) if out.status.success() => { + serde_json::from_slice(&out.stdout) + .unwrap_or_else(|_| json!({"nodes": [], "edges": []})) + } + Ok(out) => { + let err = String::from_utf8_lossy(&out.stderr); + tracing::warn!(path = %path.display(), %err, "nickel export failed"); + json!({"nodes": [], "edges": []}) + } + Err(e) => { + tracing::warn!(path = %path.display(), error = %e, "nickel export spawn failed"); + json!({"nodes": [], "edges": []}) + } + } +} + +// ── Reflection modes ────────────────────────────────────────────────────────── + +#[derive(Debug, serde::Serialize)] +pub struct ModeEntry { + pub id: String, + pub path: String, +} + +pub fn list_modes(project_root: &Path) -> Vec { + let modes_dir = project_root.join("reflection").join("modes"); + let Ok(entries) = std::fs::read_dir(&modes_dir) else { + return vec![]; + }; + let mut modes: Vec = entries + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().map(|x| x == "ncl").unwrap_or(false)) + .map(|e| { + let path = e.path(); + let id = path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + ModeEntry { id, path: path.display().to_string() } + }) + .collect(); + modes.sort_by(|a, b| a.id.cmp(&b.id)); + modes +} + +// ── Backlog ─────────────────────────────────────────────────────────────────── + +pub async fn list_backlog(backlog_ncl: &Path, import_path: Option<&str>) -> Value { + if !backlog_ncl.exists() { + return json!({"items": []}); + } + let v = ncl_export(backlog_ncl, import_path).await; + if v["items"].is_array() { v } else { json!({"items": []}) } +} + +// ── Mode runner ─────────────────────────────────────────────────────────────── + +pub async fn run_mode(project_root: &Path, mode_id: &str, provisioning_bin: &str) -> Result<()> { + tokio::process::Command::new(provisioning_bin) + .args(["run", mode_id]) + .current_dir(project_root) + .spawn() + .with_context(|| format!("spawn {provisioning_bin} run {mode_id}"))?; + Ok(()) +} diff --git a/crates/provisioning-daemon/src/ui/error.rs b/crates/provisioning-daemon/src/ui/error.rs new file mode 100644 index 0000000..730eb99 --- /dev/null +++ b/crates/provisioning-daemon/src/ui/error.rs @@ -0,0 +1,53 @@ +use axum::{http::StatusCode, response::{Html, IntoResponse, Response}}; + +#[derive(Debug, thiserror::Error)] +pub enum UiError { + #[error("template render error: {0}")] + Render(#[from] tera::Error), + #[error("ui not configured — daemon built without embedded templates")] + NotConfigured, + #[error("project root not set — start daemon with --project-root")] + NoProjectRoot, + #[error("tool invocation failed: {0}")] + Tool(String), + #[error("io error: {0}")] + Io(#[from] std::io::Error), +} + +impl IntoResponse for UiError { + fn into_response(self) -> Response { + let status = match &self { + UiError::NotConfigured | UiError::NoProjectRoot => StatusCode::SERVICE_UNAVAILABLE, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + let detail = self.to_string(); + // Log the full source chain so the root cause is visible in logs. + let mut cause_chain = String::new(); + if let Self::Render(ref e) = self { + let mut src: &dyn std::error::Error = e; + while let Some(next) = src.source() { + use std::fmt::Write as _; + let _ = write!(cause_chain, " → {next}"); + src = next; + } + } + tracing::error!(ui_error = %detail, cause = %cause_chain, "UI handler error"); + let escaped = detail + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """); + let html = format!( + r#" + +UI Error + + +
+
{escaped}
+
+"# + ); + (status, Html(html)).into_response() + } +} diff --git a/crates/provisioning-daemon/src/ui/handlers.rs b/crates/provisioning-daemon/src/ui/handlers.rs new file mode 100644 index 0000000..8a8a08b --- /dev/null +++ b/crates/provisioning-daemon/src/ui/handlers.rs @@ -0,0 +1,1884 @@ +use std::{collections::HashMap, path::PathBuf}; +use shellexpand; + +use axum::{ + Json, + extract::{Extension, Form, Path, Query, State}, + http::{HeaderMap, HeaderValue, StatusCode, header}, + response::{Html, IntoResponse, Redirect, Response}, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use tera::Context; + +use crate::AppState; +use super::{context as ctx, error::UiError, extract_session_cookie, session::{SessionData, UiPerms}}; + +// ── Render helper ───────────────────────────────────────────────────────────── + +fn render(app: &AppState, template: &str, tc: &Context) -> Result, UiError> { + let ui = app.ui.as_ref().ok_or(UiError::NotConfigured)?; + let html = ui.render(template, tc)?; + Ok(Html(html)) +} + +fn base_ctx(app: &AppState) -> Context { + let mut c = Context::new(); + c.insert("project_name", &app.project_name); + c.insert("nats_enabled", &app.bus.is_some()); + c.insert("jwt_enabled", &app.jwt.is_some()); + let tool_names: Vec<&str> = app.registry.list().iter().map(|t| t.name).collect(); + c.insert("tool_names", &tool_names); + c.insert("ws_name", ""); + c.insert("logo", ""); + c.insert("logo_dark", ""); + c.insert("daemon_version", env!("CARGO_PKG_VERSION")); + let now_hms = chrono::Utc::now().format("%H:%M:%S").to_string(); + c.insert("now_hms", &now_hms); + c +} + +fn ws_ctx(app: &AppState, ws_name: &str, active_tab: &str) -> Context { + let mut c = base_ctx(app); + c.insert("ws_name", ws_name); + c.insert("ws_active_tab", active_tab); + let empty: Vec = vec![]; + c.insert("ws_envs", &empty); + c.insert("ws_has_clusters", &false); + c.insert("ws_has_workflows", &false); + c.insert("current_env", ""); + c +} + +fn perm_ctx(perms: &UiPerms, c: &mut Context) { + c.insert("perm_sub", &perms.subject); + c.insert("perm_role", perms.role_label()); + c.insert("can_view", &perms.can_view); + c.insert("can_operate", &perms.can_operate); + c.insert("can_admin", &perms.can_admin); +} + +// ── Workspace env helpers ───────────────────────────────────────────────────── + +/// True if any infra env has a `clusters/` subdirectory. +fn ws_has_clusters(ws_path: &str, envs: &[String]) -> bool { + !ws_path.is_empty() && envs.iter().any(|env| { + std::path::Path::new(ws_path).join("infra").join(env).join("clusters").is_dir() + }) +} + +/// True if any infra env has a `workflows/` subdirectory. +fn ws_has_workflows(ws_path: &str, envs: &[String]) -> bool { + !ws_path.is_empty() && envs.iter().any(|env| { + std::path::Path::new(ws_path).join("infra").join(env).join("workflows").is_dir() + }) +} + +struct WsEnvInfo { + envs: Vec, + has_clusters: bool, + has_workflows: bool, +} + +/// Priority rank for aggregating component states across servers. +/// Higher = worse; we surface the worst state seen on any server. +fn state_rank(s: &str) -> u8 { + match s { + "failed" => 4, + "running" => 3, + "pending" => 2, + "completed" => 1, + _ => 0, + } +} + +struct EnvStates { + /// comp_name_underscored → worst state across all servers + components: HashMap, + /// server_name → worst state across all its taskservs + servers: HashMap, + /// comp_name_underscored → primary server hosting it (first seen) + comp_server: HashMap, +} + +/// Read `.provisioning-state.ncl` for a single infra env. +/// Returns component states (underscore-normalised) and per-server aggregate states. +async fn load_env_states( + ws_path: &str, + env: &str, + import_path: Option<&str>, +) -> EnvStates { + let mut comp: HashMap = HashMap::new(); + let mut srv: HashMap = HashMap::new(); + let mut comp_srv: HashMap = HashMap::new(); + + if ws_path.is_empty() { + return EnvStates { components: comp, servers: srv, comp_server: comp_srv }; + } + + let state_file = std::path::Path::new(ws_path) + .join("infra").join(env).join(".provisioning-state.ncl"); + if !state_file.exists() { + return EnvStates { components: comp, servers: srv, comp_server: comp_srv }; + } + + let exported = ctx::ncl_export(&state_file, import_path).await; + let Some(servers) = exported["servers"].as_object() else { + return EnvStates { components: comp, servers: srv, comp_server: comp_srv }; + }; + + for (srv_name, srv_val) in servers { + let Some(taskservs) = srv_val["taskservs"].as_object() else { continue }; + for (ts_name, ts_val) in taskservs { + let state = ts_val["state"].as_str().unwrap_or(""); + let rank = state_rank(state); + + let comp_key = ts_name.replace('-', "_"); + let ce = comp.entry(comp_key.clone()).or_insert_with(|| state.to_string()); + if rank > state_rank(ce) { *ce = state.to_string(); } + + comp_srv.entry(comp_key).or_insert_with(|| srv_name.clone()); + + let se = srv.entry(srv_name.clone()).or_insert_with(|| state.to_string()); + if rank > state_rank(se) { *se = state.to_string(); } + } + } + + EnvStates { components: comp, servers: srv, comp_server: comp_srv } +} + + +/// Read `infra/{env}/servers.ncl` → hostname → `{role, scale}` JSON. +/// Non-fatal: returns empty map when the file is absent or nickel fails. +async fn load_infra_server_roles( + ws_path: &str, + env: &str, + import_path: Option<&str>, +) -> HashMap { + let mut map = HashMap::new(); + if ws_path.is_empty() || env.is_empty() { return map; } + let servers_ncl = std::path::Path::new(ws_path) + .join("infra").join(env).join("servers.ncl"); + if !servers_ncl.exists() { return map; } + let exported = ctx::ncl_export(&servers_ncl, import_path).await; + let Some(srvs) = exported["servers"].as_array() else { return map }; + for srv in srvs { + let Some(hostname) = srv["hostname"].as_str() else { continue }; + let role = srv.get("role").and_then(|r| r.as_str()).unwrap_or("").to_string(); + let scale = srv.get("scale").cloned().unwrap_or(Value::Null); + let ssh_key = srv.get("ssh_key_path").and_then(|v| v.as_str()).unwrap_or("~/.ssh/htz_ops").to_string(); + let ssh_user = srv.get("installer_user").and_then(|v| v.as_str()).unwrap_or("root").to_string(); + let floating_ip_ncl = srv.get("floating_ip").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let not_use = srv.get("not_use").and_then(|v| v.as_bool()).unwrap_or(false); + let additional_volumes = srv + .get("storage") + .and_then(|s| s.get("additional_volumes")) + .cloned() + .unwrap_or(Value::Array(vec![])); + map.insert(hostname.to_string(), json!({ + "role": role, "scale": scale, "ssh_key": ssh_key, "ssh_user": ssh_user, + "floating_ip_ncl": floating_ip_ncl, "not_use": not_use, + "additional_volumes": additional_volumes, + })); + } + map +} + +/// Read `infra/{env}/.servers-state.json` → hostname → cached state JSON. +async fn load_servers_cache(ws_path: &str, env: &str) -> HashMap { + let mut map = HashMap::new(); + if ws_path.is_empty() || env.is_empty() { return map; } + let cache_path = std::path::Path::new(ws_path) + .join("infra").join(env).join(".servers-state.json"); + let Ok(bytes) = tokio::fs::read(&cache_path).await else { return map }; + let Ok(v) = serde_json::from_slice::(&bytes) else { return map }; + let Some(obj) = v.as_object() else { return map }; + for (k, v) in obj { + map.insert(k.clone(), v.clone()); + } + map +} + +/// Read `infra/{env}/.provisioning-state.ncl` and extract per-server taskserv states. +/// Returns a map: hostname → array of `{name, state, ended_at}`. +async fn load_provisioning_taskservs(ws_path: &str, env: &str) -> HashMap { + let mut map = HashMap::new(); + if ws_path.is_empty() || env.is_empty() { return map; } + let state_path = std::path::Path::new(ws_path) + .join("infra").join(env).join(".provisioning-state.ncl"); + if !state_path.exists() { return map; } + // No import path needed — .provisioning-state.ncl has no imports. + let exported = ctx::ncl_export(&state_path, None).await; + let Some(servers_obj) = exported.get("servers").and_then(|v| v.as_object()) else { return map }; + for (hostname, srv_val) in servers_obj { + let Some(taskservs) = srv_val.get("taskservs").and_then(|v| v.as_object()) else { continue }; + let items: Vec = taskservs.iter().map(|(name, ts)| { + json!({ + "name": name, + "state": ts.get("state").and_then(|v| v.as_str()).unwrap_or(""), + "started_at": ts.get("started_at").and_then(|v| v.as_str()).unwrap_or(""), + "ended_at": ts.get("ended_at").and_then(|v| v.as_str()).unwrap_or(""), + "operation": ts.get("operation").and_then(|v| v.as_str()).unwrap_or(""), + }) + }).collect(); + map.insert(hostname.clone(), Value::Array(items)); + } + map +} + +/// Fetch live hcloud server status for a set of server names. +/// Returns `server_name → hcloud_status` (e.g. "running", "off", "starting"). +/// Non-fatal: returns empty map on any error (hcloud not configured, network down, etc.). +async fn hcloud_server_statuses(app: &AppState, names: &[String]) -> HashMap { + if names.is_empty() { return HashMap::new(); } + let Ok(resp) = ctx::invoke_tool(app, "server_list", json!({})).await else { + return HashMap::new(); + }; + let Some(items) = resp["items"].as_array() else { return HashMap::new() }; + items.iter().filter_map(|srv| { + let name = srv["name"].as_str()?; + let status = srv["status"].as_str().unwrap_or("unknown"); + if names.iter().any(|n| n == name) { + Some((name.to_string(), status.to_string())) + } else { + None + } + }).collect() +} + +fn build_overview_components( + raw: Option<&Vec>, + ncl_states: &HashMap, + all_running: bool, +) -> Vec { + let Some(arr) = raw else { return vec![] }; + arr.iter().map(|c| { + let cname = c.as_str().unwrap_or(""); + let ncl = ncl_states.get(cname).cloned().unwrap_or_default(); + let state = if !ncl.is_empty() { + ncl + } else if all_running { + "running".to_string() + } else { + String::new() + }; + json!({ "name": cname, "state": state }) + }).collect() +} + +fn build_overview_servers( + ncl_servers: &HashMap, + hcloud: &HashMap, +) -> Vec { + ncl_servers.keys().map(|srv_name| { + let live = hcloud.get(srv_name).cloned().unwrap_or_default(); + let ncl = ncl_servers.get(srv_name).cloned().unwrap_or_default(); + let state = if !live.is_empty() { live } else { ncl }; + json!({ "name": srv_name, "state": state }) + }).collect() +} + +/// Returns only environments that have `infra//settings.ncl`. +fn ws_infra_envs(ws: &Value) -> Vec { + let ws_path = ws["path"].as_str().unwrap_or(""); + ws["environments"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|e| e["name"].as_str().map(str::to_string)) + .filter(|name| { + if ws_path.is_empty() { return true; } + std::path::Path::new(ws_path) + .join("infra").join(name).join("settings.ncl") + .exists() + }) + .collect() + }) + .unwrap_or_default() +} + +fn ws_env_info(ws: Option<&Value>) -> WsEnvInfo { + let Some(ws) = ws else { + return WsEnvInfo { envs: vec![], has_clusters: false, has_workflows: false }; + }; + let ws_path = ws["path"].as_str().unwrap_or(""); + let envs = ws_infra_envs(ws); + let has_clusters = ws_has_clusters(ws_path, &envs); + let has_workflows = ws_has_workflows(ws_path, &envs); + WsEnvInfo { envs, has_clusters, has_workflows } +} + +fn insert_ws_env_info(c: &mut Context, info: &WsEnvInfo) { + c.insert("ws_envs", &info.envs); + c.insert("ws_has_clusters", &info.has_clusters); + c.insert("ws_has_workflows", &info.has_workflows); +} + +async fn ws_path(app: &AppState, ws_name: &str) -> Option { + let detail = ctx::invoke_tool(app, "workspace_show", json!({ "name": ws_name })).await.ok()?; + let p = detail["path"].as_str()?; + Some(PathBuf::from(p)) +} + +// ── Auth / Sessions ─────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct LoginForm { + pub password: String, +} + +pub async fn login_page(State(app): State, headers: HeaderMap) -> Response { + if let Some(id) = extract_session_cookie(&headers) { + if app.sessions.get(id).await.is_some() { + return Redirect::to("/ui/").into_response(); + } + } + let mut c = base_ctx(&app); + c.insert("error", &Option::::None); + match render(&app, "pages/login.html", &c) { + Ok(html) => html.into_response(), + Err(e) => e.into_response(), + } +} + +pub async fn login_post( + State(app): State, + Form(form): Form, +) -> Response { + let authenticated = validate_credentials(&app, &form.password).await; + + if !authenticated { + let mut c = base_ctx(&app); + c.insert("error", &Some("Invalid credentials")); + return match render(&app, "pages/login.html", &c) { + Ok(html) => (StatusCode::UNAUTHORIZED, html).into_response(), + Err(e) => e.into_response(), + }; + } + + let session = SessionData::new("ui-user", vec!["admin".to_string()]); + let session_id = app.sessions.create(session).await; + app.sessions.prune().await; + + let cookie = format!( + "{}={}; HttpOnly; SameSite=Lax; Path=/; Max-Age=28800", + super::session::COOKIE_NAME, + session_id + ); + let mut resp_headers = HeaderMap::new(); + resp_headers.insert(header::SET_COOKIE, HeaderValue::from_str(&cookie).unwrap()); + (resp_headers, Redirect::to("/ui/")).into_response() +} + +async fn validate_credentials(app: &AppState, password: &str) -> bool { + if password.is_empty() { + return false; + } + + // 1. Control-center delegation (primary when configured) + if let Some(ref cc_url) = app.control_center_url { + if let Ok(client) = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + { + let url = format!("{}/api/v1/auth/verify", cc_url); + match client.post(&url).bearer_auth(password).send().await { + Ok(resp) if resp.status().is_success() => return true, + Ok(_) => return false, + Err(e) => tracing::warn!("control-center auth request failed: {e}"), + } + } + } + + // 2. Local JWT validation + if let Some(ref jwt) = app.jwt { + if jwt.validate(password).is_ok() { + return true; + } + } + + // 3. Static UI password + if let Some(ref ui_pw) = app.ui_password { + return password == ui_pw.as_ref(); + } + + // 4. Open/solo mode — any non-empty password when no auth configured + app.jwt.is_none() && app.ui_password.is_none() && app.control_center_url.is_none() +} + +pub async fn logout(State(app): State, headers: HeaderMap) -> Response { + if let Some(id) = extract_session_cookie(&headers) { + app.sessions.delete(id).await; + } + let expire = format!( + "{}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0", + super::session::COOKIE_NAME + ); + let mut resp_headers = HeaderMap::new(); + resp_headers.insert(header::SET_COOKIE, HeaderValue::from_str(&expire).unwrap()); + (resp_headers, Redirect::to("/ui/login")).into_response() +} + +// ── Dashboard ───────────────────────────────────────────────────────────────── + +pub async fn dashboard( + State(app): State, + Extension(perms): Extension, +) -> Result, UiError> { + let stats = ctx::invoke_tool(&app, "dashboard_statistics", json!({})).await.ok(); + let audit = ctx::invoke_tool(&app, "dashboard_audit", json!({"limit": 20})).await.ok(); + let tool_count = app.registry.list().len(); + let total_invocations = stats.as_ref() + .and_then(|s| s["total_invocations"].as_u64()) + .unwrap_or(0); + let active_sessions = stats.as_ref() + .and_then(|s| s["active_sessions"].as_u64()) + .unwrap_or(0); + let secs_since_reload: Option = stats.as_ref() + .and_then(|s| s["secs_since_reload"].as_u64()); + // audit tool may return an array directly or a wrapper object + let recent: &Value = audit.as_ref().and_then(|a| { + if a.is_array() { Some(a) } else { a.get("items") } + }).unwrap_or(&Value::Null); + let mut c = base_ctx(&app); + perm_ctx(&perms, &mut c); + c.insert("tool_count", &tool_count); + c.insert("total_invocations", &total_invocations); + c.insert("active_sessions", &active_sessions); + c.insert("secs_since_reload", &secs_since_reload); + c.insert("recent", recent); + render(&app, "pages/dashboard.html", &c) +} + +// ── Tools ───────────────────────────────────────────────────────────────────── + +pub async fn tools_list( + State(app): State, + Extension(perms): Extension, +) -> Result, UiError> { + let tools = app.registry.list(); + let mut c = base_ctx(&app); + perm_ctx(&perms, &mut c); + c.insert("tools", &tools); + render(&app, "pages/tools.html", &c) +} + +pub async fn tool_detail( + State(app): State, + Extension(perms): Extension, + Path(name): Path, +) -> Result { + let Some(meta) = app.registry.metadata(&name) else { + return Ok(Redirect::to("/ui/tools").into_response()); + }; + let schema_pretty = serde_json::to_string_pretty(&*meta.schema).unwrap_or_default(); + let mut c = base_ctx(&app); + perm_ctx(&perms, &mut c); + c.insert("tool", meta); + c.insert("schema_pretty", &schema_pretty); + Ok(render(&app, "pages/tool_detail.html", &c)?.into_response()) +} + +// ── Workspaces ──────────────────────────────────────────────────────────────── + +pub async fn workspaces( + State(app): State, + Extension(perms): Extension, +) -> Result, UiError> { + let raw = ctx::invoke_tool(&app, "workspace_list", json!({})).await.ok(); + let ws_root_set = !app.env.workspaces_root.as_os_str().is_empty(); + let import_path = app.nickel_import_path.as_deref(); + + // Async gather: nickel export card.ncl for each infra workspace. + let raw_pairs: Vec<(Value, std::path::PathBuf)> = raw + .as_ref() + .and_then(|v| v["items"].as_array()) + .map(|items| items.iter().filter_map(|ws| { + let path = ws["path"].as_str().unwrap_or(""); + if path.is_empty() { return None; } + let card_path = std::path::Path::new(path).join("card.ncl"); + if !card_path.exists() { return None; } + Some((ws.clone(), card_path)) + }).collect()) + .unwrap_or_default(); + + let mut enriched: Vec = Vec::with_capacity(raw_pairs.len()); + for (ws, card_path) in raw_pairs { + let card = ctx::ncl_export(&card_path, import_path).await; + let tagline = card["tagline"].as_str().unwrap_or("").to_string(); + let status = card["status"].as_str().unwrap_or("").to_string(); + let tags: Vec = card["tags"].as_array() + .map(|a| a.iter().filter_map(|t| t.as_str().map(str::to_owned)).collect()) + .unwrap_or_default(); + let features: Vec = card["features"].as_array() + .map(|a| a.iter().filter_map(|t| t.as_str().map(str::to_owned)).collect()) + .unwrap_or_default(); + enriched.push(json!({ + "name": ws["name"], + "path": ws["path"], + "active": ws["active"], + "environments": ws["environments"], + "tagline": tagline, + "status": status, + "tags": tags, + "features": features, + })); + } + + let mut c = base_ctx(&app); + perm_ctx(&perms, &mut c); + c.insert("infra_workspaces", &enriched); + c.insert("ws_root_set", &ws_root_set); + render(&app, "pages/workspaces.html", &c) +} + +pub async fn workspace_detail( + State(app): State, + Extension(perms): Extension, + Path(name): Path, +) -> Result { + let detail = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &name })).await; + match detail { + Err(_) => Ok(Redirect::to("/ui/workspaces").into_response()), + Ok(detail) => { + let wi = ws_env_info(Some(&detail)); + let ws_path = detail["path"].as_str().unwrap_or(""); + let import_path = app.nickel_import_path.as_deref(); + + // Build enriched infra-only environment list with component + server states. + let infra_env_set: std::collections::HashSet<&str> = + wi.envs.iter().map(String::as_str).collect(); + + let mut infra_envs: Vec = Vec::new(); + let mut total_comp_count: usize = 0; + let mut total_wf_count: usize = 0; + + if let Some(envs) = detail["environments"].as_array() { + // Collect all server names across infra envs for one hcloud call. + let ncl_server_names: Vec = envs.iter() + .filter(|e| infra_env_set.contains(e["name"].as_str().unwrap_or(""))) + .flat_map(|_e| { + // We don't know server names yet — gather from state files below. + // Placeholder: hcloud_server_statuses called after state load. + std::iter::empty::() + }) + .collect(); + let _ = ncl_server_names; // resolved per-env below + + for env_val in envs { + let env_name = env_val["name"].as_str().unwrap_or(""); + if !infra_env_set.contains(env_name) { continue; } + + let states = load_env_states(ws_path, env_name, import_path).await; + + // Fetch live hcloud status for the servers known in the state file. + let srv_names: Vec = states.servers.keys().cloned().collect(); + let hcloud = hcloud_server_statuses(&app, &srv_names).await; + + // Components: taskserv → NCL state; cluster → hcloud-inferred. + // We don't have mode info at this level, so mark "cluster" if not in state. + let all_running = !hcloud.is_empty() + && hcloud.values().all(|s| s == "running"); + + let components: Vec = build_overview_components( + env_val["components"].as_array(), + &states.components, + all_running, + ); + + // Servers: prefer live hcloud status over NCL aggregate. + let mut servers: Vec = build_overview_servers(&states.servers, &hcloud); + servers.sort_by(|a, b| { + a["name"].as_str().unwrap_or("").cmp(b["name"].as_str().unwrap_or("")) + }); + + let workflows: Vec<&Value> = env_val["workflows"] + .as_array() + .map(|a| a.iter().collect()) + .unwrap_or_default(); + + total_comp_count += components.len(); + total_wf_count += workflows.len(); + + infra_envs.push(json!({ + "name": env_name, + "components": components, + "servers": servers, + "workflows": workflows, + })); + } + } + + let total_srv_count: usize = infra_envs.iter() + .filter_map(|e| e["servers"].as_array()) + .map(|a| a.len()) + .sum(); + + let card_path = std::path::Path::new(ws_path).join("card.ncl"); + let ws_card = if card_path.exists() { + ctx::ncl_export(&card_path, import_path).await + } else { + Value::Null + }; + + let mut c = ws_ctx(&app, &name, "overview"); + perm_ctx(&perms, &mut c); + insert_ws_env_info(&mut c, &wi); + c.insert("ws", &detail); + c.insert("ws_card", &ws_card); + c.insert("infra_envs", &infra_envs); + c.insert("comp_count", &total_comp_count); + c.insert("wf_count", &total_wf_count); + c.insert("srv_count", &total_srv_count); + Ok(render(&app, "pages/workspace_detail.html", &c)?.into_response()) + } + } +} + +pub async fn workspace_servers( + State(app): State, + Extension(perms): Extension, + Path(name): Path, + Query(params): Query>, +) -> Result { + let env_filter = params.get("env").cloned(); + let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &name })).await.ok(); + let wi = ws_env_info(ws.as_ref()); + + // Load role/scale from servers.ncl for the active env. + let ws_path = ws.as_ref().and_then(|v| v["path"].as_str()).unwrap_or(""); + let import_path = app.nickel_import_path.as_deref(); + let ncl_env = env_filter.as_deref() + .or_else(|| wi.envs.first().map(String::as_str)) + .unwrap_or(""); + let roles = load_infra_server_roles(ws_path, ncl_env, import_path).await; + + let cache = load_servers_cache(ws_path, ncl_env).await; + let dag_states = load_provisioning_taskservs(ws_path, ncl_env).await; + + // Build hcloud lookup: name → full server JSON (non-fatal if unavailable). + let hcloud_map: HashMap = ctx::invoke_tool(&app, "server_list", json!({})) + .await + .ok() + .and_then(|sv| sv["items"].as_array().cloned()) + .unwrap_or_default() + .into_iter() + .filter_map(|s| { + let name = s["name"].as_str()?.to_string(); + Some((name, s)) + }) + .collect(); + + let enrich = |mut s: Value, hostname: &str, meta: Option<&Value>| -> Value { + s["role"] = meta.map(|m| m["role"].clone()).unwrap_or(json!("")); + s["scale"] = meta.map(|m| m["scale"].clone()).unwrap_or(Value::Null); + s["provider"] = json!("hetzner"); + let deploy = meta.and_then(|m| m["not_use"].as_bool()).map(|nu| if nu { "skip" } else { "yes" }).unwrap_or("yes"); + s["deploy"] = json!(deploy); + if let Some(cached) = cache.get(hostname) { + s["floating_ip"] = cached.get("floating_ip").cloned().unwrap_or(Value::Null); + s["floating_ip_address"] = cached.get("floating_ip_address").cloned().unwrap_or(Value::Null); + s["last_sync"] = cached.get("last_sync").cloned().unwrap_or(Value::Null); + } + let server_dag = dag_states.get(hostname).and_then(|v| v.as_array()).cloned().unwrap_or_default(); + let total_tasks = server_dag.len(); + let completed = server_dag.iter().filter(|t| t["state"].as_str() == Some("completed")).count(); + let failed = server_dag.iter().filter(|t| t["state"].as_str() == Some("failed")).count(); + s["fsm_state"] = if total_tasks == 0 { + json!("—") + } else if failed > 0 { + json!(format!("{completed}/{total_tasks} [{failed}✗]")) + } else { + json!(format!("{completed}/{total_tasks}")) + }; + s + }; + + // Primary source: NCL-declared servers. Enrich with hcloud data where available. + // This ensures servers are always visible even when the provider API is unreachable. + let items: Vec = if roles.is_empty() { + // No NCL declarations — fall back to whatever hcloud returned. + hcloud_map.values().cloned().map(|s| { + let hostname = s["name"].as_str().unwrap_or("").to_string(); + enrich(s, &hostname, None) + }).collect() + } else { + let mut out = Vec::with_capacity(roles.len()); + for (hostname, meta) in &roles { + let s = if let Some(hcloud) = hcloud_map.get(hostname) { + hcloud.clone() + } else { + json!({ "name": hostname, "status": "not_provisioned" }) + }; + out.push(enrich(s, hostname, Some(meta))); + } + out.sort_by(|a, b| { + a["name"].as_str().unwrap_or("").cmp(b["name"].as_str().unwrap_or("")) + }); + out + }; + let total = items.len(); + let servers = if total > 0 || !roles.is_empty() { + Some(json!({ "items": items, "total": total })) + } else { + None + }; + + let mut c = ws_ctx(&app, &name, "servers"); + perm_ctx(&perms, &mut c); + insert_ws_env_info(&mut c, &wi); + c.insert("ws", &ws); + c.insert("servers", &servers); + c.insert("current_env", &env_filter.unwrap_or_default()); + Ok(render(&app, "pages/workspace_servers.html", &c)?.into_response()) +} + +pub async fn workspace_server_detail( + State(app): State, + Extension(perms): Extension, + Path((ws_name, srv_name)): Path<(String, String)>, +) -> Result { + let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &ws_name })).await.ok(); + let wi = ws_env_info(ws.as_ref()); + let ws_path = ws.as_ref().and_then(|v| v["path"].as_str()).unwrap_or(""); + let import_path = app.nickel_import_path.as_deref(); + + // Search role/scale across all infra envs — first match wins. + let srv_meta = { + let mut found = Value::Null; + for env in &wi.envs { + let roles = load_infra_server_roles(ws_path, env, import_path).await; + if let Some(meta) = roles.get(&srv_name) { + found = meta.clone(); + break; + } + } + found + }; + + // Load cache + provisioning state from the first env that has server data. + let srv_env = wi.envs.iter() + .find(|env| { + let p = std::path::Path::new(ws_path).join("infra").join(env).join("servers.ncl"); + p.exists() + }) + .cloned() + .unwrap_or_default(); + + let cache = load_servers_cache(ws_path, &srv_env).await; + let srv_cache = cache.get(&srv_name).cloned().unwrap_or(Value::Null); + + let all_taskservs = load_provisioning_taskservs(ws_path, &srv_env).await; + let dag_taskservs = all_taskservs.get(&srv_name).cloned().unwrap_or(Value::Array(vec![])); + let dag_total = dag_taskservs.as_array().map(|a| a.len()).unwrap_or(0); + let dag_deployed = dag_taskservs.as_array().map(|a| { + a.iter().filter(|ts| ts["state"].as_str() == Some("completed")).count() + }).unwrap_or(0); + + let srv_volumes_declared = srv_meta.get("additional_volumes").cloned().unwrap_or(Value::Array(vec![])); + + let server = ctx::invoke_tool(&app, "server_show", json!({ "name": &srv_name })).await.ok(); + + // Enrich hcloud-attached volumes: for each int ID in server.volumes, fetch full detail. + let hcloud_volumes: Vec = if let Some(srv) = &server { + let vol_ids: Vec = srv["volumes"] + .as_array() + .map(|a| a.iter().filter_map(|v| v.as_i64()).collect()) + .unwrap_or_default(); + let mut enriched = Vec::with_capacity(vol_ids.len()); + for vol_id in vol_ids { + let detail = ctx::invoke_tool(&app, "volume_show", json!({ "name": vol_id.to_string() })).await; + enriched.push(match detail { + Ok(v) => v, + Err(_) => json!({ "id": vol_id, "name": format!("{vol_id}"), "error": true }), + }); + } + enriched + } else { + vec![] + }; + + let mut c = ws_ctx(&app, &ws_name, "servers"); + perm_ctx(&perms, &mut c); + insert_ws_env_info(&mut c, &wi); + c.insert("server", &server); + c.insert("srv_name", &srv_name); + c.insert("srv_role", &srv_meta["role"]); + c.insert("srv_scale", &srv_meta["scale"]); + c.insert("srv_cache", &srv_cache); + c.insert("dag_taskservs", &dag_taskservs); + c.insert("dag_total", &dag_total); + c.insert("dag_deployed", &dag_deployed); + c.insert("srv_volumes_declared", &srv_volumes_declared); + c.insert("hcloud_volumes", &hcloud_volumes); + Ok(render(&app, "pages/workspace_server_detail.html", &c)?.into_response()) +} + +/// Show detail for a single Hetzner Cloud volume within a workspace context. +pub async fn workspace_volume_detail( + State(app): State, + Extension(perms): Extension, + Path((ws_name, vol_id)): Path<(String, String)>, +) -> Result { + let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &ws_name })).await.ok(); + let wi = ws_env_info(ws.as_ref()); + let volume = ctx::invoke_tool(&app, "volume_show", json!({ "name": &vol_id })).await.ok(); + let mut c = ws_ctx(&app, &ws_name, "servers"); + perm_ctx(&perms, &mut c); + insert_ws_env_info(&mut c, &wi); + c.insert("volume", &volume); + c.insert("vol_id", &vol_id); + Ok(render(&app, "pages/workspace_volume_detail.html", &c)?.into_response()) +} + +/// POST `/ui/workspaces/{ws}/servers/{srv}/scale-down` +/// +/// Gate-1: rejects ControlPlane nodes (HTTP 422). +/// Gate-2: rejects if removing this node would bring the live worker count below scale.min. +/// Only reaches server_delete on Worker / LoadBalancer nodes that pass both gates. +pub async fn server_scale_down( + State(app): State, + Extension(perms): Extension, + Path((ws_name, srv_name)): Path<(String, String)>, +) -> Result { + if !perms.can_operate { + return Ok(( + StatusCode::FORBIDDEN, + Json(json!({ "error": "Requires operator permission" })), + ).into_response()); + } + + let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &ws_name })).await.ok(); + let wi = ws_env_info(ws.as_ref()); + let ws_path = ws.as_ref().and_then(|v| v["path"].as_str()).unwrap_or(""); + let import_path = app.nickel_import_path.as_deref(); + + // Resolve role + scale for this server across all infra envs. + let mut srv_role = String::new(); + let mut srv_scale = Value::Null; + for env in &wi.envs { + let roles = load_infra_server_roles(ws_path, env, import_path).await; + if let Some(meta) = roles.get(&srv_name) { + srv_role = meta["role"].as_str().unwrap_or("").to_string(); + srv_scale = meta["scale"].clone(); + break; + } + } + + // Gate-1: ControlPlane nodes are immutable via the UI. + if srv_role == "ControlPlane" { + return Ok(( + StatusCode::UNPROCESSABLE_ENTITY, + Json(json!({ + "error": "ControlPlane nodes cannot be deleted via scale-down", + "role": srv_role, + "hint": "Use the CLI teardown workflow to destroy the full infra env", + })), + ).into_response()); + } + + // Gate-2: enforce scale.min if a ScalePolicy is declared. + if !srv_scale.is_null() { + let min = srv_scale["min"].as_u64().unwrap_or(0) as usize; + if min > 0 { + let pattern = srv_scale["template"]["hostname_pattern"] + .as_str() + .unwrap_or(""); + let prefix = pattern.split('{').next().unwrap_or(""); + if !prefix.is_empty() { + let all = ctx::invoke_tool(&app, "server_list", json!({})).await.ok(); + let is_peer = |s: &&Value| { + s["name"].as_str() + .map(|n| n != srv_name && n.starts_with(prefix)) + .unwrap_or(false) + }; + let live_count = all + .as_ref() + .and_then(|v| v["items"].as_array()) + .map(|items| items.iter().filter(is_peer).count()) + .unwrap_or(0); + // live_count is the count *after* removal of this node. + if live_count < min { + return Ok(( + StatusCode::UNPROCESSABLE_ENTITY, + Json(json!({ + "error": format!( + "Removing {} would leave {} worker(s), below scale.min ({})", + srv_name, live_count, min + ), + "live_after_removal": live_count, + "scale_min": min, + })), + ).into_response()); + } + } + } + } + + let result = ctx::invoke_tool(&app, "server_delete", json!({ "name": &srv_name })).await?; + Ok(Json(result).into_response()) +} + +struct CpTarget { + ip: String, + user: String, + key: String, +} + +/// Find CP server's public IP + SSH credentials from the roles map + hcloud list. +fn find_cp_target(roles: &HashMap, hcloud_items: &[Value]) -> Option { + let (cp_name, cp_meta) = roles.iter() + .find(|(_, v)| v["role"].as_str() == Some("ControlPlane"))?; + let ip = hcloud_items.iter() + .find(|s| s["name"].as_str() == Some(cp_name.as_str())) + .and_then(|s| s["public_net"]["ipv4"]["ip"].as_str()) + .map(|s| s.to_string())?; + let user = cp_meta["ssh_user"].as_str().unwrap_or("root").to_string(); + let key = cp_meta["ssh_key"].as_str().unwrap_or("~/.ssh/htz_ops").to_string(); + Some(CpTarget { ip, user, key }) +} + +/// Parse `cluster_pods` response into a flat pod list + namespace→(running,total) index. +fn unpack_pods(resp: Option<&Value>) -> (Vec, HashMap) { + let Some(items) = resp.and_then(|v| v["items"].as_array()) else { + return (Vec::new(), HashMap::new()); + }; + let mut ns_map: HashMap = HashMap::new(); + for pod in items { + let ns = pod["metadata"]["namespace"].as_str().unwrap_or("").to_string(); + let phase = pod["status"]["phase"].as_str().unwrap_or(""); + let e = ns_map.entry(ns).or_insert((0, 0)); + e.1 += 1; + if phase == "Running" { e.0 += 1; } + } + (items.clone(), ns_map) +} + +/// Count pods matching a name pattern (CLI-equivalent for components without a namespace). +fn count_pods_by_name(pods: &[Value], name_pattern: &str) -> (usize, usize) { + let total = pods.iter().filter(|p| p["metadata"]["name"].as_str().unwrap_or("").contains(name_pattern)).count(); + let running = pods.iter().filter(|p| { + p["metadata"]["name"].as_str().unwrap_or("").contains(name_pattern) + && p["status"]["phase"].as_str() == Some("Running") + }).count(); + (running, total) +} + +pub async fn workspace_components( + State(app): State, + Extension(perms): Extension, + Path(name): Path, + Query(params): Query>, +) -> Result { + let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &name })).await.ok(); + let wi = ws_env_info(ws.as_ref()); + let env_filter = params.get("env").cloned(); + + let ws_path = ws.as_ref() + .and_then(|v| v["path"].as_str()) + .unwrap_or(""); + let import_path = app.nickel_import_path.as_deref(); + + // Load component states from .provisioning-state.ncl across all infra envs. + let filtered_envs: Vec = match &env_filter { + Some(e) => wi.envs.iter().filter(|env| env.as_str() == e.as_str()).cloned().collect(), + None => wi.envs.clone(), + }; + // Load NCL states (taskserv components) and env-level server states for hcloud lookup. + let mut comp_states: HashMap = HashMap::new(); + let mut comp_server_map: HashMap = HashMap::new(); + let mut all_srv_names: Vec = Vec::new(); + for env in &filtered_envs { + let s = load_env_states(ws_path, env, import_path).await; + for (k, v) in s.components { + let rank = state_rank(&v); + let e = comp_states.entry(k).or_insert_with(|| v.clone()); + if rank > state_rank(e) { *e = v; } + } + for (k, v) in s.comp_server { + comp_server_map.entry(k).or_insert(v); + } + for k in s.servers.into_keys() { + if !all_srv_names.contains(&k) { all_srv_names.push(k); } + } + } + // Fetch full hcloud server list once — needed for both status and public IP lookup. + let raw_hcloud = ctx::invoke_tool(&app, "server_list", json!({})).await.ok(); + let all_hcloud_items: Vec = raw_hcloud + .as_ref() + .and_then(|v| v["items"].as_array().cloned()) + .unwrap_or_default(); + + // name → status map (same logic as hcloud_server_statuses but reuses the fetched list) + let hcloud: HashMap = all_hcloud_items.iter() + .filter_map(|s| { + let n = s["name"].as_str()?.to_string(); + if !all_srv_names.is_empty() && !all_srv_names.contains(&n) { return None; } + Some((n, s["status"].as_str().unwrap_or("unknown").to_string())) + }) + .collect(); + let all_srvs_running = !hcloud.is_empty() && hcloud.values().all(|s| s == "running"); + + // SSH to the ControlPlane(s) and collect all pods once. + // all_pods: for name-based matching (taskserv without namespace, same logic as CLI). + // ns_pods: namespace → (running, total) for namespace-declared components. + let mut all_pods: Vec = Vec::new(); + let mut ns_pods: HashMap = HashMap::new(); + for env in &filtered_envs { + let roles = load_infra_server_roles(ws_path, env, import_path).await; + let cp_tgt = find_cp_target(&roles, &all_hcloud_items); + if let Some(tgt) = cp_tgt { + let key_expanded = shellexpand::tilde(&tgt.key).into_owned(); + let resp = ctx::invoke_tool(&app, "cluster_pods", json!({ + "host": tgt.ip, "user": tgt.user, "key": key_expanded, + })).await.ok(); + let (items, ns_map) = unpack_pods(resp.as_ref()); + all_pods.extend(items); + for (ns, (r, t)) in ns_map { + let e = ns_pods.entry(ns).or_insert((0, 0)); + e.0 += r; e.1 += t; + } + } + } + + let list_params = match &env_filter { + Some(e) => json!({ "workspace": &name, "env": e }), + None => json!({ "workspace": &name }), + }; + let raw_items = ctx::invoke_tool(&app, "component_list", list_params) + .await.ok() + .and_then(|v| v["items"].as_array().cloned()) + .unwrap_or_default(); + + let components: Vec = raw_items.into_iter().map(|item| { + let comp_name = item["name"].as_str().unwrap_or("").to_string(); + let inner = item["config"].get(&comp_name).cloned().unwrap_or(Value::Null); + let mode = inner["mode"].as_str().unwrap_or(""); + let mut ops: Vec = inner["operations"] + .as_object() + .map(|o| { + o.iter() + .filter(|(_, v)| v.as_bool().unwrap_or(false)) + .map(|(k, _)| k.clone()) + .collect() + }) + .unwrap_or_default(); + ops.sort(); + let ncl_state = comp_states.get(&comp_name).cloned().unwrap_or_default(); + // Pod count resolution — matches CLI `comp-live-check` logic: + // namespace set → namespace-based lookup in ns_pods + // no namespace → name-based search across all_pods (underscore→hyphen) + let ns = inner["namespace"].as_str().unwrap_or(""); + let (pod_running, pod_total) = if !ns.is_empty() { + ns_pods.get(ns).copied().unwrap_or((0, 0)) + } else if !all_pods.is_empty() { + count_pods_by_name(&all_pods, &comp_name.replace('_', "-")) + } else { + (0, 0) + }; + let pod_detail = if pod_total > 0 { format!("{pod_running}/{pod_total} pods") } else { String::new() }; + let pod_badge = if pod_detail.is_empty() { "" + } else if pod_running == pod_total { "badge-success" } + else if pod_running == 0 { "badge-error" } + else { "badge-warning" }; + // hcloud / cluster-server live status (taskserv fallback) + let hcloud_status = comp_server_map.get(&comp_name) + .and_then(|srv| hcloud.get(srv)) + .cloned() + .unwrap_or_default(); + let live_state = if !hcloud_status.is_empty() { hcloud_status } + else if all_srvs_running { "running".to_string() } + else { String::new() }; + let state = if !ncl_state.is_empty() { + ncl_state.clone() + } else if !live_state.is_empty() { + live_state.clone() + } else { + String::new() + }; + json!({ + "name": comp_name, + "env": item["env"], + "mode": mode, + "target": inner["target"].as_str().unwrap_or(""), + "namespace": inner["namespace"].as_str().unwrap_or(""), + "version": inner["version"].as_str().unwrap_or(""), + "operations": ops, + "ncl_state": ncl_state, + "live_state": live_state, + "pod_detail": pod_detail, + "pod_badge": pod_badge, + "state": state, + }) + }).collect(); + + let mut c = ws_ctx(&app, &name, "components"); + perm_ctx(&perms, &mut c); + insert_ws_env_info(&mut c, &wi); + c.insert("components", &components); + c.insert("current_env", &env_filter.unwrap_or_default()); + Ok(render(&app, "pages/workspace_components.html", &c)?.into_response()) +} + +pub async fn workspace_component_detail( + State(app): State, + Extension(perms): Extension, + Path((ws_name, comp_name)): Path<(String, String)>, +) -> Result { + let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &ws_name })).await.ok(); + let wi = ws_env_info(ws.as_ref()); + let env_name = wi.envs.first().cloned().unwrap_or_default(); + + let raw = if !env_name.is_empty() { + ctx::invoke_tool(&app, "component_show", json!({ + "workspace": &ws_name, "env": &env_name, "name": &comp_name, + })).await.ok() + } else { + None + }; + + let component = raw.as_ref().map(|r| { + let inner = r["config"].get(&comp_name).cloned().unwrap_or(Value::Null); + let mut ops: Vec = inner["operations"] + .as_object() + .map(|o| { + let mut v: Vec = o.iter() + .filter(|(_, val)| val.as_bool().unwrap_or(false)) + .map(|(k, _)| k.clone()) + .collect(); + v.sort(); + v + }) + .unwrap_or_default(); + ops.sort(); + let ext = &r["extension_metadata"]; + let ext_provides: Vec = ext["provides"].as_array() + .map(|arr| arr.iter().filter_map(|o| { + o["id"].as_str().map(str::to_owned) + .or_else(|| o.as_str().map(str::to_owned)) + }).collect()) + .unwrap_or_default(); + let ext_requires: Vec = ext["requires"].as_array() + .map(|arr| arr.iter().filter_map(|o| { + o["capability"].as_str().map(str::to_owned) + .or_else(|| o.as_str().map(str::to_owned)) + }).collect()) + .unwrap_or_default(); + let ext_conflicts: Vec = ext["conflicts_with"].as_array() + .map(|arr| arr.iter().filter_map(|o| { + o.as_str().map(str::to_owned) + .or_else(|| o["id"].as_str().map(str::to_owned)) + }).collect()) + .unwrap_or_default(); + json!({ + "name": &comp_name, + "env": &env_name, + "workspace": &ws_name, + "mode": inner["mode"].as_str().unwrap_or(""), + "target": inner["target"].as_str().unwrap_or(""), + "namespace": inner["namespace"].as_str().unwrap_or(""), + "version": inner["version"].as_str().unwrap_or(""), + "operations": ops, + "ext_description": ext["description"].as_str().unwrap_or(""), + "ext_provides": ext_provides, + "ext_requires": ext_requires, + "ext_conflicts": ext_conflicts, + }) + }); + + let dag_entries: Vec = ctx::invoke_tool(&app, "workspace_dag", json!({ "name": &ws_name })) + .await.ok() + .and_then(|d| d["items"].as_array().cloned()) + .unwrap_or_default() + .into_iter() + .flat_map(|item| { + let formula_name = item["name"].as_str().unwrap_or("").to_string(); + item["dag"]["formulas"] + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .filter(|f| { + f["taskserv"].as_str() == Some(&comp_name) + || f["component"].as_str() == Some(&comp_name) + || f["name"].as_str().map(|n| n.contains(&comp_name)).unwrap_or(false) + }) + .map(move |f| json!({ + "formula": &formula_name, + "server": f["server"].as_str().unwrap_or(""), + "state": f["state"].as_str().unwrap_or("—"), + "started": f["started_at"].as_str().unwrap_or(""), + "ended": f["completed_at"].as_str().unwrap_or(""), + })) + .collect::>() + }) + .collect(); + + let mut c = ws_ctx(&app, &ws_name, "components"); + perm_ctx(&perms, &mut c); + insert_ws_env_info(&mut c, &wi); + c.insert("component", &component); + c.insert("comp_name", &comp_name); + c.insert("env_name", &env_name); + c.insert("dag_entries", &dag_entries); + Ok(render(&app, "pages/workspace_component_detail.html", &c)?.into_response()) +} + +/// Resolve CP target (IP + SSH user + SSH key) for a workspace (first env). +async fn resolve_ws_cp_target(app: &AppState, ws_name: &str) -> Option { + let ws = ctx::invoke_tool(app, "workspace_show", json!({ "name": ws_name })).await.ok()?; + let ws_path = ws["path"].as_str()?; + let envs = ws_infra_envs(&ws); + let env = envs.first()?; + let import_path = app.nickel_import_path.as_deref(); + let roles = load_infra_server_roles(ws_path, env, import_path).await; + let raw = ctx::invoke_tool(app, "server_list", json!({})).await.ok()?; + let items = raw["items"].as_array()?; + find_cp_target(&roles, items) +} + +/// Parse a Kubernetes pod JSON object into a compact display record. +fn parse_pod_entry(pod: &Value) -> Value { + let name = pod["metadata"]["name"].as_str().unwrap_or("").to_string(); + let ns = pod["metadata"]["namespace"].as_str().unwrap_or("").to_string(); + let phase = pod["status"]["phase"].as_str().unwrap_or("Unknown").to_string(); + let created = pod["metadata"]["creationTimestamp"].as_str().unwrap_or("").to_string(); + + let containers = pod["status"]["containerStatuses"].as_array(); + let (ready, total, restarts) = containers.map(|arr| { + let t = arr.len(); + let r = arr.iter().filter(|c| c["ready"].as_bool().unwrap_or(false)).count(); + let rs: i64 = arr.iter() + .map(|c| c["restartCount"].as_i64().unwrap_or(0)) + .sum(); + (r, t, rs) + }).unwrap_or((0, 0, 0)); + + let ready_str = if total > 0 { format!("{ready}/{total}") } else { "—".into() }; + json!({ "name": name, "namespace": ns, "phase": phase, "ready": ready_str, "restarts": restarts, "created": created }) +} + +/// `GET /ui/workspaces/{ws}/components/{comp}/pods` +/// Returns live pod list for the component's configured namespace as JSON. +pub async fn workspace_component_pods( + State(app): State, + Extension(_perms): Extension, + Path((ws_name, comp_name)): Path<(String, String)>, +) -> Result { + let env_name = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &ws_name })) + .await.ok() + .and_then(|ws| ws_infra_envs(&ws).into_iter().next()) + .unwrap_or_default(); + + let comp_ns = ctx::invoke_tool(&app, "component_show", json!({ + "workspace": &ws_name, "env": &env_name, "name": &comp_name, + })).await.ok() + .and_then(|r| { + r["config"].get(&comp_name) + .and_then(|c| c["namespace"].as_str()) + .map(str::to_owned) + }) + .unwrap_or_default(); + + let Some(tgt) = resolve_ws_cp_target(&app, &ws_name).await else { + return Ok(Json(json!({ "error": "no control plane resolved", "items": [] })).into_response()); + }; + let key_expanded = shellexpand::tilde(&tgt.key).into_owned(); + + let pods_resp = ctx::invoke_tool(&app, "cluster_pods", json!({ + "host": &tgt.ip, "user": &tgt.user, "key": &key_expanded, + })).await; + + let name_pattern = comp_name.replace('_', "-"); + + match pods_resp { + Err(e) => { + Ok(Json(json!({ "error": e.to_string(), "items": [] })).into_response()) + } + Ok(pods_resp) => { + let items: Vec = pods_resp["items"] + .as_array() + .map(|arr| { + arr.iter() + .filter(|p| { + if !comp_ns.is_empty() { + p["metadata"]["namespace"].as_str() == Some(&comp_ns) + } else { + p["metadata"]["name"].as_str().unwrap_or("").contains(name_pattern.as_str()) + } + }) + .map(parse_pod_entry) + .collect() + }) + .unwrap_or_default(); + + Ok(Json(json!({ "items": items, "namespace": comp_ns, "name_pattern": name_pattern })).into_response()) + } + } +} + +/// `GET /ui/workspaces/{ws}/components/{comp}/pods/{ns}/{pod}/describe` +/// Returns `kubectl describe pod` output as JSON `{ output: "..." }`. +pub async fn workspace_component_describe_pod( + State(app): State, + Extension(_perms): Extension, + Path((ws_name, _comp_name, ns, pod)): Path<(String, String, String, String)>, +) -> Result { + let Some(tgt) = resolve_ws_cp_target(&app, &ws_name).await else { + return Ok(Json(json!({ "error": "no control plane resolved" })).into_response()); + }; + let key_expanded = shellexpand::tilde(&tgt.key).into_owned(); + + let result = ctx::invoke_tool(&app, "cluster_describe_pod", json!({ + "host": &tgt.ip, "user": &tgt.user, "key": &key_expanded, + "namespace": ns, "pod": pod, + })).await + .unwrap_or_else(|e| json!({ "error": e.to_string() })); + + Ok(Json(result).into_response()) +} + +pub async fn workspace_dag( + State(app): State, + Extension(perms): Extension, + Path(name): Path, +) -> Result { + let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &name })).await.ok(); + let dag_resp = ctx::invoke_tool(&app, "workspace_dag", json!({ "name": &name })).await.ok(); + + let dag_json = dag_resp.as_ref() + .and_then(|d| d["items"].as_array()) + .map(|items| dag_to_cytoscape(items)) + .and_then(|v| serde_json::to_string(&v).ok()) + .unwrap_or_else(|| r#"{"nodes":[],"edges":[]}"#.to_string()); + + let env_name = dag_resp.as_ref() + .and_then(|d| d["items"].as_array()) + .and_then(|items| items.first()) + .and_then(|item| item["env"].as_str()) + .unwrap_or("") + .to_string(); + + let wi = ws_env_info(ws.as_ref()); + let mut c = ws_ctx(&app, &name, "dag"); + perm_ctx(&perms, &mut c); + insert_ws_env_info(&mut c, &wi); + c.insert("ws", &ws); + c.insert("dag_json", &dag_json); + c.insert("env_name", &env_name); + Ok(render(&app, "pages/workspace_dag.html", &c)?.into_response()) +} + +fn push_dep_edges( + deps: &[Value], + formula_id: &str, + full_id: &str, + edges: &mut Vec, + edge_idx: &mut usize, +) { + for dep in deps { + let dep_id = dep["node_id"].as_str().unwrap_or("?"); + let dep_full = format!("{formula_id}::{dep_id}"); + let cond = dep["condition"].as_str().unwrap_or(""); + edges.push(json!({ + "id": format!("e{edge_idx}"), + "source": dep_full, + "target": full_id, + "kind": cond, + })); + *edge_idx += 1; + } +} + +fn dag_to_cytoscape(items: &[Value]) -> Value { + let mut nodes: Vec = Vec::new(); + let mut edges: Vec = Vec::new(); + let mut edge_idx = 0usize; + + for item in items { + let formulas = match item["dag"]["formulas"].as_array() { + Some(f) => f, + None => continue, + }; + for formula in formulas { + let formula_id = formula["id"].as_str().unwrap_or("formula"); + let server = formula["server"].as_str().unwrap_or(""); + let formula_label = if server.is_empty() { formula_id } else { server }; + + nodes.push(json!({ + "id": formula_id, + "label": formula_label, + "kind": "formula", + "description": formula["description"].as_str().unwrap_or(""), + "server": server, + })); + + let task_nodes = formula["nodes"].as_array().map(|v| v.as_slice()).unwrap_or(&[]); + for node in task_nodes { + let node_id = node["id"].as_str().unwrap_or("?"); + let full_id = format!("{formula_id}::{node_id}"); + let taskserv_name = node["taskserv"]["name"].as_str().unwrap_or(node_id); + let health_gate = node["health_gate"].as_bool().unwrap_or(false); + + nodes.push(json!({ + "id": &full_id, + "label": taskserv_name, + "kind": "task", + "description": format!("{node_id} on {server}"), + "health_gate": health_gate, + })); + + let deps = node["depends_on"].as_array().map(|v| v.as_slice()).unwrap_or(&[]); + if deps.is_empty() { + edges.push(json!({ + "id": format!("e{edge_idx}"), + "source": formula_id, + "target": &full_id, + "kind": "entry", + })); + edge_idx += 1; + } else { + push_dep_edges(deps, formula_id, &full_id, &mut edges, &mut edge_idx); + } + } + } + } + + json!({ "nodes": nodes, "edges": edges }) +} + +pub async fn workspace_clusters( + State(app): State, + Extension(perms): Extension, + Path(name): Path, +) -> Result { + let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &name })).await.ok(); + let clusters = ctx::invoke_tool(&app, "cluster_list", json!({})).await.ok(); + let wi = ws_env_info(ws.as_ref()); + let mut c = ws_ctx(&app, &name, "clusters"); + perm_ctx(&perms, &mut c); + insert_ws_env_info(&mut c, &wi); + c.insert("ws", &ws); + c.insert("clusters", &clusters); + Ok(render(&app, "pages/workspace_clusters.html", &c)?.into_response()) +} + +// ── Workspace-scoped Reflect ────────────────────────────────────────────────── + +pub async fn workspace_modes( + State(app): State, + Extension(perms): Extension, + Path(name): Path, +) -> Result, UiError> { + let root = ws_path(&app, &name).await; + let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &name })).await.ok(); + let wi = ws_env_info(ws.as_ref()); + let mode_list = root.as_deref().map(ctx::list_modes).unwrap_or_default(); + let mut c = ws_ctx(&app, &name, "modes"); + perm_ctx(&perms, &mut c); + insert_ws_env_info(&mut c, &wi); + c.insert("modes", &mode_list); + c.insert("has_root", &root.is_some()); + render(&app, "pages/workspace_modes.html", &c) +} + +pub async fn workspace_backlog( + State(app): State, + Extension(perms): Extension, + Path(name): Path, +) -> Result, UiError> { + let root = ws_path(&app, &name).await; + let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &name })).await.ok(); + let wi = ws_env_info(ws.as_ref()); + let import_path = app.nickel_import_path.as_deref(); + let backlog = match root.as_deref() { + Some(p) => ctx::list_backlog(&p.join("reflection").join("backlog.ncl"), import_path).await, + None => json!({"items": []}), + }; + let mut c = ws_ctx(&app, &name, "backlog"); + perm_ctx(&perms, &mut c); + insert_ws_env_info(&mut c, &wi); + c.insert("backlog", &backlog); + c.insert("has_root", &root.is_some()); + render(&app, "pages/workspace_backlog.html", &c) +} + +/// Form payload for adding a backlog item. +#[derive(Deserialize)] +pub struct BacklogAddForm { + pub title: String, + pub kind: String, + pub priority: String, + pub status: String, + pub notes: String, +} + +pub async fn workspace_backlog_add( + State(app): State, + Extension(perms): Extension, + Path(name): Path, + Form(form): Form, +) -> Result { + if !perms.can_operate { + return Ok(StatusCode::FORBIDDEN.into_response()); + } + let Some(root) = ws_path(&app, &name).await else { + return Ok(StatusCode::NOT_FOUND.into_response()); + }; + let backlog_path = root.join("reflection").join("backlog.ncl"); + + // Derive next item ID from existing content length. + let existing = std::fs::read_to_string(&backlog_path).unwrap_or_default(); + let item_count = existing.matches("make_item").count(); + let new_id = format!("bl-{:03}", item_count + 1); + + // Sanitize: no backslash or backtick in NCL strings. + let sanitize = |s: &str| s.replace(['\\', '`'], "").replace('"', "'"); + let title = sanitize(&form.title); + let notes = sanitize(&form.notes); + + // Only accept known enum variants. + let kind = match form.kind.as_str() { + "Bug" | "Todo" | "Feature" | "Ops" | "Refactor" => form.kind.clone(), + _ => "Todo".to_string(), + }; + let priority = match form.priority.as_str() { + "High" | "Medium" | "Low" => form.priority.clone(), + _ => "Medium".to_string(), + }; + let status = match form.status.as_str() { + "Open" | "Done" | "InProgress" => form.status.clone(), + _ => "Open".to_string(), + }; + + let new_item = format!( + " s.make_item {{\n id = \"{new_id}\",\n title = \"{title}\",\n kind = '{kind},\n priority = '{priority},\n status = '{status},\n notes = \"{notes}\",\n }},\n" + ); + + // Insert before the closing ` ]` of the items array. + let updated = if let Some(pos) = existing.rfind(" ],") { + format!("{}{}{}", &existing[..pos], new_item, &existing[pos..]) + } else if let Some(pos) = existing.rfind(" ]") { + format!("{}{}{}", &existing[..pos], new_item, &existing[pos..]) + } else { + // File missing or malformed — create minimal skeleton. + format!( + "let s = import \"reflection/schemas/backlog.ncl\" in\n\n{{\n items = [\n{new_item} ],\n}}\n" + ) + }; + + std::fs::write(&backlog_path, updated) + .map_err(|e| UiError::Tool(format!("write backlog.ncl: {e}")))?; + + Ok(Redirect::to(&format!("/ui/workspaces/{name}/backlog")).into_response()) +} + +pub async fn workspace_ontology( + State(app): State, + Extension(perms): Extension, + Path(name): Path, +) -> Result, UiError> { + let root = ws_path(&app, &name).await; + let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &name })).await.ok(); + let wi = ws_env_info(ws.as_ref()); + let import_path = app.nickel_import_path.as_deref(); + let graph_json = match root.as_deref() { + Some(p) => { + let v = ctx::ncl_export(&p.join(".ontology").join("core.ncl"), import_path).await; + serde_json::to_string(&v).unwrap_or_else(|_| r#"{"nodes":[],"edges":[]}"#.to_string()) + } + None => r#"{"nodes":[],"edges":[]}"#.to_string(), + }; + let state_json = match root.as_deref() { + Some(p) => { + let v = ctx::ncl_export(&p.join(".ontology").join("state.ncl"), import_path).await; + serde_json::to_string(&v).unwrap_or_else(|_| r#"{"dimensions":[]}"#.to_string()) + } + None => r#"{"dimensions":[]}"#.to_string(), + }; + let mut c = ws_ctx(&app, &name, "ontology"); + perm_ctx(&perms, &mut c); + insert_ws_env_info(&mut c, &wi); + c.insert("graph_json", &graph_json); + c.insert("state_json", &state_json); + c.insert("has_root", &root.is_some()); + render(&app, "pages/workspace_ontology.html", &c) +} + +pub async fn workspace_actions( + State(app): State, + Extension(perms): Extension, + Path(name): Path, +) -> Result, UiError> { + let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &name })).await.ok(); + let wi = ws_env_info(ws.as_ref()); + let all_jobs = ctx::invoke_tool(&app, "orchestrator_jobs", json!({"limit": 100})) + .await.ok() + .and_then(|v| v["items"].as_array().cloned()) + .unwrap_or_default(); + let jobs: Vec = all_jobs.into_iter() + .filter(|j| j["name"].as_str() + .map(|n| n.to_lowercase().contains(&name.to_lowercase())) + .unwrap_or(false)) + .collect(); + let mut c = ws_ctx(&app, &name, "actions"); + perm_ctx(&perms, &mut c); + insert_ws_env_info(&mut c, &wi); + c.insert("jobs", &jobs); + render(&app, "pages/workspace_actions.html", &c) +} + +pub async fn workspace_notifications( + State(app): State, + Extension(perms): Extension, + Path(name): Path, +) -> Result, UiError> { + let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &name })).await.ok(); + let wi = ws_env_info(ws.as_ref()); + let audit = ctx::invoke_tool(&app, "dashboard_audit", json!({"limit": 50})).await.ok(); + let mut c = ws_ctx(&app, &name, "notifications"); + perm_ctx(&perms, &mut c); + insert_ws_env_info(&mut c, &wi); + c.insert("audit", &audit); + render(&app, "pages/workspace_notifications.html", &c) +} + +pub async fn workspace_config( + State(app): State, + Extension(perms): Extension, + Path(name): Path, + Query(params): Query>, +) -> Result, UiError> { + let root = ws_path(&app, &name).await; + let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &name })).await.ok(); + let wi = ws_env_info(ws.as_ref()); + let env_filter = params.get("env").cloned(); + let target_envs: Vec = match &env_filter { + Some(e) if wi.envs.contains(e) => vec![e.clone()], + _ => wi.envs.clone(), + }; + let import_path = app.nickel_import_path.as_deref(); + let env_configs: Vec = if let Some(ref p) = root { + let mut out = Vec::new(); + for env in &target_envs { + let settings = p.join("infra").join(env).join("settings.ncl"); + let config = ctx::ncl_export(&settings, import_path).await; + let config_pretty = serde_json::to_string_pretty(&config).unwrap_or_default(); + out.push(json!({ "env": env, "config": config, "config_pretty": config_pretty })); + } + out + } else { + vec![] + }; + let mut c = ws_ctx(&app, &name, "config"); + perm_ctx(&perms, &mut c); + insert_ws_env_info(&mut c, &wi); + c.insert("env_configs", &env_configs); + c.insert("has_root", &root.is_some()); + c.insert("current_env", &env_filter.unwrap_or_default()); + render(&app, "pages/workspace_config.html", &c) +} + +fn wf_steps_from_ncl(exported: &Value, wf_name: &str) -> (Vec, usize) { + let Some(obj) = exported.as_object() else { + return (vec![], 0); + }; + let steps: Vec = obj.values().flat_map(|wf| { + wf_steps_for_entry(wf, wf_name) + }).collect(); + (steps, obj.len()) +} + +fn wf_steps_for_entry(wf: &Value, wf_name: &str) -> Vec { + let wf_id = wf["id"].as_str().unwrap_or(wf_name).to_string(); + let wf_desc = wf["description"].as_str().unwrap_or("").to_string(); + let Some(steps) = wf["steps"].as_array() else { return vec![] }; + steps.iter().map(|s| { + let targets: Vec = s["targets"].as_array() + .map(|ts| ts.iter().filter_map(|t| { + let comp = t["component"].as_str().unwrap_or(""); + let op = t["operation"].as_str().unwrap_or(""); + if comp.is_empty() { None } else { Some(format!("{comp}:{op}")) } + }).collect()) + .unwrap_or_default(); + let deps: Vec = s["depends_on"].as_array() + .map(|d| d.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()) + .unwrap_or_default(); + json!({ + "workflow_id": wf_id, + "workflow_desc": wf_desc, + "id": s["id"].as_str().unwrap_or(""), + "targets": targets, + "depends_on": deps, + "condition": s["condition"].as_str().unwrap_or(""), + "on_error": s["on_error"].as_str().unwrap_or(""), + }) + }).collect() +} + +pub async fn workspace_workflows( + State(app): State, + Extension(perms): Extension, + Path(name): Path, + Query(params): Query>, +) -> Result, UiError> { + let root = ws_path(&app, &name).await; + let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &name })).await.ok(); + let wi = ws_env_info(ws.as_ref()); + let env_filter = params.get("env").cloned(); + let target_envs: Vec = match &env_filter { + Some(e) if wi.envs.contains(e) => vec![e.clone()], + _ => wi.envs.clone(), + }; + let import_path = app.nickel_import_path.as_deref(); + let env_workflows: Vec = if let Some(ref p) = root { + let mut out = Vec::new(); + for env in &target_envs { + let wf_dir = p.join("infra").join(env).join("workflows"); + if !wf_dir.is_dir() { continue; } + let entries = std::fs::read_dir(&wf_dir) + .into_iter() + .flatten() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("ncl")) + .collect::>(); + let mut workflows = Vec::new(); + for entry in entries { + let path = entry.path(); + let wf_name = path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + let exported = ctx::ncl_export(&path, import_path).await; + // NCL exports as {workflow_id: {id, description, steps, ...}} + let (steps_flat, wf_count) = wf_steps_from_ncl(&exported, &wf_name); + workflows.push(json!({ + "name": wf_name, + "count": wf_count, + "steps": steps_flat, + })); + } + if !workflows.is_empty() { + out.push(json!({ "env": env, "workflows": workflows })); + } + } + out + } else { + vec![] + }; + let mut c = ws_ctx(&app, &name, "workflows"); + perm_ctx(&perms, &mut c); + insert_ws_env_info(&mut c, &wi); + c.insert("env_workflows", &env_workflows); + c.insert("has_root", &root.is_some()); + render(&app, "pages/workspace_workflows.html", &c) +} + +// ── Jobs ────────────────────────────────────────────────────────────────────── + +pub async fn jobs( + State(app): State, + Extension(perms): Extension, +) -> Result, UiError> { + let jobs = ctx::invoke_tool(&app, "orchestrator_jobs", json!({"limit": 50})).await.ok(); + let mut c = base_ctx(&app); + perm_ctx(&perms, &mut c); + c.insert("jobs", &jobs); + render(&app, "pages/jobs.html", &c) +} + +pub async fn job_detail( + State(app): State, + Extension(perms): Extension, + Path(id): Path, +) -> Result, UiError> { + let job = ctx::invoke_tool(&app, "orchestrator_job_show", json!({ "id": &id })).await.ok(); + let mut c = base_ctx(&app); + perm_ctx(&perms, &mut c); + c.insert("job", &job); + c.insert("job_id", &id); + render(&app, "pages/job_detail.html", &c) +} + +// ── Extensions ──────────────────────────────────────────────────────────────── + +pub async fn extensions( + State(app): State, + Extension(perms): Extension, +) -> Result, UiError> { + let ext_list = ctx::invoke_tool(&app, "extension_list", json!({})).await.ok(); + let capabilities = ctx::invoke_tool(&app, "extension_capabilities", json!({})).await.ok(); + let mut c = base_ctx(&app); + perm_ctx(&perms, &mut c); + c.insert("extensions", &ext_list); + c.insert("capabilities", &capabilities); + render(&app, "pages/extensions.html", &c) +} + +// ── Modes (global) ──────────────────────────────────────────────────────────── + +pub async fn modes( + State(app): State, + Extension(perms): Extension, +) -> Result, UiError> { + let mode_list = match app.project_root.as_ref() { + Some(root) => ctx::list_modes(root), + None => vec![], + }; + let mut c = base_ctx(&app); + perm_ctx(&perms, &mut c); + c.insert("modes", &mode_list); + c.insert("has_root", &app.project_root.is_some()); + render(&app, "pages/modes.html", &c) +} + +#[derive(Deserialize)] +pub struct ModeRunForm { + pub mode_id: String, +} + +pub async fn mode_run( + State(app): State, + Form(form): Form, +) -> impl IntoResponse { + let Some(root) = app.project_root.as_ref() else { + return Redirect::to("/ui/modes").into_response(); + }; + let bin = app.provisioning_bin.as_deref().unwrap_or("provisioning"); + if let Err(e) = ctx::run_mode(root, &form.mode_id, bin).await { + tracing::warn!(mode = %form.mode_id, error = %e, "mode run failed"); + } + Redirect::to("/ui/modes").into_response() +} + +// ── Ontology graph (global) ─────────────────────────────────────────────────── + +pub async fn ontology( + State(app): State, + Extension(perms): Extension, +) -> Result, UiError> { + let graph_json = match app.project_root.as_ref() { + Some(root) => { + let core = root.join(".ontology").join("core.ncl"); + let import_path = app.nickel_import_path.as_deref(); + let v = ctx::ncl_export(&core, import_path).await; + serde_json::to_string(&v).unwrap_or_else(|_| "{}".to_string()) + } + None => r#"{"nodes":[],"edges":[]}"#.to_string(), + }; + let state_json = match app.project_root.as_ref() { + Some(root) => { + let state_file = root.join(".ontology").join("state.ncl"); + let import_path = app.nickel_import_path.as_deref(); + let v = ctx::ncl_export(&state_file, import_path).await; + serde_json::to_string(&v).unwrap_or_else(|_| "{}".to_string()) + } + None => r#"{"dimensions":[]}"#.to_string(), + }; + let mut c = base_ctx(&app); + perm_ctx(&perms, &mut c); + c.insert("graph_json", &graph_json); + c.insert("state_json", &state_json); + c.insert("has_root", &app.project_root.is_some()); + render(&app, "pages/ontology.html", &c) +} diff --git a/crates/provisioning-daemon/src/ui/mod.rs b/crates/provisioning-daemon/src/ui/mod.rs new file mode 100644 index 0000000..ea5297d --- /dev/null +++ b/crates/provisioning-daemon/src/ui/mod.rs @@ -0,0 +1,158 @@ +pub mod context; +pub mod error; +pub mod handlers; +pub mod session; + +use anyhow::{Context as _, Result}; +use axum::{ + extract::Request, + middleware::{Next, from_fn_with_state}, + response::{IntoResponse, Redirect, Response}, + routing::{get, post}, + Router, +}; +use rust_embed::RustEmbed; +use std::path::Path; +use tera::Tera; + +use crate::AppState; + +// ── Embedded assets ─────────────────────────────────────────────────────────── + +#[derive(RustEmbed)] +#[folder = "ui/templates/"] +struct UiAssets; + +#[derive(RustEmbed)] +#[folder = "ui/public/"] +struct PublicAssets; + +// ── UiRenderer ──────────────────────────────────────────────────────────────── + +pub struct UiRenderer { + tera: Tera, +} + +impl UiRenderer { + pub fn from_embedded() -> Result { + let mut tera = Tera::default(); + let templates: Vec<(String, String)> = UiAssets::iter() + .filter_map(|name| { + let file = UiAssets::get(&name)?; + let src = std::str::from_utf8(&file.data).ok()?.to_string(); + Some((name.into_owned(), src)) + }) + .collect(); + tera.add_raw_templates(templates.iter().map(|(n, s)| (n.as_str(), s.as_str()))) + .context("tera parse embedded ui templates")?; + Ok(Self { tera }) + } + + pub fn from_dir(dir: &Path) -> Result { + let pattern = format!("{}/**/*", dir.display()); + let tera = Tera::new(&pattern) + .with_context(|| format!("tera parse {}", dir.display()))?; + Ok(Self { tera }) + } + + pub fn render(&self, template: &str, ctx: &tera::Context) -> Result { + self.tera.render(template, ctx) + } +} + +// ── Static public asset handler ─────────────────────────────────────────────── + +async fn serve_public(axum::extract::Path(path): axum::extract::Path) -> Response { + match PublicAssets::get(&path) { + Some(file) => { + let mime = mime_guess::from_path(&path).first_or_octet_stream(); + ( + [(axum::http::header::CONTENT_TYPE, mime.as_ref())], + file.data.into_owned(), + ) + .into_response() + } + None => axum::http::StatusCode::NOT_FOUND.into_response(), + } +} + +// ── Session middleware ───────────────────────────────────────────────────────── + +async fn session_guard( + axum::extract::State(app): axum::extract::State, + mut req: Request, + next: Next, +) -> Response { + let session_id = extract_session_cookie(req.headers()); + let session_data = match session_id { + Some(id) => app.sessions.get(id).await, + None => None, + }; + match session_data { + Some(data) => { + let perms = session::UiPerms::from_session(&data); + req.extensions_mut().insert(perms); + next.run(req).await + } + None => Redirect::to("/ui/login").into_response(), + } +} + +pub fn extract_session_cookie(headers: &axum::http::HeaderMap) -> Option<&str> { + headers.get(axum::http::header::COOKIE)? + .to_str().ok()? + .split(';') + .find_map(|kv| { + let kv = kv.trim(); + kv.strip_prefix(session::COOKIE_NAME) + .and_then(|rest| rest.strip_prefix('=')) + }) +} + +// ── Router ──────────────────────────────────────────────────────────────────── + +/// Build the UI router. Requires `state` so the session middleware can be wired +/// with the real `AppState` (same pattern as `http::raw_router`). +pub fn ui_router(state: &AppState) -> Router { + // Public routes — no session required + let public = Router::new() + .route("/ui/login", get(handlers::login_page).post(handlers::login_post)) + .route("/ui/logout", get(handlers::logout)) + .route("/public/{*path}", get(serve_public)); + + // Protected routes — session required + let protected = Router::new() + .route("/ui", get(|| async { Redirect::permanent("/ui/") })) + .route("/ui/", get(handlers::dashboard)) + .route("/ui/tools", get(handlers::tools_list)) + .route("/ui/tools/{name}", get(handlers::tool_detail)) + .route("/ui/workspaces", get(handlers::workspaces)) + .route("/ui/workspaces/{name}", get(handlers::workspace_detail)) + .route("/ui/workspaces/{name}/servers", get(handlers::workspace_servers)) + .route("/ui/workspaces/{name}/components", get(handlers::workspace_components)) + .route("/ui/workspaces/{name}/dag", get(handlers::workspace_dag)) + .route("/ui/workspaces/{name}/clusters", get(handlers::workspace_clusters)) + .route("/ui/workspaces/{name}/workflows", get(handlers::workspace_workflows)) + .route("/ui/workspaces/{name}/servers/{srv}", get(handlers::workspace_server_detail)) + .route("/ui/workspaces/{name}/servers/{srv}/scale-down", post(handlers::server_scale_down)) + .route("/ui/workspaces/{name}/volumes/{vol_id}", get(handlers::workspace_volume_detail)) + .route("/ui/workspaces/{name}/components/{comp}", get(handlers::workspace_component_detail)) + .route("/ui/workspaces/{name}/components/{comp}/pods", get(handlers::workspace_component_pods)) + .route("/ui/workspaces/{name}/components/{comp}/pods/{ns}/{pod}/describe", get(handlers::workspace_component_describe_pod)) + .route("/ui/workspaces/{name}/modes", get(handlers::workspace_modes)) + .route("/ui/workspaces/{name}/backlog", get(handlers::workspace_backlog)) + .route("/ui/workspaces/{name}/backlog/add", post(handlers::workspace_backlog_add)) + .route("/ui/workspaces/{name}/ontology", get(handlers::workspace_ontology)) + .route("/ui/workspaces/{name}/actions", get(handlers::workspace_actions)) + .route("/ui/workspaces/{name}/notifications", get(handlers::workspace_notifications)) + .route("/ui/workspaces/{name}/config", get(handlers::workspace_config)) + .route("/ui/jobs", get(handlers::jobs)) + .route("/ui/jobs/{id}", get(handlers::job_detail)) + .route("/ui/extensions", get(handlers::extensions)) + .route("/ui/modes", get(handlers::modes)) + .route("/ui/modes/run", post(handlers::mode_run)) + .route("/ui/ontology", get(handlers::ontology)) + .layer(from_fn_with_state(state.clone(), session_guard)); + + public.merge(protected) +} diff --git a/crates/provisioning-daemon/src/ui/session.rs b/crates/provisioning-daemon/src/ui/session.rs new file mode 100644 index 0000000..d2de414 --- /dev/null +++ b/crates/provisioning-daemon/src/ui/session.rs @@ -0,0 +1,155 @@ +//! In-memory session store for the SSR UI, with disk persistence. +//! +//! Each session is a UUID cookie (`prov_session`) mapped to a `SessionData`. +//! Sessions are flushed to `~/.local/share/provisioning/daemon-sessions.json` +//! on every create/delete so they survive daemon restarts. + +use std::{ + collections::HashMap, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; +use uuid::Uuid; + +pub const COOKIE_NAME: &str = "prov_session"; +const SESSION_TTL_SECS: u64 = 7 * 24 * 3600; + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::ZERO) + .as_secs() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionData { + pub sub: String, + pub roles: Vec, + /// Unix epoch seconds at creation time. + created_at: u64, +} + +impl SessionData { + pub fn new(sub: impl Into, roles: Vec) -> Self { + Self { sub: sub.into(), roles, created_at: now_secs() } + } + + pub fn is_expired(&self) -> bool { + now_secs().saturating_sub(self.created_at) > SESSION_TTL_SECS + } +} + +/// Permission summary derived from a session's roles. +/// +/// In solo/dev mode every session gets `["admin"]`, giving full access. +/// In JWT mode roles come from the token claims (issuer / control-center). +/// When Cedar-backed authz is available, the control-center delegation path +/// will populate roles via policy evaluation — these fields remain stable. +#[derive(Debug, Clone)] +pub struct UiPerms { + pub subject: String, + pub roles: Vec, + /// Always true for any authenticated session. + pub can_view: bool, + /// `operator` or `admin` — may run non-destructive mutations. + pub can_operate: bool, + /// `admin` only — destructive ops, daemon control, config changes. + pub can_admin: bool, +} + +impl UiPerms { + pub fn from_session(session: &SessionData) -> Self { + let admin = session.roles.iter().any(|r| r == "admin"); + let operator = admin || session.roles.iter().any(|r| r == "operator"); + Self { + subject: session.sub.clone(), + roles: session.roles.clone(), + can_view: true, + can_operate: operator, + can_admin: admin, + } + } + + /// Human-readable label for the highest role this session holds. + pub fn role_label(&self) -> &'static str { + if self.can_admin { "admin" } + else if self.can_operate { "operator" } + else { "viewer" } + } +} + +#[derive(Clone)] +pub struct SessionStore { + inner: Arc>>, + file: Option>, +} + +impl Default for SessionStore { + fn default() -> Self { + Self { inner: Arc::new(RwLock::new(HashMap::new())), file: None } + } +} + +impl SessionStore { + /// Load from `file` if it exists; non-fatal on parse errors. + pub fn with_persistence(file: std::path::PathBuf) -> Self { + let sessions = load_from_disk(&file); + Self { + inner: Arc::new(RwLock::new(sessions)), + file: Some(Arc::new(file)), + } + } + + pub async fn create(&self, data: SessionData) -> String { + let id = Uuid::new_v4().to_string(); + self.inner.write().await.insert(id.clone(), data); + self.save().await; + id + } + + pub async fn get(&self, id: &str) -> Option { + let store = self.inner.read().await; + let data = store.get(id)?; + if data.is_expired() { return None; } + Some(data.clone()) + } + + pub async fn delete(&self, id: &str) { + self.inner.write().await.remove(id); + self.save().await; + } + + /// Prune expired sessions and flush to disk. + pub async fn prune(&self) { + self.inner.write().await.retain(|_, v| !v.is_expired()); + self.save().await; + } + + async fn save(&self) { + let Some(ref path) = self.file else { return }; + let snapshot: HashMap = self.inner.read().await.clone(); + let path = Arc::clone(path); + // Spawn blocking write so we don't block the async runtime. + tokio::task::spawn_blocking(move || { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + match serde_json::to_string(&snapshot) { + Ok(json) => { let _ = std::fs::write(&*path, json); } + Err(e) => tracing::warn!(error = %e, "failed to serialize sessions"), + } + }); + } +} + +fn load_from_disk(path: &std::path::Path) -> HashMap { + let Ok(bytes) = std::fs::read(path) else { return HashMap::new() }; + let Ok(map) = serde_json::from_slice::>(&bytes) else { + tracing::warn!(path = %path.display(), "session file corrupt — starting fresh"); + return HashMap::new(); + }; + // Drop expired sessions on load. + map.into_iter().filter(|(_, v)| !v.is_expired()).collect() +} diff --git a/crates/provisioning-daemon/src/watcher.rs b/crates/provisioning-daemon/src/watcher.rs new file mode 100644 index 0000000..7ff9830 --- /dev/null +++ b/crates/provisioning-daemon/src/watcher.rs @@ -0,0 +1,92 @@ +//! File-system watcher that forwards config-path change events to a channel. + +use crate::events::{ChangeKind, ConfigChangedEvent}; +use chrono::Utc; +use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use std::path::PathBuf; +use tokio::sync::mpsc; + +pub type WatchSender = mpsc::Sender; +pub type WatchReceiver = mpsc::Receiver; + +/// Create a watcher that sends `ConfigChangedEvent`s when any of `paths` change. +/// +/// Returns `(WatchReceiver, RecommendedWatcher)`. Caller must retain the +/// `RecommendedWatcher` for the watch to stay active — drop it to stop. +pub fn spawn_watcher(paths: Vec) -> anyhow::Result<(WatchReceiver, RecommendedWatcher)> { + let (tx, rx) = mpsc::channel::(64); + + let mut watcher = notify::recommended_watcher(move |res: notify::Result| { + let Ok(event) = res else { return }; + let kind = classify(event.kind); + for path in event.paths { + let ev = ConfigChangedEvent { path, change: kind, at: Utc::now() }; + // Non-blocking send — drop if channel is full rather than blocking notify callback. + let _ = tx.try_send(ev); + } + })?; + + for path in paths { + if path.exists() { + watcher.watch(&path, RecursiveMode::NonRecursive)?; + } + } + + Ok((rx, watcher)) +} + +fn classify(kind: EventKind) -> ChangeKind { + match kind { + EventKind::Create(_) => ChangeKind::Create, + EventKind::Remove(_) => ChangeKind::Remove, + _ => ChangeKind::Modify, + } +} + +/// Consume config-change events, emit NATS events and mark domain state. +/// +/// Runs until sender side is dropped (watcher dropped or channel closed). +pub async fn run_config_watch_loop( + mut rx: WatchReceiver, + state: crate::domain_state::DomainState, + bus: Option, +) { + while let Some(ev) = rx.recv().await { + tracing::info!(path = %ev.path.display(), change = ?ev.change, "config file changed"); + state.mark_config_reload(); + if let Some(ref b) = bus { + let nats_ev = crate::events::DaemonEvent::ConfigChanged(ev); + if let Err(e) = b.publish(&nats_ev).await { + tracing::warn!("failed to publish config-changed event: {e}"); + } + } + } + tracing::info!("config watcher loop exited"); +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[tokio::test] + async fn watcher_creates_receiver() { + let dir = TempDir::new().unwrap(); + let (mut rx, _watcher) = spawn_watcher(vec![dir.path().to_path_buf()]).unwrap(); + // Write a file into the watched directory + std::fs::write(dir.path().join("test.ncl"), b"{}").unwrap(); + // Give the OS watcher a moment to fire + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + // We may or may not receive depending on OS event timing — just verify + // the channel is still open (not disconnected). + let _ = rx.try_recv(); // ok if empty + } + + #[test] + fn classify_maps_correctly() { + use notify::event::{CreateKind, ModifyKind, RemoveKind}; + assert_eq!(classify(EventKind::Create(CreateKind::File)), ChangeKind::Create); + assert_eq!(classify(EventKind::Remove(RemoveKind::File)), ChangeKind::Remove); + assert_eq!(classify(EventKind::Modify(ModifyKind::Any)), ChangeKind::Modify); + } +} diff --git a/crates/provisioning-daemon/ui/public/css/provisioning.css b/crates/provisioning-daemon/ui/public/css/provisioning.css new file mode 100644 index 0000000..5d2ad24 --- /dev/null +++ b/crates/provisioning-daemon/ui/public/css/provisioning.css @@ -0,0 +1,3593 @@ +*,::before,::after{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}::backdrop{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;} +*,::before,::after{box-sizing:border-box} +html{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";line-height:1.5;-webkit-text-size-adjust:100%;tab-size:4} +body{margin:0;padding:0;line-height:inherit} +a{color:inherit;text-decoration:inherit} +img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle} +img,video{max-width:100%;height:auto} +h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit} +ol,ul{list-style:none;margin:0;padding:0} +button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:inherit;color:inherit;margin:0;padding:0} +button,select{text-transform:none} +:root { + color-scheme: light; + --pf: 259 94% 44%; + --sf: 314 100% 40%; + --af: 174 75% 39%; + --nf: 214 20% 14%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 259 94% 51%; + --pc: 259 96% 91%; + --s: 314 100% 47%; + --sc: 314 100% 91%; + --a: 174 75% 46%; + --ac: 174 75% 11%; + --n: 214 20% 21%; + --nc: 212 19% 87%; + --b1: 0 0% 100%; + --b2: 0 0% 95%; + --b3: 180 2% 90%; + --bc: 215 28% 17% +} +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --pf: 262 80% 43%; + --sf: 316 70% 43%; + --af: 175 70% 34%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 262 80% 50%; + --pc: 0 0% 100%; + --s: 316 70% 50%; + --sc: 0 0% 100%; + --a: 175 70% 41%; + --ac: 0 0% 100%; + --n: 213 18% 20%; + --nf: 212 17% 17%; + --nc: 220 13% 69%; + --b1: 212 18% 14%; + --b2: 213 18% 12%; + --b3: 213 18% 10%; + --bc: 220 13% 69% + } +} +[data-theme=light] { + color-scheme: light; + --pf: 259 94% 44%; + --sf: 314 100% 40%; + --af: 174 75% 39%; + --nf: 214 20% 14%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 259 94% 51%; + --pc: 259 96% 91%; + --s: 314 100% 47%; + --sc: 314 100% 91%; + --a: 174 75% 46%; + --ac: 174 75% 11%; + --n: 214 20% 21%; + --nc: 212 19% 87%; + --b1: 0 0% 100%; + --b2: 0 0% 95%; + --b3: 180 2% 90%; + --bc: 215 28% 17% +} +[data-theme=dark] { + color-scheme: dark; + --pf: 262 80% 43%; + --sf: 316 70% 43%; + --af: 175 70% 34%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 262 80% 50%; + --pc: 0 0% 100%; + --s: 316 70% 50%; + --sc: 0 0% 100%; + --a: 175 70% 41%; + --ac: 0 0% 100%; + --n: 213 18% 20%; + --nf: 212 17% 17%; + --nc: 220 13% 69%; + --b1: 212 18% 14%; + --b2: 213 18% 12%; + --b3: 213 18% 10%; + --bc: 220 13% 69% +} +[data-theme=cupcake] { + color-scheme: light; + --pf: 183 47% 52%; + --sf: 338 71% 71%; + --af: 39 84% 51%; + --nf: 280 46% 7%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --pc: 183 20% 13%; + --sc: 340 15% 16%; + --ac: 37 41% 13%; + --nc: 283 9% 81%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --p: 183 47% 59%; + --s: 338 71% 78%; + --a: 39 84% 58%; + --n: 280 46% 14%; + --b1: 24 33% 97%; + --b2: 27 22% 92%; + --b3: 23 14% 89%; + --bc: 280 46% 14%; + --rounded-btn: 1.9rem; + --tab-border: 2px; + --tab-radius: .5rem +} +[data-theme=bumblebee] { + color-scheme: light; + --pf: 50 94% 51%; + --sf: 41 74% 46%; + --af: 24 67% 52%; + --nf: 240 33% 7%; + --b2: 0 0% 93%; + --b3: 0 0% 86%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --bc: 146 0% 19%; + --ac: 23 34% 13%; + --nc: 247 7% 81%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 50 94% 58%; + --pc: 240 33% 14%; + --s: 41 74% 53%; + --sc: 240 33% 14%; + --a: 24 67% 59%; + --n: 240 33% 14%; + --b1: 0 0% 100% +} +[data-theme=emerald] { + color-scheme: light; + --pf: 141 50% 53%; + --sf: 219 96% 53%; + --af: 10 81% 49%; + --nf: 219 20% 18%; + --b2: 0 0% 93%; + --b3: 0 0% 86%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --btn-text-case: uppercase; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 141 50% 60%; + --pc: 151 28% 19%; + --s: 219 96% 60%; + --sc: 210 20% 98%; + --a: 10 81% 56%; + --ac: 210 20% 98%; + --n: 219 20% 25%; + --nc: 210 20% 98%; + --b1: 0 0% 100%; + --bc: 219 20% 25%; + --animation-btn: 0; + --animation-input: 0; + --btn-focus-scale: 1 +} +[data-theme=corporate] { + color-scheme: light; + --pf: 229 96% 57%; + --sf: 215 26% 52%; + --af: 154 49% 53%; + --nf: 233 27% 6%; + --b2: 0 0% 93%; + --b3: 0 0% 86%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --pc: 243 100% 94%; + --sc: 216 13% 13%; + --ac: 151 21% 13%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --btn-text-case: uppercase; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 229 96% 64%; + --s: 215 26% 59%; + --a: 154 49% 60%; + --n: 233 27% 13%; + --nc: 210 38% 95%; + --b1: 0 0% 100%; + --bc: 233 27% 13%; + --rounded-box: 0.25rem; + --rounded-btn: .125rem; + --rounded-badge: .125rem; + --animation-btn: 0; + --animation-input: 0; + --btn-focus-scale: 1 +} +[data-theme=synthwave] { + color-scheme: dark; + --pf: 321 70% 62%; + --sf: 197 87% 58%; + --af: 48 89% 50%; + --nf: 253 59% 13%; + --b2: 253 58% 8%; + --b3: 253 58% 1%; + --pc: 323 23% 15%; + --sc: 199 28% 14%; + --ac: 45 42% 13%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 321 70% 69%; + --s: 197 87% 65%; + --a: 48 89% 57%; + --n: 253 59% 20%; + --nc: 260 60% 98%; + --b1: 253 58% 15%; + --bc: 260 60% 98%; + --in: 199 87% 64%; + --inc: 257 63% 17%; + --su: 168 74% 68%; + --suc: 257 63% 17%; + --wa: 48 89% 57%; + --wac: 257 63% 17%; + --er: 352 74% 57%; + --erc: 260 60% 98% +} +[data-theme=retro] { + color-scheme: light; + --pf: 3 74% 69%; + --sf: 145 27% 65%; + --af: 24 67% 52%; + --nf: 340 7% 10%; + --inc: 239 85% 93%; + --suc: 126 38% 89%; + --wac: 29 59% 11%; + --erc: 11 100% 91%; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 3 74% 76%; + --pc: 345 5% 15%; + --s: 145 27% 72%; + --sc: 345 5% 15%; + --a: 24 67% 59%; + --ac: 345 5% 15%; + --n: 340 7% 17%; + --nc: 43 41% 88%; + --b1: 45 47% 80%; + --b2: 44 47% 73%; + --b3: 44 47% 68%; + --bc: 345 5% 15%; + --in: 221 83% 53%; + --su: 142 76% 36%; + --wa: 32 95% 44%; + --er: 0 72% 51%; + --rounded-box: 0.4rem; + --rounded-btn: 0.4rem; + --rounded-badge: 0.4rem +} +[data-theme=cyberpunk] { + color-scheme: light; + --pf: 345 100% 66%; + --sf: 195 80% 63%; + --af: 276 74% 64%; + --nf: 57 100% 6%; + --b2: 56 100% 43%; + --b3: 56 100% 36%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --bc: 53 46% 13%; + --pc: 348 27% 15%; + --sc: 196 23% 15%; + --ac: 277 22% 15%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + font-family: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace; + --p: 345 100% 73%; + --s: 195 80% 70%; + --a: 276 74% 71%; + --n: 57 100% 13%; + --nc: 56 100% 50%; + --b1: 56 100% 50%; + --rounded-box: 0; + --rounded-btn: 0; + --rounded-badge: 0; + --tab-radius: 0 +} +[data-theme=valentine] { + color-scheme: light; + --pf: 353 74% 60%; + --sf: 254 86% 70%; + --af: 181 56% 63%; + --nf: 336 43% 41%; + --b2: 318 46% 82%; + --b3: 318 46% 75%; + --pc: 356 26% 14%; + --sc: 256 20% 15%; + --ac: 181 16% 15%; + --inc: 239 85% 93%; + --suc: 126 38% 89%; + --wac: 29 59% 11%; + --erc: 11 100% 91%; + --rounded-box: 1rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 353 74% 67%; + --s: 254 86% 77%; + --a: 181 56% 70%; + --n: 336 43% 48%; + --nc: 318 46% 89%; + --b1: 318 46% 89%; + --bc: 344 38% 28%; + --in: 221 83% 53%; + --su: 142 76% 36%; + --wa: 32 95% 44%; + --er: 0 72% 51%; + --rounded-btn: 1.9rem +} +[data-theme=halloween] { + color-scheme: dark; + --pf: 32 89% 45%; + --sf: 271 46% 35%; + --af: 91 100% 26%; + --nf: 31 81% 3%; + --b2: 0 0% 6%; + --b3: 0 0% 0%; + --bc: 145 0% 81%; + --sc: 275 36% 88%; + --nc: 26 11% 80%; + --inc: 239 85% 93%; + --suc: 126 38% 89%; + --wac: 29 59% 11%; + --erc: 11 100% 91%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 32 89% 52%; + --pc: 180 7% 8%; + --s: 271 46% 42%; + --a: 91 100% 33%; + --ac: 0 0% 0%; + --n: 31 81% 10%; + --b1: 0 0% 13%; + --in: 221 83% 53%; + --su: 142 76% 36%; + --wa: 32 95% 44%; + --er: 0 72% 51% +} +[data-theme=garden] { + color-scheme: light; + --pf: 331 100% 41%; + --sf: 334 37% 34%; + --af: 139 16% 36%; + --nf: 44 100% 1%; + --b2: 0 4% 84%; + --b3: 0 4% 77%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --pc: 346 100% 93%; + --sc: 340 30% 88%; + --ac: 136 12% 88%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 331 100% 48%; + --s: 334 37% 41%; + --a: 139 16% 43%; + --n: 44 100% 8%; + --nc: 0 4% 91%; + --b1: 0 4% 91%; + --bc: 0 3% 6% +} +[data-theme=forest] { + color-scheme: dark; + --pf: 141 72% 35%; + --sf: 164 73% 35%; + --af: 175 73% 35%; + --nf: 161 37% 8%; + --b2: 0 12% 1%; + --b3: 0 0% 0%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --bc: 360 1% 79%; + --sc: 158 32% 11%; + --ac: 172 31% 11%; + --nc: 157 7% 81%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 141 72% 42%; + --pc: 0 0% 0%; + --s: 164 73% 42%; + --a: 175 73% 42%; + --n: 161 37% 15%; + --b1: 0 12% 8%; + --rounded-btn: 1.9rem +} +[data-theme=aqua] { + color-scheme: dark; + --pf: 182 93% 42%; + --sf: 274 31% 50%; + --af: 47 100% 73%; + --nf: 205 54% 43%; + --b2: 219 53% 36%; + --b3: 219 53% 29%; + --bc: 228 38% 89%; + --sc: 276 17% 12%; + --ac: 46 19% 16%; + --nc: 212 51% 91%; + --inc: 239 85% 93%; + --suc: 126 38% 89%; + --wac: 29 59% 11%; + --erc: 11 100% 91%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 182 93% 49%; + --pc: 181 100% 17%; + --s: 274 31% 57%; + --a: 47 100% 80%; + --n: 205 54% 50%; + --b1: 219 53% 43%; + --in: 221 83% 53%; + --su: 142 76% 36%; + --wa: 32 95% 44%; + --er: 0 72% 51% +} +[data-theme=lofi] { + color-scheme: light; + --pf: 0 0% 0%; + --sf: 0 2% 3%; + --af: 0 0% 8%; + --nf: 0 0% 0%; + --btn-text-case: uppercase; + --border-btn: 1px; + --tab-border: 1px; + --p: 0 0% 5%; + --pc: 0 0% 100%; + --s: 0 2% 10%; + --sc: 0 0% 100%; + --a: 0 0% 15%; + --ac: 0 0% 100%; + --n: 0 0% 0%; + --nc: 0 0% 100%; + --b1: 0 0% 100%; + --b2: 0 0% 95%; + --b3: 0 2% 90%; + --bc: 0 0% 0%; + --in: 212 100% 48%; + --inc: 0 0% 100%; + --su: 137 72% 46%; + --suc: 0 0% 0%; + --wa: 5 100% 66%; + --wac: 0 0% 100%; + --er: 325 78% 49%; + --erc: 0 0% 100%; + --rounded-box: 0.25rem; + --rounded-btn: 0.125rem; + --rounded-badge: 0.125rem; + --animation-btn: 0; + --animation-input: 0; + --btn-focus-scale: 1; + --tab-radius: 0 +} +[data-theme=pastel] { + color-scheme: light; + --pf: 284 22% 73%; + --sf: 352 70% 81%; + --af: 158 55% 74%; + --nf: 199 44% 54%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --bc: 146 0% 19%; + --pc: 284 4% 16%; + --sc: 352 7% 17%; + --ac: 158 10% 16%; + --nc: 200 19% 13%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 284 22% 80%; + --s: 352 70% 88%; + --a: 158 55% 81%; + --n: 199 44% 61%; + --b1: 0 0% 100%; + --b2: 210 20% 98%; + --b3: 216 12% 84%; + --rounded-btn: 1.9rem +} +[data-theme=fantasy] { + color-scheme: light; + --pf: 296 83% 18%; + --sf: 200 100% 30%; + --af: 31 94% 44%; + --nf: 215 28% 10%; + --b2: 0 0% 93%; + --b3: 0 0% 86%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --pc: 302 27% 85%; + --sc: 212 51% 90%; + --ac: 28 57% 12%; + --nc: 218 6% 82%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 296 83% 25%; + --s: 200 100% 37%; + --a: 31 94% 51%; + --n: 215 28% 17%; + --b1: 0 0% 100%; + --bc: 215 28% 17% +} +[data-theme=wireframe] { + color-scheme: light; + --pf: 0 0% 65%; + --sf: 0 0% 65%; + --af: 0 0% 65%; + --nf: 0 0% 85%; + --bc: 146 0% 19%; + --pc: 145 0% 15%; + --sc: 145 0% 15%; + --ac: 145 0% 15%; + --nc: 145 0% 18%; + --inc: 263 100% 91%; + --suc: 105 32% 85%; + --wac: 58 21% 11%; + --erc: 17 100% 90%; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + font-family: Chalkboard,comic sans ms,"sanssecondaryerif"; + --p: 0 0% 72%; + --s: 0 0% 72%; + --a: 0 0% 72%; + --n: 0 0% 92%; + --b1: 0 0% 100%; + --b2: 0 0% 93%; + --b3: 0 0% 87%; + --in: 240 100% 50%; + --su: 120 100% 25%; + --wa: 60 30% 50%; + --er: 0 100% 50%; + --rounded-box: 0.2rem; + --rounded-btn: 0.2rem; + --rounded-badge: 0.2rem; + --tab-radius: 0.2rem +} +[data-theme=black] { + color-scheme: dark; + --pf: 0 2% 13%; + --sf: 0 2% 13%; + --af: 0 2% 13%; + --bc: 145 0% 78%; + --pc: 0 1% 82%; + --sc: 0 1% 82%; + --ac: 0 1% 82%; + --nc: 0 0% 81%; + --inc: 263 100% 91%; + --suc: 105 32% 85%; + --wac: 58 45% 13%; + --erc: 17 100% 90%; + --border-btn: 1px; + --tab-border: 1px; + --p: 0 2% 20%; + --s: 0 2% 20%; + --a: 0 2% 20%; + --b1: 0 0% 0%; + --b2: 0 0% 5%; + --b3: 0 2% 10%; + --n: 0 1% 15%; + --nf: 0 2% 20%; + --in: 240 100% 50%; + --su: 120 100% 25%; + --wa: 60 100% 50%; + --er: 0 100% 50%; + --rounded-box: 0; + --rounded-btn: 0; + --rounded-badge: 0; + --animation-btn: 0; + --animation-input: 0; + --btn-text-case: lowercase; + --btn-focus-scale: 1; + --tab-radius: 0 +} +[data-theme=luxury] { + color-scheme: dark; + --pf: 0 0% 93%; + --sf: 218 54% 11%; + --af: 319 22% 19%; + --nf: 28 100% 3%; + --pc: 146 0% 19%; + --sc: 227 12% 82%; + --ac: 322 9% 84%; + --inc: 205 27% 15%; + --suc: 88 35% 12%; + --wac: 52 28% 14%; + --erc: 3 31% 15%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 0 0% 100%; + --s: 218 54% 18%; + --a: 319 22% 26%; + --n: 28 100% 10%; + --nc: 44 100% 82%; + --b1: 240 10% 4%; + --b2: 270 4% 9%; + --b3: 270 2% 18%; + --bc: 37 67% 58%; + --in: 202 100% 70%; + --su: 89 62% 52%; + --wa: 54 69% 64%; + --er: 0 100% 72% +} +[data-theme=dracula] { + color-scheme: dark; + --pf: 326 100% 67%; + --sf: 265 89% 71%; + --af: 31 100% 64%; + --nf: 230 15% 23%; + --b2: 231 15% 11%; + --b3: 231 15% 4%; + --pc: 328 26% 15%; + --sc: 266 19% 16%; + --ac: 30 30% 15%; + --nc: 232 7% 85%; + --inc: 191 20% 16%; + --suc: 128 30% 14%; + --wac: 64 20% 15%; + --erc: 5 39% 14%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 326 100% 74%; + --s: 265 89% 78%; + --a: 31 100% 71%; + --n: 230 15% 30%; + --b1: 231 15% 18%; + --bc: 60 30% 96%; + --in: 191 97% 77%; + --su: 135 94% 65%; + --wa: 65 92% 76%; + --er: 0 100% 67% +} +[data-theme=cmyk] { + color-scheme: light; + --pf: 203 83% 53%; + --sf: 335 78% 53%; + --af: 56 100% 53%; + --nf: 0 0% 3%; + --b2: 0 0% 93%; + --b3: 0 0% 86%; + --bc: 146 0% 19%; + --pc: 207 32% 14%; + --sc: 344 100% 93%; + --ac: 54 41% 14%; + --nc: 145 0% 80%; + --inc: 194 26% 12%; + --suc: 295 30% 87%; + --wac: 24 46% 13%; + --erc: 12 100% 91%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 203 83% 60%; + --s: 335 78% 60%; + --a: 56 100% 60%; + --n: 0 0% 10%; + --b1: 0 0% 100%; + --in: 192 48% 52%; + --su: 291 48% 38%; + --wa: 25 85% 57%; + --er: 4 81% 56% +} +[data-theme=autumn] { + color-scheme: light; + --pf: 344 96% 21%; + --sf: 0 63% 51%; + --af: 27 56% 56%; + --nf: 22 17% 37%; + --b2: 0 0% 88%; + --b3: 0 0% 81%; + --bc: 145 0% 18%; + --pc: 2 46% 87%; + --sc: 6 87% 92%; + --ac: 27 25% 13%; + --nc: 21 15% 88%; + --inc: 188 26% 12%; + --suc: 161 25% 89%; + --wac: 28 55% 12%; + --erc: 8 100% 91%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 344 96% 28%; + --s: 0 63% 58%; + --a: 27 56% 63%; + --n: 22 17% 44%; + --b1: 0 0% 95%; + --in: 187 48% 50%; + --su: 165 34% 43%; + --wa: 30 84% 50%; + --er: 354 79% 49% +} +[data-theme=business] { + color-scheme: dark; + --pf: 210 64% 24%; + --sf: 200 13% 48%; + --af: 13 80% 53%; + --nf: 213 14% 9%; + --b2: 0 0% 6%; + --b3: 0 0% 0%; + --bc: 145 0% 80%; + --pc: 219 26% 86%; + --sc: 200 7% 12%; + --ac: 14 40% 13%; + --nc: 214 3% 81%; + --inc: 210 64% 91%; + --suc: 141 16% 12%; + --wac: 37 30% 13%; + --erc: 11 59% 89%; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 210 64% 31%; + --s: 200 13% 55%; + --a: 13 80% 60%; + --n: 213 14% 16%; + --b1: 0 0% 13%; + --in: 199 100% 42%; + --su: 144 31% 56%; + --wa: 39 64% 60%; + --er: 6 56% 43%; + --rounded-box: 0.25rem; + --rounded-btn: .125rem; + --rounded-badge: .125rem +} +[data-theme=acid] { + color-scheme: light; + --pf: 303 100% 43%; + --sf: 27 100% 43%; + --af: 72 98% 43%; + --nf: 238 43% 10%; + --b2: 0 0% 91%; + --b3: 0 0% 84%; + --bc: 145 0% 19%; + --pc: 302 100% 93%; + --sc: 25 62% 12%; + --ac: 73 44% 13%; + --nc: 248 11% 82%; + --inc: 217 36% 14%; + --suc: 145 23% 13%; + --wac: 50 42% 13%; + --erc: 15 100% 90%; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 303 100% 50%; + --s: 27 100% 50%; + --a: 72 98% 50%; + --n: 238 43% 17%; + --b1: 0 0% 98%; + --in: 210 92% 58%; + --su: 149 50% 58%; + --wa: 53 93% 57%; + --er: 1 100% 45%; + --rounded-box: 1.25rem; + --rounded-btn: 1rem; + --rounded-badge: 1rem +} +[data-theme=lemonade] { + color-scheme: light; + --pf: 89 96% 24%; + --sf: 60 81% 48%; + --af: 63 80% 81%; + --nf: 238 43% 10%; + --b2: 0 0% 93%; + --b3: 0 0% 86%; + --bc: 146 0% 19%; + --pc: 89 39% 87%; + --sc: 58 39% 13%; + --ac: 62 8% 17%; + --nc: 248 11% 82%; + --inc: 192 5% 17%; + --suc: 74 15% 16%; + --wac: 49 21% 15%; + --erc: 2 11% 16%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 89 96% 31%; + --s: 60 81% 55%; + --a: 63 80% 88%; + --n: 238 43% 17%; + --b1: 0 0% 100%; + --in: 192 39% 85%; + --su: 74 76% 79%; + --wa: 50 87% 75%; + --er: 1 70% 83% +} +[data-theme=night] { + color-scheme: dark; + --pf: 198 93% 53%; + --sf: 234 89% 67%; + --af: 329 86% 63%; + --b2: 222 47% 4%; + --b3: 0 0% 0%; + --bc: 229 7% 80%; + --pc: 202 34% 14%; + --sc: 239 22% 15%; + --ac: 332 26% 15%; + --nc: 221 7% 82%; + --suc: 169 31% 13%; + --wac: 39 36% 14%; + --erc: 354 28% 15%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 198 93% 60%; + --s: 234 89% 74%; + --a: 329 86% 70%; + --n: 217 33% 17%; + --nf: 217 30% 22%; + --b1: 222 47% 11%; + --in: 198 90% 48%; + --inc: 0 0% 0%; + --su: 172 66% 50%; + --wa: 41 88% 64%; + --er: 351 95% 71% +} +[data-theme=coffee] { + color-scheme: dark; + --pf: 30 67% 51%; + --sf: 182 25% 13%; + --af: 194 74% 18%; + --nf: 0 0% 0%; + --b2: 306 19% 4%; + --b3: 0 0% 0%; + --pc: 28 35% 13%; + --sc: 182 6% 83%; + --ac: 199 20% 85%; + --nc: 300 1% 79%; + --inc: 170 12% 14%; + --suc: 92 11% 13%; + --wac: 41 33% 14%; + --erc: 11 25% 15%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 30 67% 58%; + --s: 182 25% 20%; + --a: 194 74% 25%; + --n: 300 20% 6%; + --b1: 306 19% 11%; + --bc: 37 8% 42%; + --in: 171 37% 67%; + --su: 93 25% 62%; + --wa: 43 100% 69%; + --er: 10 95% 75% +} +[data-theme=winter] { + color-scheme: light; + --pf: 212 100% 44%; + --sf: 247 47% 36%; + --af: 310 49% 45%; + --nf: 217 92% 3%; + --pc: 231 100% 93%; + --sc: 256 40% 88%; + --ac: 316 56% 91%; + --nc: 229 10% 80%; + --inc: 192 18% 16%; + --suc: 181 16% 14%; + --wac: 32 9% 16%; + --erc: 2 19% 15%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 212 100% 51%; + --s: 247 47% 43%; + --a: 310 49% 52%; + --n: 217 92% 10%; + --b1: 0 0% 100%; + --b2: 217 100% 97%; + --b3: 219 44% 92%; + --bc: 214 30% 32%; + --in: 192 93% 78%; + --su: 182 47% 66%; + --wa: 32 62% 84%; + --er: 0 63% 72% +} + +.btn{text-transform:none!important;letter-spacing:normal!important} +.container{width:100%;}@media (min-width: 640px){.container{max-width:640px;}}@media (min-width: 768px){.container{max-width:768px;}}@media (min-width: 1024px){.container{max-width:1024px;}}@media (min-width: 1280px){.container{max-width:1280px;}}@media (min-width: 1536px){.container{max-width:1536px;}}:root, +[data-theme] { + background-color: hsl(var(--b1) / var(--un-bg-opacity, 1)); + color: hsl(var(--bc) / var(--un-text-opacity, 1)) +} +html { + -webkit-tap-highlight-color: transparent +}.alert { + display: grid; + width: 100%; + grid-auto-flow: row; + align-content: flex-start; + align-items: center; + justify-items: center; + gap: 1rem; + text-align: center; + border-width: 1px; + --un-border-opacity: 1; + border-color: hsl(var(--b2) / var(--un-border-opacity)); + padding: 1rem; + --un-text-opacity: 1; + color: hsl(var(--bc) / var(--un-text-opacity)); + border-radius: var(--rounded-box, 1rem); + --alert-bg: hsl(var(--b2)); + --alert-bg-mix: hsl(var(--b1)); + background-color: var(--alert-bg) +} +.alert { + grid-auto-flow: column; + grid-template-columns: auto minmax(auto,1fr); + justify-items: start; + text-align: left + } +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + transition-duration: 200ms; + height: 1.25rem; + font-size: 0.875rem; + line-height: 1.25rem; + width: -moz-fit-content; + width: fit-content; + padding-left: 0.563rem; + padding-right: 0.563rem; + border-width: 1px; + --un-border-opacity: 1; + border-color: hsl(var(--b2) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--b1) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--bc) / var(--un-text-opacity)); + border-radius: var(--rounded-badge, 1.9rem) +} +.link-hover:hover { + text-decoration-line: underline + } +.link-primary:hover { + --un-text-opacity: 1; + color: hsl(var(--pf) / var(--un-text-opacity)) + } +.link-secondary:hover { + --un-text-opacity: 1; + color: hsl(var(--sf) / var(--un-text-opacity)) + } +.link-accent:hover { + --un-text-opacity: 1; + color: hsl(var(--af) / var(--un-text-opacity)) + } +.link-neutral:hover { + --un-text-opacity: 1; + color: hsl(var(--nf) / var(--un-text-opacity)) + } +.link-success:hover { + --un-text-opacity: 1; + color: hsl(var(--su) / var(--un-text-opacity)) + } +.link-info:hover { + --un-text-opacity: 1; + color: hsl(var(--in) / var(--un-text-opacity)) + } +.link-warning:hover { + --un-text-opacity: 1; + color: hsl(var(--wa) / var(--un-text-opacity)) + } +.link-error:hover { + --un-text-opacity: 1; + color: hsl(var(--er) / var(--un-text-opacity)) + } +.link { + cursor: pointer; + text-decoration-line: underline +} +.link-hover { + text-decoration-line: none +} +.link-primary { + --un-text-opacity: 1; + color: hsl(var(--p) / var(--un-text-opacity)) +} +.link-secondary { + --un-text-opacity: 1; + color: hsl(var(--s) / var(--un-text-opacity)) +} +.link-accent { + --un-text-opacity: 1; + color: hsl(var(--a) / var(--un-text-opacity)) +} +.link-neutral { + --un-text-opacity: 1; + color: hsl(var(--n) / var(--un-text-opacity)) +} +.link-success { + --un-text-opacity: 1; + color: hsl(var(--su) / var(--un-text-opacity)) +} +.link-info { + --un-text-opacity: 1; + color: hsl(var(--in) / var(--un-text-opacity)) +} +.link-warning { + --un-text-opacity: 1; + color: hsl(var(--wa) / var(--un-text-opacity)) +} +.link-error { + --un-text-opacity: 1; + color: hsl(var(--er) / var(--un-text-opacity)) +} +.link:focus { + outline: 2px solid transparent; + outline-offset: 2px +} +.link:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px +} +.label a:hover { + --un-text-opacity: 1; + color: hsl(var(--bc) / var(--un-text-opacity)) + } +.label { + display: flex; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + align-items: center; + justify-content: space-between; + padding-left: 0.25rem; + padding-right: 0.25rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem +} +.menu li > *:not(ul):not(.menu-title):not(details):active, +.menu li > *:not(ul):not(.menu-title):not(details).active, +.menu li > details > summary:active { + --un-bg-opacity: 1; + background-color: hsl(var(--n) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--nc) / var(--un-text-opacity)) + } +:where(.menu li:not(.menu-title):not(.disabled) > *:not(ul):not(details):not(.menu-title)):not(.active):hover, :where(.menu li:not(.menu-title):not(.disabled) > details > summary:not(.menu-title)):not(.active):hover { + cursor: pointer; + background-color: hsl(var(--bc) / 0.1); + --un-text-opacity: 1; + color: hsl(var(--bc) / var(--un-text-opacity)); + outline: 2px solid transparent; + outline-offset: 2px + } +.menu { + display: flex; + flex-direction: column; + flex-wrap: wrap; + font-size: 0.875rem; + line-height: 1.25rem; + padding: 0.5rem +} +.menu :where(li ul) { + position: relative; + white-space: nowrap; + margin-left: 1rem; + padding-left: 0.5rem +} +.menu :where(li:not(.menu-title) > *:not(ul):not(details):not(.menu-title)), + .menu :where(li:not(.menu-title) > details > summary:not(.menu-title)) { + display: grid; + grid-auto-flow: column; + align-content: flex-start; + align-items: center; + gap: 0.5rem; + grid-auto-columns: minmax(auto, max-content) auto max-content; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none +} +.menu li.disabled { + cursor: not-allowed; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + color: hsl(var(--bc) / 0.3) +} +.menu :where(li > .menu-dropdown:not(.menu-dropdown-show)) { + display: none +} +:where(.menu li) { + position: relative; + display: flex; + flex-shrink: 0; + flex-direction: column; + flex-wrap: wrap; + align-items: stretch +} +:where(.menu li) .badge { + justify-self: end +} +:where(.menu li:empty) { + background-color: hsl(var(--bc) / 0.1); + margin: 0.5rem 1rem; + height: 1px +} +.menu :where(li ul):before { + position: absolute; + bottom: 0.75rem; + left: 0px; + top: 0.75rem; + width: 1px; + background-color: hsl(var(--bc) / 0.1); + content: "" +} +.menu :where(li:not(.menu-title) > *:not(ul):not(details):not(.menu-title)), +.menu :where(li:not(.menu-title) > details > summary:not(.menu-title)) { + padding-left: 1rem; + padding-right: 1rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + text-align: left; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + transition-duration: 200ms; + border-radius: var(--rounded-btn, 0.5rem); + text-wrap: balance +} +:where(.menu li:not(.menu-title):not(.disabled) > *:not(ul):not(details):not(.menu-title)):not(summary):not(.active).focus, + :where(.menu li:not(.menu-title):not(.disabled) > *:not(ul):not(details):not(.menu-title)):not(summary):not(.active):focus, + :where(.menu li:not(.menu-title):not(.disabled) > *:not(ul):not(details):not(.menu-title)):is(summary):not(.active):focus-visible, + :where(.menu li:not(.menu-title):not(.disabled) > details > summary:not(.menu-title)):not(summary):not(.active).focus, + :where(.menu li:not(.menu-title):not(.disabled) > details > summary:not(.menu-title)):not(summary):not(.active):focus, + :where(.menu li:not(.menu-title):not(.disabled) > details > summary:not(.menu-title)):is(summary):not(.active):focus-visible { + cursor: pointer; + background-color: hsl(var(--bc) / 0.1); + --un-text-opacity: 1; + color: hsl(var(--bc) / var(--un-text-opacity)); + outline: 2px solid transparent; + outline-offset: 2px +} +.menu li > *:not(ul):not(.menu-title):not(details):active, +.menu li > *:not(ul):not(.menu-title):not(details).active, +.menu li > details > summary:active { + --un-bg-opacity: 1; + background-color: hsl(var(--n) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--nc) / var(--un-text-opacity)) +} +.menu :where(li > details > summary)::-webkit-details-marker { + display: none +} +.menu :where(li > details > summary):after, +.menu :where(li > .menu-dropdown-toggle):after { + justify-self: end; + display: block; + margin-top: -0.5rem; + height: 0.5rem; + width: 0.5rem; + transform: rotate(45deg); + transition-property: transform, margin-top; + transition-duration: 0.3s; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + content: ""; + transform-origin: 75% 75%; + box-shadow: 2px 2px; + pointer-events: none +} +.menu :where(li > details[open] > summary):after, +.menu :where(li > .menu-dropdown-toggle.menu-dropdown-show):after { + transform: rotate(225deg); + margin-top: 0 +} +.tab:hover { + --un-text-opacity: 1 + } +.tab[disabled], + .tab[disabled]:hover { + cursor: not-allowed; + color: hsl(var(--bc) / var(--un-text-opacity)); + --un-text-opacity: 0.2 + } +.tab { + position: relative; + display: inline-flex; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + flex-wrap: wrap; + align-items: center; + justify-content: center; + text-align: center; + height: 2rem; + font-size: 0.875rem; + line-height: 1.25rem; + line-height: 2; + --tab-padding: 1rem; + --un-text-opacity: 0.5; + --tab-color: hsl(var(--bc) / var(--un-text-opacity, 1)); + --tab-bg: hsl(var(--b1) / var(--un-bg-opacity, 1)); + --tab-border-color: hsl(var(--b3) / var(--un-bg-opacity, 1)); + color: var(--tab-color); + padding-left: var(--tab-padding, 1rem); + padding-right: var(--tab-padding, 1rem) +} +.tab.tab-active:not(.tab-disabled):not([disabled]) { + border-color: hsl(var(--bc) / var(--un-border-opacity)); + --un-border-opacity: 1; + --un-text-opacity: 1 +} +.tab:focus { + outline: 2px solid transparent; + outline-offset: 2px +} +.tab:focus-visible { + outline: 2px solid currentColor; + outline-offset: -3px +} +.tab:focus-visible.tab-lifted { + border-bottom-right-radius: var(--tab-radius, 0.5rem); + border-bottom-left-radius: var(--tab-radius, 0.5rem) +} +.table-zebra tr.hover:hover, + .table-zebra tr.hover:nth-child(even):hover { + --un-bg-opacity: 1; + background-color: hsl(var(--b3) / var(--un-bg-opacity)) + } +.table-zebra tbody tr:nth-child(even) :where(.table-pin-cols tr th) { + --un-bg-opacity: 1; + background-color: hsl(var(--b2) / var(--un-bg-opacity)) +} +.table-zebra tr.active, + .table-zebra tr.active:nth-child(even), + .table-zebra-zebra tbody tr:nth-child(even) { + --un-bg-opacity: 1; + background-color: hsl(var(--b3) / var(--un-bg-opacity)) +} +.btn { + display: inline-flex; + flex-shrink: 0; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + flex-wrap: wrap; + align-items: center; + justify-content: center; + border-color: transparent; + border-color: hsl(var(--b2) / var(--un-border-opacity)); + text-align: center; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + transition-duration: 200ms; + border-radius: var(--rounded-btn, 0.5rem); + height: 3rem; + padding-left: 1rem; + padding-right: 1rem; + min-height: 3rem; + font-size: 0.875rem; + line-height: 1em; + gap: 0.5rem; + font-weight: 600; + text-decoration-line: none; + text-decoration-line: none; + border-width: var(--border-btn, 1px); + animation: button-pop var(--animation-btn, 0.25s) ease-out; + text-transform: var(--btn-text-case, uppercase); + --un-border-opacity: 1; + --un-bg-opacity: 1; + background-color: hsl(var(--b2) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--bc) / var(--un-text-opacity)); + outline-color: hsl(var(--bc) / 1) +} +.btn:is(input[type="checkbox"]), +.btn:is(input[type="radio"]) { + width: auto; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none +} +.btn:is(input[type="checkbox"]):after, +.btn:is(input[type="radio"]):after { + --un-content: attr(aria-label); + content: var(--un-content) +} +.btn:hover { + --un-border-opacity: 1; + border-color: hsl(var(--b3) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--b3) / var(--un-bg-opacity)) + } +.btn.glass:hover { + --glass-opacity: 25%; + --glass-border-opacity: 15% + } +.btn:is(input[type="checkbox"]:checked):hover, .btn:is(input[type="radio"]:checked):hover { + --un-border-opacity: 1; + border-color: hsl(var(--pf) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--pf) / var(--un-bg-opacity)) + } +.btn:active:hover, + .btn:active:focus { + animation: button-pop 0s ease-out; + transform: scale(var(--btn-focus-scale, 0.97)) +} +.btn:focus-visible { + outline-style: solid; + outline-width: 2px; + outline-offset: 2px +} +.btn.glass { + --un-shadow: 0 0 #0000; + --un-shadow-colored: 0 0 #0000; + box-shadow: var(--un-ring-offset-shadow, 0 0 #0000), var(--un-ring-shadow, 0 0 #0000), var(--un-shadow); + outline-color: currentColor +} +.btn.glass.btn-active { + --glass-opacity: 25%; + --glass-border-opacity: 15% +} +.btn.btn-disabled, + .btn[disabled], + .btn:disabled { + --un-border-opacity: 0; + background-color: hsl(var(--n) / var(--un-bg-opacity)); + --un-bg-opacity: 0.2; + color: hsl(var(--bc) / var(--un-text-opacity)); + --un-text-opacity: 0.2 +} +.btn:is(input[type="checkbox"]:checked), +.btn:is(input[type="radio"]:checked) { + --un-border-opacity: 1; + border-color: hsl(var(--p) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--p) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--pc) / var(--un-text-opacity)) +} +.btn:is(input[type="checkbox"]:checked):focus-visible, .btn:is(input[type="radio"]:checked):focus-visible { + outline-color: hsl(var(--p) / 1) +} +.btn-circle { + height: 3rem; + width: 3rem; + border-radius: 9999px; + padding: 0px +} +.btn-circle:where(.btn-xs) { + height: 1.5rem; + width: 1.5rem; + border-radius: 9999px; + padding: 0px +} +.btn-circle:where(.btn-sm) { + height: 2rem; + width: 2rem; + border-radius: 9999px; + padding: 0px +} +.btn-circle:where(.btn-md) { + height: 3rem; + width: 3rem; + border-radius: 9999px; + padding: 0px +} +.btn-circle:where(.btn-lg) { + height: 4rem; + width: 4rem; + border-radius: 9999px; + padding: 0px +} +.card { + position: relative; + display: flex; + flex-direction: column; + border-radius: var(--rounded-box, 1rem) +} +.card:focus { + outline: 2px solid transparent; + outline-offset: 2px +} +.card figure { + display: flex; + align-items: center; + justify-content: center +} +.card.image-full { + display: grid +} +.card.image-full:before { + position: relative; + content: ""; + z-index: 10; + --un-bg-opacity: 1; + background-color: hsl(var(--n) / var(--un-bg-opacity)); + opacity: 0.75; + border-radius: var(--rounded-box, 1rem) +} +.card.image-full:before, + .card.image-full > * { + grid-column-start: 1; + grid-row-start: 1 +} +.card.image-full > figure img { + height: 100%; + -o-object-fit: cover; + object-fit: cover +} +.card.image-full > .card-body { + position: relative; + z-index: 20; + --un-text-opacity: 1; + color: hsl(var(--nc) / var(--un-text-opacity)) +} +.card :where(figure:first-child) { + overflow: hidden; + border-start-start-radius: inherit; + border-start-end-radius: inherit; + border-end-start-radius: unset; + border-end-end-radius: unset +} +.card :where(figure:last-child) { + overflow: hidden; + border-start-start-radius: unset; + border-start-end-radius: unset; + border-end-start-radius: inherit; + border-end-end-radius: inherit +} +.card:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px +} +.card.bordered { + border-width: 1px; + --un-border-opacity: 1; + border-color: hsl(var(--b2) / var(--un-border-opacity)) +} +.card.compact .card-body { + padding: 1rem; + font-size: 0.875rem; + line-height: 1.25rem +} +.card.image-full :where(figure) { + overflow: hidden; + border-radius: inherit +} +.card-body { + display: flex; + display: flex; + flex: 1 1 auto; + flex-direction: column; + flex-direction: column; + padding: var(--padding-card, 2rem); + gap: 0.5rem +} +.card-body :where(p) { + flex-grow: 1 +} +.card-actions { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 0.5rem +} +.checkbox { + flex-shrink: 0; + --chkbg: var(--bc); + --chkfg: var(--b1); + height: 1.5rem; + width: 1.5rem; + cursor: pointer; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + border-width: 1px; + border-color: hsl(var(--bc) / var(--un-border-opacity)); + --un-border-opacity: 0.2; + border-radius: var(--rounded-btn, 0.5rem) +} +.checkbox:focus-visible { + outline-style: solid; + outline-width: 2px; + outline-offset: 2px; + outline-color: hsl(var(--bc) / 1) +} +.checkbox:checked, + .checkbox[checked="true"], + .checkbox[aria-checked="true"] { + --un-bg-opacity: 1; + background-color: hsl(var(--bc) / var(--un-bg-opacity)); + background-repeat: no-repeat; + animation: checkmark var(--animation-input, 0.2s) ease-out; + background-image: linear-gradient(-45deg, transparent 65%, hsl(var(--chkbg)) 65.99%), + linear-gradient(45deg, transparent 75%, hsl(var(--chkbg)) 75.99%), + linear-gradient(-45deg, hsl(var(--chkbg)) 40%, transparent 40.99%), + linear-gradient( + 45deg, + hsl(var(--chkbg)) 30%, + hsl(var(--chkfg)) 30.99%, + hsl(var(--chkfg)) 40%, + transparent 40.99% + ), + linear-gradient(-45deg, hsl(var(--chkfg)) 50%, hsl(var(--chkbg)) 50.99%) +} +.checkbox:indeterminate { + --un-bg-opacity: 1; + background-color: hsl(var(--bc) / var(--un-bg-opacity)); + background-repeat: no-repeat; + animation: checkmark var(--animation-input, 0.2s) ease-out; + background-image: linear-gradient(90deg, transparent 80%, hsl(var(--chkbg)) 80%), + linear-gradient(-90deg, transparent 80%, hsl(var(--chkbg)) 80%), + linear-gradient( + 0deg, + hsl(var(--chkbg)) 43%, + hsl(var(--chkfg)) 43%, + hsl(var(--chkfg)) 57%, + hsl(var(--chkbg)) 57% + ) +} +.checkbox:disabled { + cursor: not-allowed; + border-color: transparent; + --un-bg-opacity: 1; + background-color: hsl(var(--bc) / var(--un-bg-opacity)); + opacity: 0.2 +} +[dir="rtl"] .checkbox:checked, + [dir="rtl"] .checkbox[checked="true"], + [dir="rtl"] .checkbox[aria-checked="true"] { + background-image: linear-gradient(45deg, transparent 65%, hsl(var(--chkbg)) 65.99%), + linear-gradient(-45deg, transparent 75%, hsl(var(--chkbg)) 75.99%), + linear-gradient(45deg, hsl(var(--chkbg)) 40%, transparent 40.99%), + linear-gradient( + -45deg, + hsl(var(--chkbg)) 30%, + hsl(var(--chkfg)) 30.99%, + hsl(var(--chkfg)) 40%, + transparent 40.99% + ), + linear-gradient(45deg, hsl(var(--chkfg)) 50%, hsl(var(--chkbg)) 50.99%) +} +.collapse:not(td):not(tr):not(colgroup) { + visibility: visible +} +.collapse { + position: relative; + display: grid; + overflow: hidden; + grid-template-rows: auto 0fr; + transition: grid-template-rows 0.2s; + width: 100%; + border-radius: var(--rounded-box, 1rem) +} +.collapse > input[type="checkbox"], +.collapse > input[type="radio"] { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + opacity: 0 +} +.collapse[open], +.collapse-open, +.collapse:focus:not(.collapse-close) { + grid-template-rows: auto 1fr +} +.collapse:not(.collapse-close):has(> input[type="checkbox"]:checked), +.collapse:not(.collapse-close):has(> input[type="radio"]:checked) { + grid-template-rows: auto 1fr +} +.collapse[open] > .collapse-content, +.collapse-open > .collapse-content, +.collapse:focus:not(.collapse-close) > .collapse-content, +.collapse:not(.collapse-close) > input[type="checkbox"]:checked ~ .collapse-content, +.collapse:not(.collapse-close) > input[type="radio"]:checked ~ .collapse-content { + visibility: visible; + min-height: -moz-fit-content; + min-height: fit-content +} +.collapse:focus-visible { + outline-style: solid; + outline-width: 2px; + outline-offset: 2px; + outline-color: hsl(var(--bc) / 1) +} +.collapse:has(.collapse-title:focus-visible), +.collapse:has(> input[type="checkbox"]:focus-visible), +.collapse:has(> input[type="radio"]:focus-visible) { + outline-style: solid; + outline-width: 2px; + outline-offset: 2px; + outline-color: hsl(var(--bc) / 1) +} +.collapse:not(.collapse-open):not(.collapse-close) > input[type="checkbox"], +.collapse:not(.collapse-open):not(.collapse-close) > input[type="radio"]:not(:checked), +.collapse:not(.collapse-open):not(.collapse-close) > .collapse-title { + cursor: pointer +} +.collapse:focus:not(.collapse-open):not(.collapse-close):not(.collapse[open]) > .collapse-title { + cursor: unset +} +:where(.collapse > input[type="checkbox"]), +:where(.collapse > input[type="radio"]) { + z-index: 1 +} +.collapse[open] > :where(.collapse-content), +.collapse-open > :where(.collapse-content), +.collapse:focus:not(.collapse-close) > :where(.collapse-content), +.collapse:not(.collapse-close) > :where(input[type="checkbox"]:checked ~ .collapse-content), +.collapse:not(.collapse-close) > :where(input[type="radio"]:checked ~ .collapse-content) { + padding-bottom: 1rem; + transition: padding 0.2s ease-out, + background-color 0.2s ease-out +} +.collapse[open].collapse-arrow > .collapse-title:after, +.collapse-open.collapse-arrow > .collapse-title:after, +.collapse-arrow:focus:not(.collapse-close) > .collapse-title:after, +.collapse-arrow:not(.collapse-close) > input[type="checkbox"]:checked ~ .collapse-title:after, +.collapse-arrow:not(.collapse-close) > input[type="radio"]:checked ~ .collapse-title:after { + --un-translate-y: -50%; + --un-rotate: 225deg; + transform: translate(var(--un-translate-x), var(--un-translate-y)) rotate(var(--un-rotate)) skewX(var(--un-skew-x)) skewY(var(--un-skew-y)) scaleX(var(--un-scale-x)) scaleY(var(--un-scale-y)) +} +[dir="rtl"] .collapse[open].collapse-arrow > .collapse-title:after, +[dir="rtl"] .collapse-open.collapse-arrow > .collapse-title:after, +[dir="rtl"] .collapse-arrow:focus:not(.collapse-close) .collapse-title:after, +[dir="rtl"] + .collapse-arrow:not(.collapse-close) + input[type="checkbox"]:checked + ~ .collapse-title:after { + --un-rotate: 135deg +} +.collapse[open].collapse-plus > .collapse-title:after, +.collapse-open.collapse-plus > .collapse-title:after, +.collapse-plus:focus:not(.collapse-close) > .collapse-title:after, +.collapse-plus:not(.collapse-close) > input[type="checkbox"]:checked ~ .collapse-title:after, +.collapse-plus:not(.collapse-close) > input[type="radio"]:checked ~ .collapse-title:after { + content: "−" +} +.collapse-title, +.collapse > input[type="checkbox"], +.collapse > input[type="radio"], +.collapse-content { + grid-column-start: 1; + grid-row-start: 1 +} +.collapse-title { + position: relative +} +.collapse-title, +:where(.collapse > input[type="checkbox"]), +:where(.collapse > input[type="radio"]) { + width: 100%; + padding: 1rem; + padding-right: 3rem; + min-height: 3.75rem; + transition: background-color 0.2s ease-out +} +.collapse-content { + visibility: hidden; + grid-column-start: 1; + grid-row-start: 2; + min-height: 0px; + transition: visibility 0.2s; + transition: padding 0.2s ease-out, + background-color 0.2s ease-out; + padding-left: 1rem; + padding-right: 1rem; + cursor: unset +} +.divider { + display: flex; + flex-direction: row; + align-items: center; + align-self: stretch; + margin-top: 1rem; + margin-bottom: 1rem; + height: 1rem; + white-space: nowrap +} +.divider:before, + .divider:after { + content: ""; + flex-grow: 1; + height: 0.125rem; + width: 100% +} +.divider:before { + background-color: hsl(var(--bc) / var(--un-bg-opacity)); + --un-bg-opacity: 0.1 +} +.divider:after { + background-color: hsl(var(--bc) / var(--un-bg-opacity)); + --un-bg-opacity: 0.1 +} +.divider:not(:empty) { + gap: 1rem +} +.dropdown { + position: relative; + display: inline-block +} +.dropdown > *:not(summary):focus { + outline: 2px solid transparent; + outline-offset: 2px +} +.dropdown .dropdown-content { + position: absolute +} +.dropdown:is(:not(details)) .dropdown-content { + visibility: hidden; + opacity: 0; + transform-origin: top; + --un-scale-x: .95; + --un-scale-y: .95; + transform: translate(var(--un-translate-x), var(--un-translate-y)) rotate(var(--un-rotate)) skewX(var(--un-skew-x)) skewY(var(--un-skew-y)) scaleX(var(--un-scale-x)) scaleY(var(--un-scale-y)); + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + transition-duration: 200ms +} +.dropdown.dropdown-open .dropdown-content, +.dropdown:not(.dropdown-hover):focus .dropdown-content, +.dropdown:focus-within .dropdown-content { + visibility: visible; + opacity: 1 +} +.dropdown.dropdown-hover:hover .dropdown-content { + visibility: visible; + opacity: 1 + } +.dropdown.dropdown-hover:hover .dropdown-content { + --un-scale-x: 1; + --un-scale-y: 1; + transform: translate(var(--un-translate-x), var(--un-translate-y)) rotate(var(--un-rotate)) skewX(var(--un-skew-x)) skewY(var(--un-skew-y)) scaleX(var(--un-scale-x)) scaleY(var(--un-scale-y)) + } +.dropdown:is(details) summary::-webkit-details-marker { + display: none +} +.dropdown.dropdown-open .dropdown-content, +.dropdown:focus .dropdown-content, +.dropdown:focus-within .dropdown-content { + --un-scale-x: 1; + --un-scale-y: 1; + transform: translate(var(--un-translate-x), var(--un-translate-y)) rotate(var(--un-rotate)) skewX(var(--un-skew-x)) skewY(var(--un-skew-y)) scaleX(var(--un-scale-x)) scaleY(var(--un-scale-y)) +} +.dropdown-end .dropdown-content { + right: 0px +} +.dropdown-end.dropdown-right .dropdown-content { + bottom: 0px; + top: auto +} +.dropdown-end.dropdown-left .dropdown-content { + bottom: 0px; + top: auto +} +.btn-primary:hover { + --un-border-opacity: 1; + border-color: hsl(var(--pf) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--pf) / var(--un-bg-opacity)) + } +.btn-primary { + --un-border-opacity: 1; + border-color: hsl(var(--p) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--p) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--pc) / var(--un-text-opacity)); + outline-color: hsl(var(--p) / 1) +} +.btn-primary.btn-active { + --un-border-opacity: 1; + border-color: hsl(var(--pf) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--pf) / var(--un-bg-opacity)) +} +.btn-accent:hover { + --un-border-opacity: 1; + border-color: hsl(var(--af) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--af) / var(--un-bg-opacity)) + } +.btn-accent { + --un-border-opacity: 1; + border-color: hsl(var(--a) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--a) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--ac) / var(--un-text-opacity)); + outline-color: hsl(var(--a) / 1) +} +.btn-accent.btn-active { + --un-border-opacity: 1; + border-color: hsl(var(--af) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--af) / var(--un-bg-opacity)) +} +.btn-success:hover { + --un-border-opacity: 1; + border-color: hsl(var(--su) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--su) / var(--un-bg-opacity)) + } +.btn-success { + --un-border-opacity: 1; + border-color: hsl(var(--su) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--su) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--suc) / var(--un-text-opacity)); + outline-color: hsl(var(--su) / 1) +} +.btn-success.btn-active { + --un-border-opacity: 1; + border-color: hsl(var(--su) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--su) / var(--un-bg-opacity)) +} +.btn-warning:hover { + --un-border-opacity: 1; + border-color: hsl(var(--wa) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--wa) / var(--un-bg-opacity)) + } +.btn-warning { + --un-border-opacity: 1; + border-color: hsl(var(--wa) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--wa) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--wac) / var(--un-text-opacity)); + outline-color: hsl(var(--wa) / 1) +} +.btn-warning.btn-active { + --un-border-opacity: 1; + border-color: hsl(var(--wa) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--wa) / var(--un-bg-opacity)) +} +.btn-error:hover { + --un-border-opacity: 1; + border-color: hsl(var(--er) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--er) / var(--un-bg-opacity)) + } +.btn-error { + --un-border-opacity: 1; + border-color: hsl(var(--er) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--er) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--erc) / var(--un-text-opacity)); + outline-color: hsl(var(--er) / 1) +} +.btn-error.btn-active { + --un-border-opacity: 1; + border-color: hsl(var(--er) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--er) / var(--un-bg-opacity)) +} +.btn-ghost:hover { + --un-border-opacity: 0; + background-color: hsl(var(--bc) / var(--un-bg-opacity)); + --un-bg-opacity: 0.2 + } +.btn-ghost { + border-width: 1px; + border-color: transparent; + background-color: transparent; + color: currentColor; + --un-shadow: 0 0 #0000; + --un-shadow-colored: 0 0 #0000; + box-shadow: var(--un-ring-offset-shadow, 0 0 #0000), var(--un-ring-shadow, 0 0 #0000), var(--un-shadow); + outline-color: currentColor +} +.btn-ghost.btn-active { + --un-border-opacity: 0; + background-color: hsl(var(--bc) / var(--un-bg-opacity)); + --un-bg-opacity: 0.2 +} +.btn-outline:hover { + --un-border-opacity: 1; + border-color: hsl(var(--bc) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--bc) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--b1) / var(--un-text-opacity)) + } +.btn-outline.btn-primary:hover { + --un-border-opacity: 1; + border-color: hsl(var(--pf) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--pf) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--pc) / var(--un-text-opacity)) + } +.btn-outline.btn-secondary:hover { + --un-border-opacity: 1; + border-color: hsl(var(--sf) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--sf) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--sc) / var(--un-text-opacity)) + } +.btn-outline.btn-accent:hover { + --un-border-opacity: 1; + border-color: hsl(var(--af) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--af) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--ac) / var(--un-text-opacity)) + } +.btn-outline.btn-success:hover { + --un-border-opacity: 1; + border-color: hsl(var(--su) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--su) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--suc) / var(--un-text-opacity)) + } +.btn-outline.btn-info:hover { + --un-border-opacity: 1; + border-color: hsl(var(--in) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--in) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--inc) / var(--un-text-opacity)) + } +.btn-outline.btn-warning:hover { + --un-border-opacity: 1; + border-color: hsl(var(--wa) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--wa) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--wac) / var(--un-text-opacity)) + } +.btn-outline.btn-error:hover { + --un-border-opacity: 1; + border-color: hsl(var(--er) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--er) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--erc) / var(--un-text-opacity)) + } +.btn-outline { + border-color: currentColor; + background-color: transparent; + --un-text-opacity: 1; + color: hsl(var(--bc) / var(--un-text-opacity)); + --un-shadow: 0 0 #0000; + --un-shadow-colored: 0 0 #0000; + box-shadow: var(--un-ring-offset-shadow, 0 0 #0000), var(--un-ring-shadow, 0 0 #0000), var(--un-shadow) +} +.btn-outline.btn-active { + --un-border-opacity: 1; + border-color: hsl(var(--bc) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--bc) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--b1) / var(--un-text-opacity)) +} +.btn-outline.btn-primary { + --un-text-opacity: 1; + color: hsl(var(--p) / var(--un-text-opacity)) +} +.btn-outline.btn-primary.btn-active { + --un-border-opacity: 1; + border-color: hsl(var(--pf) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--pf) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--pc) / var(--un-text-opacity)) +} +.btn-outline.btn-secondary { + --un-text-opacity: 1; + color: hsl(var(--s) / var(--un-text-opacity)) +} +.btn-outline.btn-secondary.btn-active { + --un-border-opacity: 1; + border-color: hsl(var(--sf) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--sf) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--sc) / var(--un-text-opacity)) +} +.btn-outline.btn-accent { + --un-text-opacity: 1; + color: hsl(var(--a) / var(--un-text-opacity)) +} +.btn-outline.btn-accent.btn-active { + --un-border-opacity: 1; + border-color: hsl(var(--af) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--af) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--ac) / var(--un-text-opacity)) +} +.btn-outline.btn-success { + --un-text-opacity: 1; + color: hsl(var(--su) / var(--un-text-opacity)) +} +.btn-outline.btn-success.btn-active { + --un-border-opacity: 1; + border-color: hsl(var(--su) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--su) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--suc) / var(--un-text-opacity)) +} +.btn-outline.btn-info { + --un-text-opacity: 1; + color: hsl(var(--in) / var(--un-text-opacity)) +} +.btn-outline.btn-info.btn-active { + --un-border-opacity: 1; + border-color: hsl(var(--in) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--in) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--inc) / var(--un-text-opacity)) +} +.btn-outline.btn-warning { + --un-text-opacity: 1; + color: hsl(var(--wa) / var(--un-text-opacity)) +} +.btn-outline.btn-warning.btn-active { + --un-border-opacity: 1; + border-color: hsl(var(--wa) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--wa) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--wac) / var(--un-text-opacity)) +} +.btn-outline.btn-error { + --un-text-opacity: 1; + color: hsl(var(--er) / var(--un-text-opacity)) +} +.btn-outline.btn-error.btn-active { + --un-border-opacity: 1; + border-color: hsl(var(--er) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--er) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--erc) / var(--un-text-opacity)) +} +.footer { + display: grid; + width: 100%; + grid-auto-flow: row; + place-items: start; + -moz-column-gap: 1rem; + column-gap: 1rem; + row-gap: 2.5rem; + font-size: 0.875rem; + line-height: 1.25rem +} +.footer > * { + display: grid; + place-items: start; + gap: 0.5rem +} +.footer { + grid-auto-flow: column + } +.form-control { + display: flex; + flex-direction: column +} +.input { + flex-shrink: 1; + height: 3rem; + padding-left: 1rem; + padding-right: 1rem; + font-size: 0.875rem; + font-size: 1rem; + line-height: 1.25rem; + line-height: 2; + line-height: 1.5rem; + border-width: 1px; + border-color: hsl(var(--bc) / var(--un-border-opacity)); + --un-border-opacity: 0; + --un-bg-opacity: 1; + background-color: hsl(var(--b1) / var(--un-bg-opacity)); + border-radius: var(--rounded-btn, 0.5rem) +} +.input input:focus { + outline: 2px solid transparent; + outline-offset: 2px +} +.input[list]::-webkit-calendar-picker-indicator { + line-height: 1em +} +.input:focus, + .input:focus-within { + outline-style: solid; + outline-width: 2px; + outline-offset: 2px; + outline-color: hsl(var(--bc) / 0.2) +} +.join { + display: inline-flex; + align-items: stretch; + border-radius: var(--rounded-btn, 0.5rem) +} +.join :where(.join-item) { + border-start-end-radius: 0; + border-end-end-radius: 0; + border-end-start-radius: 0; + border-start-start-radius: 0 +} +.join .join-item:not(:first-child):not(:last-child), + .join *:not(:first-child):not(:last-child) .join-item { + border-start-end-radius: 0; + border-end-end-radius: 0; + border-end-start-radius: 0; + border-start-start-radius: 0 +} +.join .join-item:first-child:not(:last-child), + .join *:first-child:not(:last-child) .join-item { + border-start-end-radius: 0; + border-end-end-radius: 0 +} +.join .dropdown .join-item:first-child:not(:last-child), + .join *:first-child:not(:last-child) .dropdown .join-item { + border-start-end-radius: inherit; + border-end-end-radius: inherit +} +.join :where(.join-item:first-child:not(:last-child)), + .join :where(*:first-child:not(:last-child) .join-item) { + border-end-start-radius: inherit; + border-start-start-radius: inherit +} +.join .join-item:last-child:not(:first-child), + .join *:last-child:not(:first-child) .join-item { + border-end-start-radius: 0; + border-start-start-radius: 0 +} +.join :where(.join-item:last-child:not(:first-child)), + .join :where(*:last-child:not(:first-child) .join-item) { + border-start-end-radius: inherit; + border-end-end-radius: inherit +} +:where(.join *) { + border-radius: inherit + } +:where(.join *:has(.join-item)) { + border-radius: inherit + } +.join > :where(*:not(:first-child)) { + margin-top: 0px; + margin-bottom: 0px; + margin-left: -1px +} +.join.join-vertical { + flex-direction: column +} +.join.join-vertical .join-item:first-child:not(:last-child), + .join.join-vertical *:first-child:not(:last-child) .join-item { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-top-left-radius: inherit; + border-top-right-radius: inherit +} +.join.join-vertical .join-item:last-child:not(:first-child), + .join.join-vertical *:last-child:not(:first-child) .join-item { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-bottom-left-radius: inherit; + border-bottom-right-radius: inherit +} +.join.join-horizontal { + flex-direction: row +} +.join.join-horizontal .join-item:first-child:not(:last-child), + .join.join-horizontal *:first-child:not(:last-child) .join-item { + border-bottom-right-radius: 0; + border-top-right-radius: 0; + border-bottom-left-radius: inherit; + border-top-left-radius: inherit +} +.join.join-horizontal .join-item:last-child:not(:first-child), + .join.join-horizontal *:last-child:not(:first-child) .join-item { + border-bottom-left-radius: 0; + border-top-left-radius: 0; + border-bottom-right-radius: inherit; + border-top-right-radius: inherit +} +.join.join-vertical > :where(*:not(:first-child)) { + margin-left: 0px; + margin-right: 0px; + margin-top: -1px +} +.join.join-horizontal > :where(*:not(:first-child)) { + margin-top: 0px; + margin-bottom: 0px; + margin-left: -1px +} +.kbd { + display: inline-flex; + align-items: center; + justify-content: center; + border-width: 1px; + border-color: hsl(var(--bc) / var(--un-border-opacity)); + --un-border-opacity: 0.2; + --un-bg-opacity: 1; + background-color: hsl(var(--b2) / var(--un-bg-opacity)); + padding-left: 0.5rem; + padding-right: 0.5rem; + border-radius: var(--rounded-btn, 0.5rem); + border-bottom-width: 2px; + min-height: 2.2em; + min-width: 2.2em +} +.modal { + pointer-events: none; + position: fixed; + inset: 0px; + margin: 0px; + display: grid; + height: 100%; + max-height: none; + width: 100%; + max-width: none; + justify-items: center; + padding: 0px; + opacity: 0; + overscroll-behavior: contain; + overscroll-behavior: contain; + z-index: 999; + background-color: transparent; + color: inherit; + transition-duration: 200ms; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + transition-property: transform, opacity, visibility; + overflow-y: hidden +} +:where(.modal) { + align-items: center +} +.modal-open, +.modal:target, +.modal-toggle:checked + .modal, +.modal[open] { + pointer-events: auto; + visibility: visible; + opacity: 1 +} +:root:has(:is(.modal-open, .modal:target, .modal-toggle:checked + .modal, .modal[open])) { + overflow: hidden +} +.modal:not(dialog:not(.modal-open)), + .modal::backdrop { + background-color: rgba(0, 0, 0, 0.3); + animation: modal-pop 0.2s ease-out +} +.modal-open .modal-box, +.modal-toggle:checked + .modal .modal-box, +.modal:target .modal-box, +.modal[open] .modal-box { + --un-translate-y: 0px; + --un-scale-x: 1; + --un-scale-y: 1; + transform: translate(var(--un-translate-x), var(--un-translate-y)) rotate(var(--un-rotate)) skewX(var(--un-skew-x)) skewY(var(--un-skew-y)) scaleX(var(--un-scale-x)) scaleY(var(--un-scale-y)) +} +.modal-box { + max-height: calc(100vh - 5em); + grid-column-start: 1; + grid-row-start: 1; + width: 91.666667%; + max-width: 32rem; + --un-scale-x: .9; + --un-scale-y: .9; + transform: translate(var(--un-translate-x), var(--un-translate-y)) rotate(var(--un-rotate)) skewX(var(--un-skew-x)) skewY(var(--un-skew-y)) scaleX(var(--un-scale-x)) scaleY(var(--un-scale-y)); + --un-bg-opacity: 1; + background-color: hsl(var(--b1) / var(--un-bg-opacity)); + padding: 1.5rem; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + transition-duration: 200ms; + border-top-left-radius: var(--rounded-box, 1rem); + border-top-right-radius: var(--rounded-box, 1rem); + border-bottom-left-radius: var(--rounded-box, 1rem); + border-bottom-right-radius: var(--rounded-box, 1rem); + box-shadow: rgba(0, 0, 0, 0.25) 0px 25px 50px -12px; + overflow-y: auto; + overscroll-behavior: contain +} +.modal-action { + display: flex; + margin-top: 1.5rem; + justify-content: flex-end +} +.modal-action > :not([hidden]) ~ :not([hidden]) { + --un-space-x-reverse: 0; + margin-right: calc(0.5rem * var(--un-space-x-reverse)); + margin-left: calc(0.5rem * calc(1 - var(--un-space-x-reverse))) +} +.navbar { + display: flex; + align-items: center; + padding: var(--navbar-padding, 0.5rem); + min-height: 4rem; + width: 100% +} +:where(.navbar > *) { + display: inline-flex; + align-items: center +} +.navbar-start { + width: 50%; + justify-content: flex-start +} +.navbar-center { + flex-shrink: 0 +} +.navbar-end { + width: 50%; + justify-content: flex-end +} +.progress { + position: relative; + width: 100%; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + overflow: hidden; + height: 0.5rem; + background-color: hsl(var(--bc) / 0.2); + border-radius: var(--rounded-box, 1rem) +} +.progress::-moz-progress-bar { + --un-bg-opacity: 1; + background-color: hsl(var(--bc) / var(--un-bg-opacity)); + border-radius: var(--rounded-box, 1rem) +} +.progress:indeterminate { + --progress-color: hsl(var(--bc)); + background-image: repeating-linear-gradient( + 90deg, + var(--progress-color) -1%, + var(--progress-color) 10%, + transparent 10%, + transparent 90% + ); + background-size: 200%; + background-position-x: 15%; + animation: progress-loading 5s ease-in-out infinite +} +.progress::-webkit-progress-bar { + background-color: transparent; + border-radius: var(--rounded-box, 1rem) +} +.progress::-webkit-progress-value { + --un-bg-opacity: 1; + background-color: hsl(var(--bc) / var(--un-bg-opacity)); + border-radius: var(--rounded-box, 1rem) +} +.progress:indeterminate::-moz-progress-bar { + background-color: transparent; + background-image: repeating-linear-gradient( + 90deg, + var(--progress-color) -1%, + var(--progress-color) 10%, + transparent 10%, + transparent 90% + ); + background-size: 200%; + background-position-x: 15%; + animation: progress-loading 5s ease-in-out infinite +} +.select { + display: inline-flex; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + height: 3rem; + padding-left: 1rem; + padding-right: 2.5rem; + padding-right: 2.5rem; + font-size: 0.875rem; + line-height: 1.25rem; + line-height: 2; + min-height: 3rem; + border-width: 1px; + border-color: hsl(var(--bc) / var(--un-border-opacity)); + --un-border-opacity: 0; + --un-bg-opacity: 1; + background-color: hsl(var(--b1) / var(--un-bg-opacity)); + border-radius: var(--rounded-btn, 0.5rem); + background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), + linear-gradient(135deg, currentColor 50%, transparent 50%); + background-position: calc(100% - 20px) calc(1px + 50%), + calc(100% - 16.1px) calc(1px + 50%); + background-size: 4px 4px, + 4px 4px; + background-repeat: no-repeat +} +.select[multiple] { + height: auto +} +.select:focus { + outline-style: solid; + outline-width: 2px; + outline-offset: 2px; + outline-color: hsl(var(--bc) / 0.2) +} +[dir="rtl"] .select { + background-position: calc(0% + 12px) calc(1px + 50%), + calc(0% + 16px) calc(1px + 50%) +} +.stack { + display: inline-grid; + place-items: center; + align-items: flex-end +} +.stack > * { + grid-column-start: 1; + grid-row-start: 1; + transform: translateY(10%) scale(0.9); + z-index: 1; + width: 100%; + opacity: 0.6 +} +.stack > *:nth-child(2) { + transform: translateY(5%) scale(0.95); + z-index: 2; + opacity: 0.8 +} +.stack > *:nth-child(1) { + transform: translateY(0) scale(1); + z-index: 3; + opacity: 1 +} +.stats { + display: inline-grid; + --un-bg-opacity: 1; + background-color: hsl(var(--b1) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--bc) / var(--un-text-opacity)); + border-radius: var(--rounded-box, 1rem) +} +:where(.stats) { + grid-auto-flow: column; + overflow-x: auto +} +:where(.stats) > :not([hidden]) ~ :not([hidden]) { + --un-divide-x-reverse: 0; + border-right-width: calc(1px * var(--un-divide-x-reverse)); + border-left-width: calc(1px * calc(1 - var(--un-divide-x-reverse))); + --un-divide-y-reverse: 0; + border-top-width: calc(0px * calc(1 - var(--un-divide-y-reverse))); + border-bottom-width: calc(0px * var(--un-divide-y-reverse)) +} +.stat { + display: inline-grid; + width: 100%; + grid-template-columns: repeat(1, 1fr); + -moz-column-gap: 1rem; + column-gap: 1rem; + border-color: hsl(var(--bc) / var(--un-border-opacity)); + --un-border-opacity: 0.1; + padding-left: 1.5rem; + padding-right: 1.5rem; + padding-top: 1rem; + padding-bottom: 1rem +} +.stat-title { + grid-column-start: 1; + white-space: nowrap; + color: hsl(var(--bc) / 0.6) +} +.stat-value { + grid-column-start: 1; + white-space: nowrap; + font-size: 2.25rem; + line-height: 2.5rem; + font-weight: 800 +} +.stat-desc { + grid-column-start: 1; + white-space: nowrap; + font-size: 0.75rem; + line-height: 1rem; + color: hsl(var(--bc) / 0.6) +} +.steps { + display: inline-grid; + grid-auto-flow: column; + overflow: hidden; + overflow-x: auto; + counter-reset: step; + grid-auto-columns: 1fr +} +.steps .step { + display: grid; + grid-template-columns: repeat(1, minmax(0, 1fr)); + grid-template-columns: auto; + grid-template-rows: repeat(2, minmax(0, 1fr)); + grid-template-rows: 40px 1fr; + place-items: center; + text-align: center; + min-width: 4rem +} +.steps .step:before { + top: 0px; + grid-column-start: 1; + grid-row-start: 1; + height: 0.5rem; + width: 100%; + transform: translate(var(--un-translate-x), var(--un-translate-y)) rotate(var(--un-rotate)) skewX(var(--un-skew-x)) skewY(var(--un-skew-y)) scaleX(var(--un-scale-x)) scaleY(var(--un-scale-y)); + --un-bg-opacity: 1; + background-color: hsl(var(--b3) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--bc) / var(--un-text-opacity)); + content: ""; + margin-left: -100% +} +.steps .step:after { + content: counter(step); + counter-increment: step; + z-index: 1; + position: relative; + grid-column-start: 1; + grid-row-start: 1; + display: grid; + height: 2rem; + width: 2rem; + place-items: center; + place-self: center; + border-radius: 9999px; + --un-bg-opacity: 1; + background-color: hsl(var(--b3) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--bc) / var(--un-text-opacity)) +} +.steps .step:first-child:before { + content: none +} +.steps .step[data-content]:after { + content: attr(data-content) +} +.steps .step-neutral + .step-neutral:before, + .steps .step-neutral:after { + --un-bg-opacity: 1; + background-color: hsl(var(--n) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--nc) / var(--un-text-opacity)) +} +.steps .step-primary + .step-primary:before, + .steps .step-primary:after { + --un-bg-opacity: 1; + background-color: hsl(var(--p) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--pc) / var(--un-text-opacity)) +} +.steps .step-secondary + .step-secondary:before, + .steps .step-secondary:after { + --un-bg-opacity: 1; + background-color: hsl(var(--s) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--sc) / var(--un-text-opacity)) +} +.steps .step-accent + .step-accent:before, + .steps .step-accent:after { + --un-bg-opacity: 1; + background-color: hsl(var(--a) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--ac) / var(--un-text-opacity)) +} +.steps .step-info + .step-info:before { + --un-bg-opacity: 1; + background-color: hsl(var(--in) / var(--un-bg-opacity)) +} +.steps .step-info:after { + --un-bg-opacity: 1; + background-color: hsl(var(--in) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--inc) / var(--un-text-opacity)) +} +.steps .step-success + .step-success:before { + --un-bg-opacity: 1; + background-color: hsl(var(--su) / var(--un-bg-opacity)) +} +.steps .step-success:after { + --un-bg-opacity: 1; + background-color: hsl(var(--su) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--suc) / var(--un-text-opacity)) +} +.steps .step-warning + .step-warning:before { + --un-bg-opacity: 1; + background-color: hsl(var(--wa) / var(--un-bg-opacity)) +} +.steps .step-warning:after { + --un-bg-opacity: 1; + background-color: hsl(var(--wa) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--wac) / var(--un-text-opacity)) +} +.steps .step-error + .step-error:before { + --un-bg-opacity: 1; + background-color: hsl(var(--er) / var(--un-bg-opacity)) +} +.steps .step-error:after { + --un-bg-opacity: 1; + background-color: hsl(var(--er) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--erc) / var(--un-text-opacity)) +} +.swap { + position: relative; + display: inline-grid; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + place-content: center; + cursor: pointer +} +.swap > * { + grid-column-start: 1; + grid-row-start: 1; + transition-duration: 300ms; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + transition-property: transform, opacity +} +.swap input { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none +} +.swap .swap-on, +.swap .swap-indeterminate, +.swap input:indeterminate ~ .swap-on { + opacity: 0 +} +.swap input:checked ~ .swap-off, +.swap-active .swap-off, +.swap input:indeterminate ~ .swap-off { + opacity: 0 +} +.swap input:checked ~ .swap-on, +.swap-active .swap-on, +.swap input:indeterminate ~ .swap-indeterminate { + opacity: 1 +} +.tabs { + display: flex; + flex-wrap: wrap; + align-items: flex-end +} +.textarea { + flex-shrink: 1; + min-height: 3rem; + padding-left: 1rem; + padding-right: 1rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + font-size: 0.875rem; + line-height: 1.25rem; + line-height: 2; + border-width: 1px; + border-color: hsl(var(--bc) / var(--un-border-opacity)); + --un-border-opacity: 0; + --un-bg-opacity: 1; + background-color: hsl(var(--b1) / var(--un-bg-opacity)); + border-radius: var(--rounded-btn, 0.5rem) +} +.textarea:focus { + outline-style: solid; + outline-width: 2px; + outline-offset: 2px; + outline-color: hsl(var(--bc) / 0.2) +} +.toggle { + flex-shrink: 0; + --tglbg: hsl(var(--b1)); + --handleoffset: 1.5rem; + --handleoffsetcalculator: calc(var(--handleoffset) * -1); + --togglehandleborder: 0 0; + height: 1.5rem; + width: 3rem; + cursor: pointer; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + border-width: 1px; + border-color: hsl(var(--bc) / var(--un-border-opacity)); + --un-border-opacity: 0.2; + background-color: hsl(var(--bc) / var(--un-bg-opacity)); + --un-bg-opacity: 0.5; + border-radius: var(--rounded-badge, 1.9rem); + transition: background, + box-shadow var(--animation-input, 0.2s) ease-out; + box-shadow: var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset, + 0 0 0 2px var(--tglbg) inset, + var(--togglehandleborder) +} +[dir="rtl"] .toggle { + --handleoffsetcalculator: calc(var(--handleoffset) * 1) +} +.toggle:focus-visible { + outline-style: solid; + outline-width: 2px; + outline-offset: 2px; + outline-color: hsl(var(--bc) / 0.2) +} +.toggle:checked, + .toggle[checked="true"], + .toggle[aria-checked="true"] { + --handleoffsetcalculator: var(--handleoffset); + --un-border-opacity: 1; + --un-bg-opacity: 1 +} +[dir="rtl"] .toggle:checked, [dir="rtl"] .toggle[checked="true"], [dir="rtl"] .toggle[aria-checked="true"] { + --handleoffsetcalculator: calc(var(--handleoffset) * -1) +} +.toggle:indeterminate { + --un-border-opacity: 1; + --un-bg-opacity: 1; + box-shadow: calc(var(--handleoffset) / 2) 0 0 2px var(--tglbg) inset, + calc(var(--handleoffset) / -2) 0 0 2px var(--tglbg) inset, + 0 0 0 2px var(--tglbg) inset +} +[dir="rtl"] .toggle:indeterminate { + box-shadow: calc(var(--handleoffset) / 2) 0 0 2px var(--tglbg) inset, + calc(var(--handleoffset) / -2) 0 0 2px var(--tglbg) inset, + 0 0 0 2px var(--tglbg) inset +} +.toggle:disabled { + cursor: not-allowed; + --un-border-opacity: 1; + border-color: hsl(var(--bc) / var(--un-border-opacity)); + background-color: transparent; + opacity: 0.3; + --togglehandleborder: 0 0 0 3px hsl(var(--bc)) inset, + var(--handleoffsetcalculator) 0 0 3px hsl(var(--bc)) inset +} +.alert-warning { + border-color: hsl(var(--wa) / 0.2); + --un-text-opacity: 1; + color: hsl(var(--wac) / var(--un-text-opacity)); + --alert-bg: hsl(var(--wa)); + --alert-bg-mix: hsl(var(--b1)) +} +.alert-error { + border-color: hsl(var(--er) / 0.2); + --un-text-opacity: 1; + color: hsl(var(--erc) / var(--un-text-opacity)); + --alert-bg: hsl(var(--er)); + --alert-bg-mix: hsl(var(--b1)) +} +.badge-neutral { + --un-border-opacity: 1; + border-color: hsl(var(--n) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--n) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--nc) / var(--un-text-opacity)) +} +.badge-primary { + --un-border-opacity: 1; + border-color: hsl(var(--p) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--p) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--pc) / var(--un-text-opacity)) +} +.badge-secondary { + --un-border-opacity: 1; + border-color: hsl(var(--s) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--s) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--sc) / var(--un-text-opacity)) +} +.badge-accent { + --un-border-opacity: 1; + border-color: hsl(var(--a) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--a) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--ac) / var(--un-text-opacity)) +} +.badge-info { + border-color: transparent; + --un-bg-opacity: 1; + background-color: hsl(var(--in) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--inc) / var(--un-text-opacity)) +} +.badge-success { + border-color: transparent; + --un-bg-opacity: 1; + background-color: hsl(var(--su) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--suc) / var(--un-text-opacity)) +} +.badge-warning { + border-color: transparent; + --un-bg-opacity: 1; + background-color: hsl(var(--wa) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--wac) / var(--un-text-opacity)) +} +.badge-error { + border-color: transparent; + --un-bg-opacity: 1; + background-color: hsl(var(--er) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--erc) / var(--un-text-opacity)) +} +.badge-ghost { + --un-border-opacity: 1; + border-color: hsl(var(--b2) / var(--un-border-opacity)); + --un-bg-opacity: 1; + background-color: hsl(var(--b2) / var(--un-bg-opacity)); + --un-text-opacity: 1; + color: hsl(var(--bc) / var(--un-text-opacity)) +} +.badge-outline { + border-color: currentColor; + --un-border-opacity: 0.5; + background-color: transparent; + color: currentColor +} +.badge-outline.badge-neutral { + --un-text-opacity: 1; + color: hsl(var(--n) / var(--un-text-opacity)) +} +.badge-outline.badge-primary { + --un-text-opacity: 1; + color: hsl(var(--p) / var(--un-text-opacity)) +} +.badge-outline.badge-secondary { + --un-text-opacity: 1; + color: hsl(var(--s) / var(--un-text-opacity)) +} +.badge-outline.badge-accent { + --un-text-opacity: 1; + color: hsl(var(--a) / var(--un-text-opacity)) +} +.badge-outline.badge-info { + --un-text-opacity: 1; + color: hsl(var(--in) / var(--un-text-opacity)) +} +.badge-outline.badge-success { + --un-text-opacity: 1; + color: hsl(var(--su) / var(--un-text-opacity)) +} +.badge-outline.badge-warning { + --un-text-opacity: 1; + color: hsl(var(--wa) / var(--un-text-opacity)) +} +.badge-outline.badge-error { + --un-text-opacity: 1; + color: hsl(var(--er) / var(--un-text-opacity)) +} +.card-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1.25rem; + line-height: 1.75rem; + font-weight: 600 +} +.collapse-arrow > .collapse-title:after { + position: absolute; + display: block; + height: 0.5rem; + width: 0.5rem; + --un-translate-y: -100%; + --un-rotate: 45deg; + transform: translate(var(--un-translate-x), var(--un-translate-y)) rotate(var(--un-rotate)) skewX(var(--un-skew-x)) skewY(var(--un-skew-y)) scaleX(var(--un-scale-x)) scaleY(var(--un-scale-y)); + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + transition-duration: 150ms; + transition-duration: 0.2s; + top: 50%; + right: 1.4rem; + content: ""; + transform-origin: 75% 75%; + box-shadow: 2px 2px; + pointer-events: none +} +[dir="rtl"] .collapse-arrow > .collapse-title:after { + --un-rotate: -45deg +} +.label-text { + font-size: 0.875rem; + line-height: 1.25rem; + --un-text-opacity: 1; + color: hsl(var(--bc) / var(--un-text-opacity)) +} +.label-text-alt { + font-size: 0.75rem; + line-height: 1rem; + --un-text-opacity: 1; + color: hsl(var(--bc) / var(--un-text-opacity)) +} +.input-bordered { + --un-border-opacity: 0.2 +} +.join-item:focus { + isolation: isolate +} +.loading { + pointer-events: none; + display: inline-block; + aspect-ratio: 1 / 1; + width: 1.5rem; + background-color: currentColor; + -webkit-mask-size: 100%; + mask-size: 100%; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='%23000' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cstyle%3E.spinner_V8m1%7Btransform-origin:center;animation:spinner_zKoa 2s linear infinite%7D.spinner_V8m1 circle%7Bstroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite%7D%40keyframes spinner_zKoa%7B100%25%7Btransform:rotate(360deg)%7D%7D%40keyframes spinner_YpZS%7B0%25%7Bstroke-dasharray:0 150;stroke-dashoffset:0%7D47.5%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-16%7D95%25%2C100%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-59%7D%7D%3C%2Fstyle%3E%3Cg class='spinner_V8m1'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3'%3E%3C%2Fcircle%3E%3C%2Fg%3E%3C%2Fsvg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='%23000' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cstyle%3E.spinner_V8m1%7Btransform-origin:center;animation:spinner_zKoa 2s linear infinite%7D.spinner_V8m1 circle%7Bstroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite%7D%40keyframes spinner_zKoa%7B100%25%7Btransform:rotate(360deg)%7D%7D%40keyframes spinner_YpZS%7B0%25%7Bstroke-dasharray:0 150;stroke-dashoffset:0%7D47.5%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-16%7D95%25%2C100%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-59%7D%7D%3C%2Fstyle%3E%3Cg class='spinner_V8m1'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3'%3E%3C%2Fcircle%3E%3C%2Fg%3E%3C%2Fsvg%3E") +} +.loading-spinner { + -webkit-mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='%23000' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cstyle%3E.spinner_V8m1%7Btransform-origin:center;animation:spinner_zKoa 2s linear infinite%7D.spinner_V8m1 circle%7Bstroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite%7D%40keyframes spinner_zKoa%7B100%25%7Btransform:rotate(360deg)%7D%7D%40keyframes spinner_YpZS%7B0%25%7Bstroke-dasharray:0 150;stroke-dashoffset:0%7D47.5%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-16%7D95%25%2C100%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-59%7D%7D%3C%2Fstyle%3E%3Cg class='spinner_V8m1'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3'%3E%3C%2Fcircle%3E%3C%2Fg%3E%3C%2Fsvg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='%23000' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cstyle%3E.spinner_V8m1%7Btransform-origin:center;animation:spinner_zKoa 2s linear infinite%7D.spinner_V8m1 circle%7Bstroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite%7D%40keyframes spinner_zKoa%7B100%25%7Btransform:rotate(360deg)%7D%7D%40keyframes spinner_YpZS%7B0%25%7Bstroke-dasharray:0 150;stroke-dashoffset:0%7D47.5%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-16%7D95%25%2C100%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-59%7D%7D%3C%2Fstyle%3E%3Cg class='spinner_V8m1'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3'%3E%3C%2Fcircle%3E%3C%2Fg%3E%3C%2Fsvg%3E") +} +.loading-xs { + width: 1rem +} +.loading-sm { + width: 1.25rem +} +.modal-backdrop { + z-index: -1; + grid-column-start: 1; + grid-row-start: 1; + display: grid; + align-self: stretch; + justify-self: stretch; + color: transparent +} +.select-bordered { + --un-border-opacity: 0.2 +} +.textarea-bordered { + --un-border-opacity: 0.2 +} +.rounded-box { + border-radius: var(--rounded-box, 1rem) +} +.badge-xs { + height: 0.75rem; + font-size: 0.75rem; + line-height: .75rem; + padding-left: 0.313rem; + padding-right: 0.313rem +} +.badge-sm { + height: 1rem; + font-size: 0.75rem; + line-height: 1rem; + padding-left: 0.438rem; + padding-right: 0.438rem +} +.badge-lg { + height: 1.5rem; + font-size: 1rem; + line-height: 1.5rem; + padding-left: 0.688rem; + padding-right: 0.688rem +} +.btn-xs { + height: 1.5rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + min-height: 1.5rem; + font-size: 0.75rem +} +.btn-sm { + height: 2rem; + padding-left: 0.75rem; + padding-right: 0.75rem; + min-height: 2rem; + font-size: 0.875rem +} +.input-xs { + height: 1.5rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + font-size: 0.75rem; + line-height: 1rem; + line-height: 1.625 +} +.input-sm { + height: 2rem; + padding-left: 0.75rem; + padding-right: 0.75rem; + font-size: 0.875rem; + line-height: 2rem +} +.kbd-xs { + padding-left: 0.25rem; + padding-right: 0.25rem; + font-size: 0.75rem; + line-height: 1rem; + min-height: 1.2em; + min-width: 1.2em +} +.select-sm { + height: 2rem; + padding-left: 0.75rem; + padding-right: 2rem; + font-size: 0.875rem; + line-height: 2rem; + min-height: 2rem +} +[dir="rtl"] .select-sm { + padding-left: 2rem; + padding-right: 0.75rem +} +.stats-horizontal { + grid-auto-flow: column +} +.stats-horizontal > :not([hidden]) ~ :not([hidden]) { + --un-divide-x-reverse: 0; + border-right-width: calc(1px * var(--un-divide-x-reverse)); + border-left-width: calc(1px * calc(1 - var(--un-divide-x-reverse))); + --un-divide-y-reverse: 0; + border-top-width: calc(0px * calc(1 - var(--un-divide-y-reverse))); + border-bottom-width: calc(0px * var(--un-divide-y-reverse)) +} +.stats-horizontal { + overflow-x: auto +} +.textarea-sm { + padding-left: 0.75rem; + padding-right: 0.75rem; + padding-top: 0.25rem; + padding-bottom: 0.25rem; + font-size: 0.875rem; + line-height: 2rem +} +.menu-xs :where(li:not(.menu-title) > *:not(ul):not(details):not(.menu-title)), + .menu-xs :where(li:not(.menu-title) > details > summary:not(.menu-title)) { + border-radius: 0.25rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + padding-top: 0.25rem; + padding-bottom: 0.25rem; + font-size: 0.75rem; + line-height: 1rem +} +.menu-xs .menu-title { + padding-left: 0.5rem; + padding-right: 0.5rem; + padding-top: 0.25rem; + padding-bottom: 0.25rem +} +.menu-sm :where(li:not(.menu-title) > *:not(ul):not(details):not(.menu-title)), + .menu-sm :where(li:not(.menu-title) > details > summary:not(.menu-title)) { + padding-left: 0.75rem; + padding-right: 0.75rem; + padding-top: 0.25rem; + padding-bottom: 0.25rem; + font-size: 0.875rem; + line-height: 1.25rem; + border-radius: var(--rounded-btn, 0.5rem) +} +.menu-sm .menu-title { + padding-left: 0.75rem; + padding-right: 0.75rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem +} +.table-xs :not(thead):not(tfoot) tr { + font-size: 0.75rem; + line-height: 1rem +} +.table-xs :where(th, td) { + padding-left: 0.5rem; + padding-right: 0.5rem; + padding-top: 0.25rem; + padding-bottom: 0.25rem +} +.table-sm :not(thead):not(tfoot) tr { + font-size: 0.875rem; + line-height: 1.25rem +} +.table-sm :where(th, td) { + padding-left: 0.75rem; + padding-right: 0.75rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem +} +.checkbox-xs { + height: 1rem; + width: 1rem +} +.checkbox-sm { + height: 1.25rem; + width: 1.25rem +} +@keyframes button-pop { + 0% { + transform: scale(var(--btn-focus-scale, 0.98)) + } + 40% { + transform: scale(1.02) + } + 100% { + transform: scale(1) + } +} +@keyframes checkmark { + 0% { + background-position-y: 5px + } + 50% { + background-position-y: -2px + } + 100% { + background-position-y: 0 + } +} +@keyframes modal-pop { + 0% { + opacity: 0 + } +} +@keyframes progress-loading { + 50% { + background-position-x: -115% + } +} +@keyframes radiomark { + 0% { + box-shadow: 0 0 0 12px hsl(var(--b1)) inset, + 0 0 0 12px hsl(var(--b1)) inset + } + 50% { + box-shadow: 0 0 0 3px hsl(var(--b1)) inset, + 0 0 0 3px hsl(var(--b1)) inset + } + 100% { + box-shadow: 0 0 0 4px hsl(var(--b1)) inset, + 0 0 0 4px hsl(var(--b1)) inset + } +} +@keyframes rating-pop { + 0% { + transform: translateY(-0.125em) + } + 40% { + transform: translateY(-0.125em) + } + 100% { + transform: translateY(0) + } +} +@keyframes toast-pop { + 0% { + transform: scale(0.9); + opacity: 0 + } + 100% { + transform: scale(1); + opacity: 1 + } +}:root { + color-scheme: dark; + --pf: 262 80% 43%; + --sf: 316 70% 43%; + --af: 175 70% 34%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 262 80% 50%; + --pc: 0 0% 100%; + --s: 316 70% 50%; + --sc: 0 0% 100%; + --a: 175 70% 41%; + --ac: 0 0% 100%; + --n: 213 18% 20%; + --nf: 212 17% 17%; + --nc: 220 13% 69%; + --b1: 212 18% 14%; + --b2: 213 18% 12%; + --b3: 213 18% 10%; + --bc: 220 13% 69% +} +[data-theme=dark] { + color-scheme: dark; + --pf: 262 80% 43%; + --sf: 316 70% 43%; + --af: 175 70% 34%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 262 80% 50%; + --pc: 0 0% 100%; + --s: 316 70% 50%; + --sc: 0 0% 100%; + --a: 175 70% 41%; + --ac: 0 0% 100%; + --n: 213 18% 20%; + --nf: 212 17% 17%; + --nc: 220 13% 69%; + --b1: 212 18% 14%; + --b2: 213 18% 12%; + --b3: 213 18% 10%; + --bc: 220 13% 69% +} +[data-theme=light] { + color-scheme: light; + --pf: 259 94% 44%; + --sf: 314 100% 40%; + --af: 174 75% 39%; + --nf: 214 20% 14%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 259 94% 51%; + --pc: 259 96% 91%; + --s: 314 100% 47%; + --sc: 314 100% 91%; + --a: 174 75% 46%; + --ac: 174 75% 11%; + --n: 214 20% 21%; + --nc: 212 19% 87%; + --b1: 0 0% 100%; + --b2: 0 0% 95%; + --b3: 180 2% 90%; + --bc: 215 28% 17% +}.pointer-events-none{pointer-events:none;}.visible{visibility:visible;}.absolute{position:absolute;}.fixed{position:fixed;}.relative{position:relative;}.left-0{left:0;}.left-2{left:0.5rem;}.right-2{right:0.5rem;}.right-2\.5{right:0.625rem;}.right-3{right:0.75rem;}.top-1\.5{top:0.375rem;}.top-1\/2{top:50%;}.top-2{top:0.5rem;}.top-3{top:0.75rem;}.top-full{top:100%;}.line-clamp-1{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:1;line-clamp:1;}.z-\[50\],.z-50{z-index:50;}.grid{display:grid;}.col-span-2{grid-column:span 2/span 2;}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr));}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr));}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr));}.mx-1{margin-left:0.25rem;margin-right:0.25rem;}.mx-auto{margin-left:auto;margin-right:auto;}.my-0{margin-top:0;margin-bottom:0;}.my-0\.5{margin-top:0.125rem;margin-bottom:0.125rem;}.-mt-1{margin-top:-0.25rem;}.mb-0\.5{margin-bottom:0.125rem;}.mb-1{margin-bottom:0.25rem;}.mb-1\.5{margin-bottom:0.375rem;}.mb-2{margin-bottom:0.5rem;}.mb-3{margin-bottom:0.75rem;}.mb-4{margin-bottom:1rem;}.mb-5{margin-bottom:1.25rem;}.mb-6{margin-bottom:1.5rem;}.mb-8{margin-bottom:2rem;}.ml-1{margin-left:0.25rem;}.ml-2{margin-left:0.5rem;}.ml-auto{margin-left:auto;}.mr-1{margin-right:0.25rem;}.mt-0\.5{margin-top:0.125rem;}.mt-1{margin-top:0.25rem;}.mt-1\.5{margin-top:0.375rem;}.mt-2{margin-top:0.5rem;}.mt-3{margin-top:0.75rem;}.mt-4{margin-top:1rem;}.mt-6{margin-top:1.5rem;}.mt-8{margin-top:2rem;}.inline{display:inline;}.block{display:block;}.inline-block{display:inline-block;}.hidden{display:none;}.h-\[calc\(100vh-196px\)\]{height:calc(100vh - 196px);}.h-\[calc\(100vh-200px\)\]{height:calc(100vh - 200px);}.h-1{height:0.25rem;}.h-1\.5{height:0.375rem;}.h-10{height:2.5rem;}.h-11{height:2.75rem;}.h-12{height:3rem;}.h-14{height:3.5rem;}.h-2\.5{height:0.625rem;}.h-3{height:0.75rem;}.h-3\.5{height:0.875rem;}.h-4{height:1rem;}.h-48{height:12rem;}.h-5{height:1.25rem;}.h-9{height:2.25rem;}.max-h-\[70vh\]{max-height:70vh;}.max-h-48{max-height:12rem;}.max-h-56{max-height:14rem;}.max-h-64{max-height:16rem;}.max-w-\[12rem\]{max-width:12rem;}.max-w-\[8rem\]{max-width:8rem;}.max-w-\[9rem\]{max-width:9rem;}.max-w-2xl{max-width:42rem;}.max-w-3xl{max-width:48rem;}.max-w-7xl{max-width:80rem;}.max-w-lg{max-width:32rem;}.max-w-sm{max-width:24rem;}.max-w-xl{max-width:36rem;}.max-w-xs{max-width:20rem;}.min-h-\[400px\]{min-height:400px;}.min-h-\[480px\]{min-height:480px;}.min-h-0{min-height:0;}.min-h-screen{min-height:100vh;}.min-w-\[200px\]{min-width:200px;}.min-w-0{min-width:0;}.min-w-48{min-width:12rem;}.w-1{width:0.25rem;}.w-1\.5{width:0.375rem;}.w-10{width:2.5rem;}.w-11\/12{width:91.6666666667%;}.w-12{width:3rem;}.w-16{width:4rem;}.w-2\.5{width:0.625rem;}.w-20{width:5rem;}.w-24{width:6rem;}.w-3{width:0.75rem;}.w-3\.5{width:0.875rem;}.w-4{width:1rem;}.w-40{width:10rem;}.w-44{width:11rem;}.w-48{width:12rem;}.w-5{width:1.25rem;}.w-56{width:14rem;}.w-64{width:16rem;}.w-9{width:2.25rem;}.w-full{width:100%;}.w-px{width:1px;}.focus\:w-64:focus{width:16rem;}.flex{display:flex;}.flex-1{flex:1 1 0%;}.flex-shrink{flex-shrink:1;}.flex-shrink-0,.shrink-0{flex-shrink:0;}.flex-col{flex-direction:column;}.flex-wrap{flex-wrap:wrap;}.table{display:table;}.-translate-y-1\/2{--un-translate-y:-50%;transform:translateX(var(--un-translate-x)) translateY(var(--un-translate-y)) translateZ(var(--un-translate-z)) rotate(var(--un-rotate)) rotateX(var(--un-rotate-x)) rotateY(var(--un-rotate-y)) rotateZ(var(--un-rotate-z)) skewX(var(--un-skew-x)) skewY(var(--un-skew-y)) scaleX(var(--un-scale-x)) scaleY(var(--un-scale-y)) scaleZ(var(--un-scale-z));}.transform{transform:translateX(var(--un-translate-x)) translateY(var(--un-translate-y)) translateZ(var(--un-translate-z)) rotate(var(--un-rotate)) rotateX(var(--un-rotate-x)) rotateY(var(--un-rotate-y)) rotateZ(var(--un-rotate-z)) skewX(var(--un-skew-x)) skewY(var(--un-skew-y)) scaleX(var(--un-scale-x)) scaleY(var(--un-scale-y)) scaleZ(var(--un-scale-z));}.cursor-default{cursor:default;}.cursor-pointer{cursor:pointer;}.cursor-col-resize{cursor:col-resize;}.select-none{-webkit-user-select:none;user-select:none;}.resize-y{resize:vertical;}.resize-none{resize:none;}.list-disc{list-style-type:disc;}.items-start{align-items:flex-start;}.items-end{align-items:flex-end;}.items-center{align-items:center;}.items-baseline{align-items:baseline;}.self-start{align-self:flex-start;}.self-center{align-self:center;}.justify-end{justify-content:flex-end;}.justify-center{justify-content:center;}.justify-between{justify-content:space-between;}.gap-0{gap:0;}.gap-0\.5{gap:0.125rem;}.gap-1{gap:0.25rem;}.gap-1\.5{gap:0.375rem;}.gap-2{gap:0.5rem;}.gap-3{gap:0.75rem;}.gap-4{gap:1rem;}.gap-x-2{column-gap:0.5rem;}.gap-x-6{column-gap:1.5rem;}.gap-y-1{row-gap:0.25rem;}.gap-y-1\.5{row-gap:0.375rem;}.space-y-0\.5>:not([hidden])~:not([hidden]){--un-space-y-reverse:0;margin-top:calc(0.125rem * calc(1 - var(--un-space-y-reverse)));margin-bottom:calc(0.125rem * var(--un-space-y-reverse));}.space-y-1\.5>:not([hidden])~:not([hidden]){--un-space-y-reverse:0;margin-top:calc(0.375rem * calc(1 - var(--un-space-y-reverse)));margin-bottom:calc(0.375rem * var(--un-space-y-reverse));}.space-y-1>:not([hidden])~:not([hidden]){--un-space-y-reverse:0;margin-top:calc(0.25rem * calc(1 - var(--un-space-y-reverse)));margin-bottom:calc(0.25rem * var(--un-space-y-reverse));}.space-y-2\.5>:not([hidden])~:not([hidden]){--un-space-y-reverse:0;margin-top:calc(0.625rem * calc(1 - var(--un-space-y-reverse)));margin-bottom:calc(0.625rem * var(--un-space-y-reverse));}.space-y-2>:not([hidden])~:not([hidden]){--un-space-y-reverse:0;margin-top:calc(0.5rem * calc(1 - var(--un-space-y-reverse)));margin-bottom:calc(0.5rem * var(--un-space-y-reverse));}.space-y-3>:not([hidden])~:not([hidden]){--un-space-y-reverse:0;margin-top:calc(0.75rem * calc(1 - var(--un-space-y-reverse)));margin-bottom:calc(0.75rem * var(--un-space-y-reverse));}.space-y-4>:not([hidden])~:not([hidden]){--un-space-y-reverse:0;margin-top:calc(1rem * calc(1 - var(--un-space-y-reverse)));margin-bottom:calc(1rem * var(--un-space-y-reverse));}.space-y-6>:not([hidden])~:not([hidden]){--un-space-y-reverse:0;margin-top:calc(1.5rem * calc(1 - var(--un-space-y-reverse)));margin-bottom:calc(1.5rem * var(--un-space-y-reverse));}.divide-y>:not([hidden])~:not([hidden]){--un-divide-y-reverse:0;border-top-width:calc(1px * calc(1 - var(--un-divide-y-reverse)));border-bottom-width:calc(1px * var(--un-divide-y-reverse));}.divide-base-content\/10>:not([hidden])~:not([hidden]){border-color:hsl(var(--bc) / 0.1) /* hsl(var(--bc) / ) */;}.overflow-auto{overflow:auto;}.overflow-hidden{overflow:hidden;}.overflow-x-auto{overflow-x:auto;}.overflow-y-auto{overflow-y:auto;}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.whitespace-nowrap{white-space:nowrap;}.whitespace-pre-wrap{white-space:pre-wrap;}.break-all{word-break:break-all;}.b,.border{border-width:1px;}.border-b{border-bottom-width:1px;}.border-l-2{border-left-width:2px;}.border-t{border-top-width:1px;}.border-base-300{--un-border-opacity:1;border-color:hsl(var(--b3) / var(--un-border-opacity));}.border-base-content\/10{border-color:hsl(var(--bc) / 0.1);}.border-base-content\/15{border-color:hsl(var(--bc) / 0.15);}.border-primary\/20{border-color:hsl(var(--p) / 0.2);}.border-warning\/30{border-color:hsl(var(--wa) / 0.3);}.hover\:border-base-content\/20:hover{border-color:hsl(var(--bc) / 0.2);}.hover\:border-primary\/40:hover{border-color:hsl(var(--p) / 0.4);}.rounded{border-radius:0.25rem;}.rounded-full{border-radius:9999px;}.rounded-lg{border-radius:0.5rem;}.rounded-sm{border-radius:0.125rem;}.rounded-l-lg{border-top-left-radius:0.5rem;border-bottom-left-radius:0.5rem;}.rounded-r-lg{border-top-right-radius:0.5rem;border-bottom-right-radius:0.5rem;}.border-none{border-style:none;}.bg-base-100{--un-bg-opacity:1;background-color:hsl(var(--b1) / var(--un-bg-opacity)) /* hsl(var(--b1) / ) */;}.bg-base-200{--un-bg-opacity:1;background-color:hsl(var(--b2) / var(--un-bg-opacity)) /* hsl(var(--b2) / ) */;}.bg-base-300{--un-bg-opacity:1;background-color:hsl(var(--b3) / var(--un-bg-opacity)) /* hsl(var(--b3) / ) */;}.bg-base-300\/40{background-color:hsl(var(--b3) / 0.4) /* hsl(var(--b3) / ) */;}.bg-base-content\/15{background-color:hsl(var(--bc) / 0.15) /* hsl(var(--bc) / ) */;}.bg-base-content\/20{background-color:hsl(var(--bc) / 0.2) /* hsl(var(--bc) / ) */;}.bg-current{background-color:currentColor /* currentColor */;}.bg-error{--un-bg-opacity:1;background-color:hsl(var(--er) / var(--un-bg-opacity)) /* hsl(var(--er) / ) */;}.bg-primary{--un-bg-opacity:1;background-color:hsl(var(--p) / var(--un-bg-opacity)) /* hsl(var(--p) / ) */;}.bg-primary\/10{background-color:hsl(var(--p) / 0.1) /* hsl(var(--p) / ) */;}.bg-success{--un-bg-opacity:1;background-color:hsl(var(--su) / var(--un-bg-opacity)) /* hsl(var(--su) / ) */;}.bg-transparent{background-color:transparent /* transparent */;}.bg-warning{--un-bg-opacity:1;background-color:hsl(var(--wa) / var(--un-bg-opacity)) /* hsl(var(--wa) / ) */;}.bg-warning\/10{background-color:hsl(var(--wa) / 0.1) /* hsl(var(--wa) / ) */;}.hover\:bg-base-200\/50:hover{background-color:hsl(var(--b2) / 0.5) /* hsl(var(--b2) / ) */;}.hover\:bg-base-300:hover{--un-bg-opacity:1;background-color:hsl(var(--b3) / var(--un-bg-opacity)) /* hsl(var(--b3) / ) */;}.hover\:bg-primary\/20:hover{background-color:hsl(var(--p) / 0.2) /* hsl(var(--p) / ) */;}.hover\:bg-primary\/40:hover{background-color:hsl(var(--p) / 0.4) /* hsl(var(--p) / ) */;}.object-contain{object-fit:contain;}.object-left{object-position:left;}.p-0{padding:0;}.p-1{padding:0.25rem;}.p-1\.5{padding:0.375rem;}.p-2{padding:0.5rem;}.p-3{padding:0.75rem;}.p-4{padding:1rem;}.p-5{padding:1.25rem;}.p-6{padding:1.5rem;}.px,.px-4{padding-left:1rem;padding-right:1rem;}.px-0{padding-left:0;padding-right:0;}.px-1{padding-left:0.25rem;padding-right:0.25rem;}.px-1\.5{padding-left:0.375rem;padding-right:0.375rem;}.px-2{padding-left:0.5rem;padding-right:0.5rem;}.px-3{padding-left:0.75rem;padding-right:0.75rem;}.px-5{padding-left:1.25rem;padding-right:1.25rem;}.py-0{padding-top:0;padding-bottom:0;}.py-0\.5{padding-top:0.125rem;padding-bottom:0.125rem;}.py-1{padding-top:0.25rem;padding-bottom:0.25rem;}.py-1\.5{padding-top:0.375rem;padding-bottom:0.375rem;}.py-10{padding-top:2.5rem;padding-bottom:2.5rem;}.py-16{padding-top:4rem;padding-bottom:4rem;}.py-2{padding-top:0.5rem;padding-bottom:0.5rem;}.py-2\.5{padding-top:0.625rem;padding-bottom:0.625rem;}.py-3{padding-top:0.75rem;padding-bottom:0.75rem;}.py-4{padding-top:1rem;padding-bottom:1rem;}.py-6{padding-top:1.5rem;padding-bottom:1.5rem;}.py-8{padding-top:2rem;padding-bottom:2rem;}.pb-1{padding-bottom:0.25rem;}.pb-3{padding-bottom:0.75rem;}.pl-2{padding-left:0.5rem;}.pl-3{padding-left:0.75rem;}.pl-4{padding-left:1rem;}.pl-8{padding-left:2rem;}.pr-1{padding-right:0.25rem;}.pr-14{padding-right:3.5rem;}.pr-8{padding-right:2rem;}.pt-1{padding-top:0.25rem;}.pt-16{padding-top:4rem;}.pt-2{padding-top:0.5rem;}.pt-3{padding-top:0.75rem;}.text-center{text-align:center;}.text-left{text-align:left;}.text-right{text-align:right;}.text-wrap{text-wrap:wrap;}.text-\[10px\]{font-size:10px;}.text-\[11px\]{font-size:11px;}.text-2xl{font-size:1.5rem;line-height:2rem;}.text-base{font-size:1rem;line-height:1.5rem;}.text-lg{font-size:1.125rem;line-height:1.75rem;}.text-sm{font-size:0.875rem;line-height:1.25rem;}.text-xl{font-size:1.25rem;line-height:1.75rem;}.text-xs{font-size:0.75rem;line-height:1rem;}.text-accent{--un-text-opacity:1;color:hsl(var(--a) / var(--un-text-opacity)) /* hsl(var(--a) / ) */;}.text-base-content{--un-text-opacity:1;color:hsl(var(--bc) / var(--un-text-opacity)) /* hsl(var(--bc) / ) */;}.text-base-content\/20{color:hsl(var(--bc) / 0.2) /* hsl(var(--bc) / ) */;}.text-base-content\/25{color:hsl(var(--bc) / 0.25) /* hsl(var(--bc) / ) */;}.text-base-content\/30{color:hsl(var(--bc) / 0.3) /* hsl(var(--bc) / ) */;}.text-base-content\/35{color:hsl(var(--bc) / 0.35) /* hsl(var(--bc) / ) */;}.text-base-content\/40{color:hsl(var(--bc) / 0.4) /* hsl(var(--bc) / ) */;}.text-base-content\/50{color:hsl(var(--bc) / 0.5) /* hsl(var(--bc) / ) */;}.text-base-content\/60{color:hsl(var(--bc) / 0.6) /* hsl(var(--bc) / ) */;}.text-base-content\/70{color:hsl(var(--bc) / 0.7) /* hsl(var(--bc) / ) */;}.text-base-content\/80{color:hsl(var(--bc) / 0.8) /* hsl(var(--bc) / ) */;}.text-cyan-400{--un-text-opacity:1;color:rgb(34 211 238 / var(--un-text-opacity)) /* #22d3ee */;}.text-error{--un-text-opacity:1;color:hsl(var(--er) / var(--un-text-opacity)) /* hsl(var(--er) / ) */;}.text-error\/70{color:hsl(var(--er) / 0.7) /* hsl(var(--er) / ) */;}.text-info{--un-text-opacity:1;color:hsl(var(--in) / var(--un-text-opacity)) /* hsl(var(--in) / ) */;}.text-orange-400{--un-text-opacity:1;color:rgb(251 146 60 / var(--un-text-opacity)) /* #fb923c */;}.text-primary{--un-text-opacity:1;color:hsl(var(--p) / var(--un-text-opacity)) /* hsl(var(--p) / ) */;}.text-purple-400{--un-text-opacity:1;color:rgb(192 132 252 / var(--un-text-opacity)) /* #c084fc */;}.text-secondary{--un-text-opacity:1;color:hsl(var(--s) / var(--un-text-opacity)) /* hsl(var(--s) / ) */;}.text-success{--un-text-opacity:1;color:hsl(var(--su) / var(--un-text-opacity)) /* hsl(var(--su) / ) */;}.text-warning{--un-text-opacity:1;color:hsl(var(--wa) / var(--un-text-opacity)) /* hsl(var(--wa) / ) */;}.text-yellow-400{--un-text-opacity:1;color:rgb(250 204 21 / var(--un-text-opacity)) /* #facc15 */;}.hover\:text-base-content:hover{--un-text-opacity:1;color:hsl(var(--bc) / var(--un-text-opacity)) /* hsl(var(--bc) / ) */;}.hover\:text-base-content\/80:hover{color:hsl(var(--bc) / 0.8) /* hsl(var(--bc) / ) */;}.hover\:text-error:hover{--un-text-opacity:1;color:hsl(var(--er) / var(--un-text-opacity)) /* hsl(var(--er) / ) */;}.hover\:text-primary:hover{--un-text-opacity:1;color:hsl(var(--p) / var(--un-text-opacity)) /* hsl(var(--p) / ) */;}.hover\:text-warning:hover{--un-text-opacity:1;color:hsl(var(--wa) / var(--un-text-opacity)) /* hsl(var(--wa) / ) */;}.font-bold{font-weight:700;}.font-medium{font-weight:500;}.font-normal{font-weight:400;}.font-semibold{font-weight:600;}.leading-relaxed{line-height:1.625;}.leading-snug{line-height:1.375;}.leading-tight{line-height:1.25;}.tracking-tight{letter-spacing:-0.025em;}.tracking-wide{letter-spacing:0.025em;}.tracking-wider{letter-spacing:0.05em;}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;}.uppercase{text-transform:uppercase;}.normal-case{text-transform:none;}.italic{font-style:italic;}.line-through{text-decoration-line:line-through;}.hover\:underline:hover{text-decoration-line:underline;}.underline-offset-2{text-underline-offset:2px;}.opacity-20{opacity:0.2;}.opacity-30{opacity:0.3;}.opacity-40{opacity:0.4;}.opacity-50{opacity:0.5;}.opacity-60{opacity:0.6;}.opacity-70{opacity:0.7;}.hover\:opacity-100:hover{opacity:1;}.shadow{--un-shadow:var(--un-shadow-inset) 0 1px 3px 0 var(--un-shadow-color, rgb(0 0 0 / 0.1)),var(--un-shadow-inset) 0 1px 2px -1px var(--un-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}.shadow-lg{--un-shadow:var(--un-shadow-inset) 0 10px 15px -3px var(--un-shadow-color, rgb(0 0 0 / 0.1)),var(--un-shadow-inset) 0 4px 6px -4px var(--un-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}.shadow-xl{--un-shadow:var(--un-shadow-inset) 0 20px 25px -5px var(--un-shadow-color, rgb(0 0 0 / 0.1)),var(--un-shadow-inset) 0 8px 10px -6px var(--un-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}.filter{filter:var(--un-blur) var(--un-brightness) var(--un-contrast) var(--un-drop-shadow) var(--un-grayscale) var(--un-hue-rotate) var(--un-invert) var(--un-saturate) var(--un-sepia);}.active-filter:active{filter:var(--un-blur) var(--un-brightness) var(--un-contrast) var(--un-drop-shadow) var(--un-grayscale) var(--un-hue-rotate) var(--un-invert) var(--un-saturate) var(--un-sepia);}.backdrop-filter{-webkit-backdrop-filter:var(--un-backdrop-blur) var(--un-backdrop-brightness) var(--un-backdrop-contrast) var(--un-backdrop-grayscale) var(--un-backdrop-hue-rotate) var(--un-backdrop-invert) var(--un-backdrop-opacity) var(--un-backdrop-saturate) var(--un-backdrop-sepia);backdrop-filter:var(--un-backdrop-blur) var(--un-backdrop-brightness) var(--un-backdrop-contrast) var(--un-backdrop-grayscale) var(--un-backdrop-hue-rotate) var(--un-backdrop-invert) var(--un-backdrop-opacity) var(--un-backdrop-saturate) var(--un-backdrop-sepia);}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transition-duration:150ms;}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transition-duration:150ms;}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transition-duration:150ms;}@media (min-width: 640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr));}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr));}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr));}.sm\:inline{display:inline;}.sm\:block{display:block;}.sm\:inline-flex{display:inline-flex;}.sm\:flex-row{flex-direction:row;}}@media (min-width: 768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr));}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr));}.md\:hidden{display:none;}.md\:flex{display:flex;}}@media (min-width: 1024px){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr));}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr));}} +/* Badge variant text — force black in dark mode for contrast. + DaisyUI's --inc/suc/wac/erc and --pc resolve to near-black but some browser + rendering paths fall back to #fff; this override is explicit and wins. */ +[data-theme=dark] .badge-success, +[data-theme=dark] .badge-info, +[data-theme=dark] .badge-warning, +[data-theme=dark] .badge-error { + color: #000000; +} +[data-theme=dark] .badge-primary { + color: #838383; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme=light]) .badge-success, + :root:not([data-theme=light]) .badge-info, + :root:not([data-theme=light]) .badge-warning, + :root:not([data-theme=light]) .badge-error { + color: #000000; + } + :root:not([data-theme=light]) .badge-primary { + color: #838383; + } +} diff --git a/crates/provisioning-daemon/ui/public/vendor/cytoscape.min.js b/crates/provisioning-daemon/ui/public/vendor/cytoscape.min.js new file mode 100644 index 0000000..ff0d7c1 --- /dev/null +++ b/crates/provisioning-daemon/ui/public/vendor/cytoscape.min.js @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2016-2024, The Cytoscape Consortium. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the “Software”), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).cytoscape=t()}(this,(function(){"use strict";function e(t){return(e="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(t)}function t(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function n(e,t){for(var n=0;ne.length)&&(t=e.length);for(var n=0,r=new Array(t);n=e.length?{done:!0}:{done:!1,value:e[r++]}},e:function(e){throw e},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var a,s=!0,l=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return s=e.done,e},e:function(e){l=!0,a=e},f:function(){try{s||null==n.return||n.return()}finally{if(l)throw a}}}}var u="undefined"==typeof window?null:window,c=u?u.navigator:null;u&&u.document;var d=e(""),h=e({}),p=e((function(){})),f="undefined"==typeof HTMLElement?"undefined":e(HTMLElement),g=function(e){return e&&e.instanceString&&y(e.instanceString)?e.instanceString():null},v=function(t){return null!=t&&e(t)==d},y=function(t){return null!=t&&e(t)===p},m=function(e){return!E(e)&&(Array.isArray?Array.isArray(e):null!=e&&e instanceof Array)},b=function(t){return null!=t&&e(t)===h&&!m(t)&&t.constructor===Object},x=function(t){return null!=t&&e(t)===e(1)&&!isNaN(t)},w=function(e){return"undefined"===f?void 0:null!=e&&e instanceof HTMLElement},E=function(e){return k(e)||C(e)},k=function(e){return"collection"===g(e)&&e._private.single},C=function(e){return"collection"===g(e)&&!e._private.single},S=function(e){return"core"===g(e)},P=function(e){return"stylesheet"===g(e)},D=function(e){return null==e||!(""!==e&&!e.match(/^\s+$/))},T=function(t){return function(t){return null!=t&&e(t)===h}(t)&&y(t.then)},_=function(e,t){t||(t=function(){if(1===arguments.length)return arguments[0];if(0===arguments.length)return"undefined";for(var e=[],t=0;tt?1:0},L=null!=Object.assign?Object.assign.bind(Object):function(e){for(var t=arguments,n=1;n255)return;t.push(Math.floor(a))}var o=r[1]||r[2]||r[3],s=r[1]&&r[2]&&r[3];if(o&&!s)return;var l=n[4];if(void 0!==l){if((l=parseFloat(l))<0||l>1)return;t.push(l)}}return t}(e)||function(e){var t,n,r,i,a,o,s,l;function u(e,t,n){return n<0&&(n+=1),n>1&&(n-=1),n<1/6?e+6*(t-e)*n:n<.5?t:n<2/3?e+(t-e)*(2/3-n)*6:e}var c=new RegExp("^hsl[a]?\\(((?:[-+]?(?:(?:\\d+|\\d*\\.\\d+)(?:[Ee][+-]?\\d+)?)))\\s*,\\s*((?:[-+]?(?:(?:\\d+|\\d*\\.\\d+)(?:[Ee][+-]?\\d+)?))[%])\\s*,\\s*((?:[-+]?(?:(?:\\d+|\\d*\\.\\d+)(?:[Ee][+-]?\\d+)?))[%])(?:\\s*,\\s*((?:[-+]?(?:(?:\\d+|\\d*\\.\\d+)(?:[Ee][+-]?\\d+)?))))?\\)$").exec(e);if(c){if((n=parseInt(c[1]))<0?n=(360- -1*n%360)%360:n>360&&(n%=360),n/=360,(r=parseFloat(c[2]))<0||r>100)return;if(r/=100,(i=parseFloat(c[3]))<0||i>100)return;if(i/=100,void 0!==(a=c[4])&&((a=parseFloat(a))<0||a>1))return;if(0===r)o=s=l=Math.round(255*i);else{var d=i<.5?i*(1+r):i+r-i*r,h=2*i-d;o=Math.round(255*u(h,d,n+1/3)),s=Math.round(255*u(h,d,n)),l=Math.round(255*u(h,d,n-1/3))}t=[o,s,l,a]}return t}(e)},R={transparent:[0,0,0,0],aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],grey:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]},V=function(e){for(var t=e.map,n=e.keys,r=n.length,i=0;i=t||n<0||d&&e-u>=a}function v(){var e=H();if(g(e))return y(e);s=setTimeout(v,function(e){var n=t-(e-l);return d?ge(n,a-(e-u)):n}(e))}function y(e){return s=void 0,h&&r?p(e):(r=i=void 0,o)}function m(){var e=H(),n=g(e);if(r=arguments,i=this,l=e,n){if(void 0===s)return f(l);if(d)return clearTimeout(s),s=setTimeout(v,t),p(l)}return void 0===s&&(s=setTimeout(v,t)),o}return t=pe(t)||0,j(n)&&(c=!!n.leading,a=(d="maxWait"in n)?fe(pe(n.maxWait)||0,t):a,h="trailing"in n?!!n.trailing:h),m.cancel=function(){void 0!==s&&clearTimeout(s),u=0,r=l=i=s=void 0},m.flush=function(){return void 0===s?o:y(H())},m},ye=u?u.performance:null,me=ye&&ye.now?function(){return ye.now()}:function(){return Date.now()},be=function(){if(u){if(u.requestAnimationFrame)return function(e){u.requestAnimationFrame(e)};if(u.mozRequestAnimationFrame)return function(e){u.mozRequestAnimationFrame(e)};if(u.webkitRequestAnimationFrame)return function(e){u.webkitRequestAnimationFrame(e)};if(u.msRequestAnimationFrame)return function(e){u.msRequestAnimationFrame(e)}}return function(e){e&&setTimeout((function(){e(me())}),1e3/60)}}(),xe=function(e){return be(e)},we=me,Ee=65599,ke=function(e){for(var t,n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:9261,r=n;!(t=e.next()).done;)r=r*Ee+t.value|0;return r},Ce=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:9261;return t*Ee+e|0},Se=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:5381;return(t<<5)+t+e|0},Pe=function(e){return 2097152*e[0]+e[1]},De=function(e,t){return[Ce(e[0],t[0]),Se(e[1],t[1])]},Te=function(e,t){var n={value:0,done:!1},r=0,i=e.length;return ke({next:function(){return r=0&&(e[r]!==t||(e.splice(r,1),!n));r--);},Ge=function(e){e.splice(0,e.length)},Ue=function(e,t,n){return n&&(t=N(n,t)),e[t]},Ze=function(e,t,n,r){n&&(t=N(n,t)),e[t]=r},$e="undefined"!=typeof Map?Map:function(){function e(){t(this,e),this._obj={}}return r(e,[{key:"set",value:function(e,t){return this._obj[e]=t,this}},{key:"delete",value:function(e){return this._obj[e]=void 0,this}},{key:"clear",value:function(){this._obj={}}},{key:"has",value:function(e){return void 0!==this._obj[e]}},{key:"get",value:function(e){return this._obj[e]}}]),e}(),Qe=function(){function e(n){if(t(this,e),this._obj=Object.create(null),this.size=0,null!=n){var r;r=null!=n.instanceString&&n.instanceString()===this.instanceString()?n.toArray():n;for(var i=0;i2&&void 0!==arguments[2])||arguments[2];if(void 0!==e&&void 0!==t&&S(e)){var r=t.group;if(null==r&&(r=t.data&&null!=t.data.source&&null!=t.data.target?"edges":"nodes"),"nodes"===r||"edges"===r){this.length=1,this[0]=this;var i=this._private={cy:e,single:!0,data:t.data||{},position:t.position||{x:0,y:0},autoWidth:void 0,autoHeight:void 0,autoPadding:void 0,compoundBoundsClean:!1,listeners:[],group:r,style:{},rstyle:{},styleCxts:[],styleKeys:{},removed:!0,selected:!!t.selected,selectable:void 0===t.selectable||!!t.selectable,locked:!!t.locked,grabbed:!1,grabbable:void 0===t.grabbable||!!t.grabbable,pannable:void 0===t.pannable?"edges"===r:!!t.pannable,active:!1,classes:new Je,animation:{current:[],queue:[]},rscratch:{},scratch:t.scratch||{},edges:[],children:[],parent:t.parent&&t.parent.isNode()?t.parent:null,traversalCache:{},backgrounding:!1,bbCache:null,bbCacheShift:{x:0,y:0},bodyBounds:null,overlayBounds:null,labelBounds:{all:null,source:null,target:null,main:null},arrowBounds:{source:null,target:null,"mid-source":null,"mid-target":null}};if(null==i.position.x&&(i.position.x=0),null==i.position.y&&(i.position.y=0),t.renderedPosition){var a=t.renderedPosition,o=e.pan(),s=e.zoom();i.position={x:(a.x-o.x)/s,y:(a.y-o.y)/s}}var l=[];m(t.classes)?l=t.classes:v(t.classes)&&(l=t.classes.split(/\s+/));for(var u=0,c=l.length;ut?1:0},u=function(e,t,i,a,o){var s;if(null==i&&(i=0),null==o&&(o=n),i<0)throw new Error("lo must be non-negative");for(null==a&&(a=e.length);in;0<=n?t++:t--)u.push(t);return u}.apply(this).reverse()).length;ag;0<=g?++h:--h)v.push(a(e,r));return v},f=function(e,t,r,i){var a,o,s;for(null==i&&(i=n),a=e[r];r>t&&i(a,o=e[s=r-1>>1])<0;)e[r]=o,r=s;return e[r]=a},g=function(e,t,r){var i,a,o,s,l;for(null==r&&(r=n),a=e.length,l=t,o=e[t],i=2*t+1;i0;){var k=m.pop(),C=g(k),S=k.id();if(d[S]=C,C!==1/0)for(var P=k.neighborhood().intersect(p),D=0;D0)for(n.unshift(t);c[i];){var a=c[i];n.unshift(a.edge),n.unshift(a.node),i=(r=a.node).id()}return o.spawn(n)}}}},ot={kruskal:function(e){e=e||function(e){return 1};for(var t=this.byGroup(),n=t.nodes,r=t.edges,i=n.length,a=new Array(i),o=n,s=function(e){for(var t=0;t0;){if(l=g.pop(),u=l.id(),v.delete(u),w++,u===d){for(var E=[],k=i,C=d,S=m[C];E.unshift(k),null!=S&&E.unshift(S),null!=(k=y[C]);)S=m[C=k.id()];return{found:!0,distance:h[u],path:this.spawn(E),steps:w}}f[u]=!0;for(var P=l._private.edges,D=0;DD&&(p[P]=D,m[P]=S,b[P]=w),!i){var T=S*u+C;!i&&p[T]>D&&(p[T]=D,m[T]=C,b[T]=w)}}}for(var _=0;_1&&void 0!==arguments[1]?arguments[1]:a,r=b(e),i=[],o=r;;){if(null==o)return t.spawn();var l=m(o),u=l.edge,c=l.pred;if(i.unshift(o[0]),o.same(n)&&i.length>0)break;null!=u&&i.unshift(u),o=c}return s.spawn(i)},hasNegativeWeightCycle:f,negativeWeightCycles:g}}},pt=Math.sqrt(2),ft=function(e,t,n){0===n.length&&Ve("Karger-Stein must be run on a connected (sub)graph");for(var r=n[e],i=r[1],a=r[2],o=t[i],s=t[a],l=n,u=l.length-1;u>=0;u--){var c=l[u],d=c[1],h=c[2];(t[d]===o&&t[h]===s||t[d]===s&&t[h]===o)&&l.splice(u,1)}for(var p=0;pr;){var i=Math.floor(Math.random()*t.length);t=ft(i,e,t),n--}return t},vt={kargerStein:function(){var e=this,t=this.byGroup(),n=t.nodes,r=t.edges;r.unmergeBy((function(e){return e.isLoop()}));var i=n.length,a=r.length,o=Math.ceil(Math.pow(Math.log(i)/Math.LN2,2)),s=Math.floor(i/pt);if(!(i<2)){for(var l=[],u=0;u0?1:e<0?-1:0},kt=function(e,t){return Math.sqrt(Ct(e,t))},Ct=function(e,t){var n=t.x-e.x,r=t.y-e.y;return n*n+r*r},St=function(e){for(var t=e.length,n=0,r=0;r=e.x1&&e.y2>=e.y1)return{x1:e.x1,y1:e.y1,x2:e.x2,y2:e.y2,w:e.x2-e.x1,h:e.y2-e.y1};if(null!=e.w&&null!=e.h&&e.w>=0&&e.h>=0)return{x1:e.x1,y1:e.y1,x2:e.x1+e.w,y2:e.y1+e.h,w:e.w,h:e.h}}},Mt=function(e,t){e.x1=Math.min(e.x1,t.x1),e.x2=Math.max(e.x2,t.x2),e.w=e.x2-e.x1,e.y1=Math.min(e.y1,t.y1),e.y2=Math.max(e.y2,t.y2),e.h=e.y2-e.y1},Bt=function(e,t,n){e.x1=Math.min(e.x1,t),e.x2=Math.max(e.x2,t),e.w=e.x2-e.x1,e.y1=Math.min(e.y1,n),e.y2=Math.max(e.y2,n),e.h=e.y2-e.y1},Nt=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0;return e.x1-=t,e.x2+=t,e.y1-=t,e.y2+=t,e.w=e.x2-e.x1,e.h=e.y2-e.y1,e},zt=function(e){var t,n,r,i,o=arguments.length>1&&void 0!==arguments[1]?arguments[1]:[0];if(1===o.length)t=n=r=i=o[0];else if(2===o.length)t=r=o[0],i=n=o[1];else if(4===o.length){var s=a(o,4);t=s[0],n=s[1],r=s[2],i=s[3]}return e.x1-=i,e.x2+=n,e.y1-=t,e.y2+=r,e.w=e.x2-e.x1,e.h=e.y2-e.y1,e},It=function(e,t){e.x1=t.x1,e.y1=t.y1,e.x2=t.x2,e.y2=t.y2,e.w=e.x2-e.x1,e.h=e.y2-e.y1},At=function(e,t){return!(e.x1>t.x2)&&(!(t.x1>e.x2)&&(!(e.x2t.y2)&&!(t.y1>e.y2)))))))},Lt=function(e,t,n){return e.x1<=t&&t<=e.x2&&e.y1<=n&&n<=e.y2},Ot=function(e,t){return Lt(e,t.x1,t.y1)&&Lt(e,t.x2,t.y2)},Rt=function(e,t,n,r,i,a,o){var s,l,u=arguments.length>7&&void 0!==arguments[7]?arguments[7]:"auto",c="auto"===u?nn(i,a):u,d=i/2,h=a/2,p=(c=Math.min(c,d,h))!==d,f=c!==h;if(p){var g=n-d+c-o,v=r-h-o,y=n+d-c+o,m=v;if((s=Zt(e,t,n,r,g,v,y,m,!1)).length>0)return s}if(f){var b=n+d+o,x=r-h+c-o,w=b,E=r+h-c+o;if((s=Zt(e,t,n,r,b,x,w,E,!1)).length>0)return s}if(p){var k=n-d+c-o,C=r+h+o,S=n+d-c+o,P=C;if((s=Zt(e,t,n,r,k,C,S,P,!1)).length>0)return s}if(f){var D=n-d-o,T=r-h+c-o,_=D,M=r+h-c+o;if((s=Zt(e,t,n,r,D,T,_,M,!1)).length>0)return s}var B=n-d+c,N=r-h+c;if((l=Gt(e,t,n,r,B,N,c+o)).length>0&&l[0]<=B&&l[1]<=N)return[l[0],l[1]];var z=n+d-c,I=r-h+c;if((l=Gt(e,t,n,r,z,I,c+o)).length>0&&l[0]>=z&&l[1]<=I)return[l[0],l[1]];var A=n+d-c,L=r+h-c;if((l=Gt(e,t,n,r,A,L,c+o)).length>0&&l[0]>=A&&l[1]>=L)return[l[0],l[1]];var O=n-d+c,R=r+h-c;return(l=Gt(e,t,n,r,O,R,c+o)).length>0&&l[0]<=O&&l[1]>=R?[l[0],l[1]]:[]},Vt=function(e,t,n,r,i,a,o){var s=o,l=Math.min(n,i),u=Math.max(n,i),c=Math.min(r,a),d=Math.max(r,a);return l-s<=e&&e<=u+s&&c-s<=t&&t<=d+s},Ft=function(e,t,n,r,i,a,o,s,l){var u=Math.min(n,o,i)-l,c=Math.max(n,o,i)+l,d=Math.min(r,s,a)-l,h=Math.max(r,s,a)+l;return!(ec||th)},jt=function(e,t,n,r,i,a,o,s){var l=[];!function(e,t,n,r,i){var a,o,s,l,u,c,d,h;0===e&&(e=1e-5),s=-27*(r/=e)+(t/=e)*(9*(n/=e)-t*t*2),a=(o=(3*n-t*t)/9)*o*o+(s/=54)*s,i[1]=0,d=t/3,a>0?(u=(u=s+Math.sqrt(a))<0?-Math.pow(-u,1/3):Math.pow(u,1/3),c=(c=s-Math.sqrt(a))<0?-Math.pow(-c,1/3):Math.pow(c,1/3),i[0]=-d+u+c,d+=(u+c)/2,i[4]=i[2]=-d,d=Math.sqrt(3)*(-c+u)/2,i[3]=d,i[5]=-d):(i[5]=i[3]=0,0===a?(h=s<0?-Math.pow(-s,1/3):Math.pow(s,1/3),i[0]=2*h-d,i[4]=i[2]=-(h+d)):(l=(o=-o)*o*o,l=Math.acos(s/Math.sqrt(l)),h=2*Math.sqrt(o),i[0]=-d+h*Math.cos(l/3),i[2]=-d+h*Math.cos((l+2*Math.PI)/3),i[4]=-d+h*Math.cos((l+4*Math.PI)/3)))}(1*n*n-4*n*i+2*n*o+4*i*i-4*i*o+o*o+r*r-4*r*a+2*r*s+4*a*a-4*a*s+s*s,9*n*i-3*n*n-3*n*o-6*i*i+3*i*o+9*r*a-3*r*r-3*r*s-6*a*a+3*a*s,3*n*n-6*n*i+n*o-n*e+2*i*i+2*i*e-o*e+3*r*r-6*r*a+r*s-r*t+2*a*a+2*a*t-s*t,1*n*i-n*n+n*e-i*e+r*a-r*r+r*t-a*t,l);for(var u=[],c=0;c<6;c+=2)Math.abs(l[c+1])<1e-7&&l[c]>=0&&l[c]<=1&&u.push(l[c]);u.push(1),u.push(0);for(var d,h,p,f=-1,g=0;g=0?pl?(e-i)*(e-i)+(t-a)*(t-a):u-d},Yt=function(e,t,n){for(var r,i,a,o,s=0,l=0;l=e&&e>=a||r<=e&&e<=a))continue;(e-r)/(a-r)*(o-i)+i>t&&s++}return s%2!=0},Xt=function(e,t,n,r,i,a,o,s,l){var u,c=new Array(n.length);null!=s[0]?(u=Math.atan(s[1]/s[0]),s[0]<0?u+=Math.PI/2:u=-u-Math.PI/2):u=s;for(var d,h=Math.cos(-u),p=Math.sin(-u),f=0;f0){var g=Ht(c,-l);d=Wt(g)}else d=c;return Yt(e,t,d)},Wt=function(e){for(var t,n,r,i,a,o,s,l,u=new Array(e.length/2),c=0;c=0&&f<=1&&v.push(f),g>=0&&g<=1&&v.push(g),0===v.length)return[];var y=v[0]*s[0]+e,m=v[0]*s[1]+t;return v.length>1?v[0]==v[1]?[y,m]:[y,m,v[1]*s[0]+e,v[1]*s[1]+t]:[y,m]},Ut=function(e,t,n){return t<=e&&e<=n||n<=e&&e<=t?e:e<=t&&t<=n||n<=t&&t<=e?t:n},Zt=function(e,t,n,r,i,a,o,s,l){var u=e-i,c=n-e,d=o-i,h=t-a,p=r-t,f=s-a,g=d*h-f*u,v=c*h-p*u,y=f*c-d*p;if(0!==y){var m=g/y,b=v/y;return-.001<=m&&m<=1.001&&-.001<=b&&b<=1.001||l?[e+m*c,t+m*p]:[]}return 0===g||0===v?Ut(e,n,o)===o?[o,s]:Ut(e,n,i)===i?[i,a]:Ut(i,o,n)===n?[n,r]:[]:[]},$t=function(e,t,n,r,i,a,o,s){var l,u,c,d,h,p,f=[],g=new Array(n.length),v=!0;if(null==a&&(v=!1),v){for(var y=0;y0){var m=Ht(g,-s);u=Wt(m)}else u=g}else u=n;for(var b=0;bu&&(u=t)},d=function(e){return l[e]},h=0;h0?b.edgesTo(m)[0]:m.edgesTo(b)[0];var w=r(x);m=m.id(),h[m]>h[v]+w&&(h[m]=h[v]+w,p.nodes.indexOf(m)<0?p.push(m):p.updateItem(m),u[m]=0,l[m]=[]),h[m]==h[v]+w&&(u[m]=u[m]+u[v],l[m].push(v))}else for(var E=0;E0;){for(var P=n.pop(),D=0;D0&&o.push(n[s]);0!==o.length&&i.push(r.collection(o))}return i}(c,l,t,r);return b=function(e){for(var t=0;t5&&void 0!==arguments[5]?arguments[5]:Cn,o=r,s=0;s=2?Mn(e,t,n,0,Dn,Tn):Mn(e,t,n,0,Pn)},squaredEuclidean:function(e,t,n){return Mn(e,t,n,0,Dn)},manhattan:function(e,t,n){return Mn(e,t,n,0,Pn)},max:function(e,t,n){return Mn(e,t,n,-1/0,_n)}};function Nn(e,t,n,r,i,a){var o;return o=y(e)?e:Bn[e]||Bn.euclidean,0===t&&y(e)?o(i,a):o(t,n,r,i,a)}Bn["squared-euclidean"]=Bn.squaredEuclidean,Bn.squaredeuclidean=Bn.squaredEuclidean;var zn=He({k:2,m:2,sensitivityThreshold:1e-4,distance:"euclidean",maxIterations:10,attributes:[],testMode:!1,testCentroids:null}),In=function(e){return zn(e)},An=function(e,t,n,r,i){var a="kMedoids"!==i?function(e){return n[e]}:function(e){return r[e](n)},o=n,s=t;return Nn(e,r.length,a,(function(e){return r[e](t)}),o,s)},Ln=function(e,t,n){for(var r=n.length,i=new Array(r),a=new Array(r),o=new Array(t),s=null,l=0;ln)return!1}return!0},jn=function(e,t,n){for(var r=0;ri&&(i=t[l][u],a=u);o[a].push(e[l])}for(var c=0;c=i.threshold||"dendrogram"===i.mode&&1===e.length)return!1;var p,f=t[o],g=t[r[o]];p="dendrogram"===i.mode?{left:f,right:g,key:f.key}:{value:f.value.concat(g.value),key:f.key},e[f.index]=p,e.splice(g.index,1),t[f.key]=p;for(var v=0;vn[g.key][y.key]&&(a=n[g.key][y.key])):"max"===i.linkage?(a=n[f.key][y.key],n[f.key][y.key]1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:e.length,r=!(arguments.length>3&&void 0!==arguments[3])||arguments[3],i=!(arguments.length>4&&void 0!==arguments[4])||arguments[4],a=!(arguments.length>5&&void 0!==arguments[5])||arguments[5];r?e=e.slice(t,n):(n0&&e.splice(0,t));for(var o=0,s=e.length-1;s>=0;s--){var l=e[s];a?isFinite(l)||(e[s]=-1/0,o++):e.splice(s,1)}i&&e.sort((function(e,t){return e-t}));var u=e.length,c=Math.floor(u/2);return u%2!=0?e[c+1+o]:(e[c-1+o]+e[c+o])/2}(e):"mean"===t?function(e){for(var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:e.length,r=0,i=0,a=t;a1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:e.length,r=1/0,i=t;i1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:e.length,r=-1/0,i=t;io&&(a=l,o=t[i*e+l])}a>0&&r.push(a)}for(var u=0;u=D?(T=D,D=M,_=B):M>T&&(T=M);for(var N=0;N0?1:0;C[k%u.minIterations*t+R]=V,O+=V}if(O>0&&(k>=u.minIterations-1||k==u.maxIterations-1)){for(var F=0,j=0;j0&&r.push(i);return r}(t,a,o),X=function(e,t,n){for(var r=rr(e,t,n),i=0;il&&(s=u,l=c)}n[i]=a[s]}return r=rr(e,t,n)}(t,r,Y),W={},H=0;H1)}}));var l=Object.keys(t).filter((function(e){return t[e].cutVertex})).map((function(t){return e.getElementById(t)}));return{cut:e.spawn(l),components:i}},lr=function(){var e=this,t={},n=0,r=[],i=[],a=e.spawn(e);return e.forEach((function(o){if(o.isNode()){var s=o.id();s in t||function o(s){if(i.push(s),t[s]={index:n,low:n++,explored:!1},e.getElementById(s).connectedEdges().intersection(e).forEach((function(e){var n=e.target().id();n!==s&&(n in t||o(n),t[n].explored||(t[s].low=Math.min(t[s].low,t[n].low)))})),t[s].index===t[s].low){for(var l=e.spawn();;){var u=i.pop();if(l.merge(e.getElementById(u)),t[u].low=t[s].index,t[u].explored=!0,u===s)break}var c=l.edgesWith(l),d=l.merge(c);r.push(d),a=a.difference(d)}}(s)}})),{cut:a,components:r}},ur={};[nt,at,ot,lt,ct,ht,vt,sn,un,dn,pn,kn,Kn,Jn,ar,{hierholzer:function(e){if(!b(e)){var t=arguments;e={root:t[0],directed:t[1]}}var n,r,i,a=or(e),o=a.root,s=a.directed,l=this,u=!1;o&&(i=v(o)?this.filter(o)[0].id():o[0].id());var c={},d={};s?l.forEach((function(e){var t=e.id();if(e.isNode()){var i=e.indegree(!0),a=e.outdegree(!0),o=i-a,s=a-i;1==o?n?u=!0:n=t:1==s?r?u=!0:r=t:(s>1||o>1)&&(u=!0),c[t]=[],e.outgoers().forEach((function(e){e.isEdge()&&c[t].push(e.id())}))}else d[t]=[void 0,e.target().id()]})):l.forEach((function(e){var t=e.id();e.isNode()?(e.degree(!0)%2&&(n?r?u=!0:r=t:n=t),c[t]=[],e.connectedEdges().forEach((function(e){return c[t].push(e.id())}))):d[t]=[e.source().id(),e.target().id()]}));var h={found:!1,trail:void 0};if(u)return h;if(r&&n)if(s){if(i&&r!=i)return h;i=r}else{if(i&&r!=i&&n!=i)return h;i||(i=r)}else i||(i=l[0].id());var p=function(e){for(var t,n,r,i=e,a=[e];c[i].length;)t=c[i].shift(),n=d[t][0],i!=(r=d[t][1])?(c[r]=c[r].filter((function(e){return e!=t})),i=r):s||i==n||(c[n]=c[n].filter((function(e){return e!=t})),i=n),a.unshift(t),a.unshift(i);return a},f=[],g=[];for(g=p(i);1!=g.length;)0==c[g[0]].length?(f.unshift(l.getElementById(g.shift())),f.unshift(l.getElementById(g.shift()))):g=p(g.shift()).concat(g);for(var y in f.unshift(l.getElementById(g.shift())),c)if(c[y].length)return h;return h.found=!0,h.trail=this.spawn(f,!0),h}},{hopcroftTarjanBiconnected:sr,htbc:sr,htb:sr,hopcroftTarjanBiconnectedComponents:sr},{tarjanStronglyConnected:lr,tsc:lr,tscc:lr,tarjanStronglyConnectedComponents:lr}].forEach((function(e){L(ur,e)})); +/*! + Embeddable Minimum Strictly-Compliant Promises/A+ 1.1.1 Thenable + Copyright (c) 2013-2014 Ralf S. Engelschall (http://engelschall.com) + Licensed under The MIT License (http://opensource.org/licenses/MIT) + */ +var cr=function e(t){if(!(this instanceof e))return new e(t);this.id="Thenable/1.0.7",this.state=0,this.fulfillValue=void 0,this.rejectReason=void 0,this.onFulfilled=[],this.onRejected=[],this.proxy={then:this.then.bind(this)},"function"==typeof t&&t.call(this,this.fulfill.bind(this),this.reject.bind(this))};cr.prototype={fulfill:function(e){return dr(this,1,"fulfillValue",e)},reject:function(e){return dr(this,2,"rejectReason",e)},then:function(e,t){var n=new cr;return this.onFulfilled.push(fr(e,n,"fulfill")),this.onRejected.push(fr(t,n,"reject")),hr(this),n.proxy}};var dr=function(e,t,n,r){return 0===e.state&&(e.state=t,e[n]=r,hr(e)),e},hr=function(e){1===e.state?pr(e,"onFulfilled",e.fulfillValue):2===e.state&&pr(e,"onRejected",e.rejectReason)},pr=function(e,t,n){if(0!==e[t].length){var r=e[t];e[t]=[];var i=function(){for(var e=0;e0:void 0}},clearQueue:function(){return function(){var e=void 0!==this.length?this:[this];if(!(this._private.cy||this).styleEnabled())return this;for(var t=0;t-1};var ri=function(e,t){var n=this.__data__,r=Qr(n,e);return r<0?(++this.size,n.push([e,t])):n[r][1]=t,this};function ii(e){var t=-1,n=null==e?0:e.length;for(this.clear();++t-1&&e%1==0&&e0&&this.spawn(n).updateStyle().emit("class"),this},addClass:function(e){return this.toggleClass(e,!0)},hasClass:function(e){var t=this[0];return null!=t&&t._private.classes.has(e)},toggleClass:function(e,t){m(e)||(e=e.match(/\S+/g)||[]);for(var n=void 0===t,r=[],i=0,a=this.length;i0&&this.spawn(r).updateStyle().emit("class"),this},removeClass:function(e){return this.toggleClass(e,!1)},flashClass:function(e,t){var n=this;if(null==t)t=250;else if(0===t)return n;return n.addClass(e),setTimeout((function(){n.removeClass(e)}),t),n}};qi.className=qi.classNames=qi.classes;var Yi={metaChar:"[\\!\\\"\\#\\$\\%\\&\\'\\(\\)\\*\\+\\,\\.\\/\\:\\;\\<\\=\\>\\?\\@\\[\\]\\^\\`\\{\\|\\}\\~]",comparatorOp:"=|\\!=|>|>=|<|<=|\\$=|\\^=|\\*=",boolOp:"\\?|\\!|\\^",string:"\"(?:\\\\\"|[^\"])*\"|'(?:\\\\'|[^'])*'",number:I,meta:"degree|indegree|outdegree",separator:"\\s*,\\s*",descendant:"\\s+",child:"\\s+>\\s+",subject:"\\$",group:"node|edge|\\*",directedEdge:"\\s+->\\s+",undirectedEdge:"\\s+<->\\s+"};Yi.variable="(?:[\\w-.]|(?:\\\\"+Yi.metaChar+"))+",Yi.className="(?:[\\w-]|(?:\\\\"+Yi.metaChar+"))+",Yi.value=Yi.string+"|"+Yi.number,Yi.id=Yi.variable,function(){var e,t,n;for(e=Yi.comparatorOp.split("|"),n=0;n=0||"="!==t&&(Yi.comparatorOp+="|\\!"+t)}();var Xi=0,Wi=1,Hi=2,Ki=3,Gi=4,Ui=5,Zi=6,$i=7,Qi=8,Ji=9,ea=10,ta=11,na=12,ra=13,ia=14,aa=15,oa=16,sa=17,la=18,ua=19,ca=20,da=[{selector:":selected",matches:function(e){return e.selected()}},{selector:":unselected",matches:function(e){return!e.selected()}},{selector:":selectable",matches:function(e){return e.selectable()}},{selector:":unselectable",matches:function(e){return!e.selectable()}},{selector:":locked",matches:function(e){return e.locked()}},{selector:":unlocked",matches:function(e){return!e.locked()}},{selector:":visible",matches:function(e){return e.visible()}},{selector:":hidden",matches:function(e){return!e.visible()}},{selector:":transparent",matches:function(e){return e.transparent()}},{selector:":grabbed",matches:function(e){return e.grabbed()}},{selector:":free",matches:function(e){return!e.grabbed()}},{selector:":removed",matches:function(e){return e.removed()}},{selector:":inside",matches:function(e){return!e.removed()}},{selector:":grabbable",matches:function(e){return e.grabbable()}},{selector:":ungrabbable",matches:function(e){return!e.grabbable()}},{selector:":animated",matches:function(e){return e.animated()}},{selector:":unanimated",matches:function(e){return!e.animated()}},{selector:":parent",matches:function(e){return e.isParent()}},{selector:":childless",matches:function(e){return e.isChildless()}},{selector:":child",matches:function(e){return e.isChild()}},{selector:":orphan",matches:function(e){return e.isOrphan()}},{selector:":nonorphan",matches:function(e){return e.isChild()}},{selector:":compound",matches:function(e){return e.isNode()?e.isParent():e.source().isParent()||e.target().isParent()}},{selector:":loop",matches:function(e){return e.isLoop()}},{selector:":simple",matches:function(e){return e.isSimple()}},{selector:":active",matches:function(e){return e.active()}},{selector:":inactive",matches:function(e){return!e.active()}},{selector:":backgrounding",matches:function(e){return e.backgrounding()}},{selector:":nonbackgrounding",matches:function(e){return!e.backgrounding()}}].sort((function(e,t){return function(e,t){return-1*A(e,t)}(e.selector,t.selector)})),ha=function(){for(var e,t={},n=0;n0&&l.edgeCount>0)return je("The selector `"+e+"` is invalid because it uses both a compound selector and an edge selector"),!1;if(l.edgeCount>1)return je("The selector `"+e+"` is invalid because it uses multiple edge selectors"),!1;1===l.edgeCount&&je("The selector `"+e+"` is deprecated. Edge selectors do not take effect on changes to source and target nodes after an edge is added, for performance reasons. Use a class or data selector on edges instead, updating the class or data of an edge when your app detects a change in source or target nodes.")}return!0},toString:function(){if(null!=this.toStringCache)return this.toStringCache;for(var e=function(e){return null==e?"":e},t=function(t){return v(t)?'"'+t+'"':e(t)},n=function(e){return" "+e+" "},r=function(r,a){var o=r.type,s=r.value;switch(o){case Xi:var l=e(s);return l.substring(0,l.length-1);case Ki:var u=r.field,c=r.operator;return"["+u+n(e(c))+t(s)+"]";case Ui:var d=r.operator,h=r.field;return"["+e(d)+h+"]";case Gi:return"["+r.field+"]";case Zi:var p=r.operator;return"[["+r.field+n(e(p))+t(s)+"]]";case $i:return s;case Qi:return"#"+s;case Ji:return"."+s;case sa:case aa:return i(r.parent,a)+n(">")+i(r.child,a);case la:case oa:return i(r.ancestor,a)+" "+i(r.descendant,a);case ua:var f=i(r.left,a),g=i(r.subject,a),v=i(r.right,a);return f+(f.length>0?" ":"")+g+v;case ca:return""}},i=function(e,t){return e.checks.reduce((function(n,i,a){return n+(t===e&&0===a?"$":"")+r(i,t)}),"")},a="",o=0;o1&&o=0&&(t=t.replace("!",""),c=!0),t.indexOf("@")>=0&&(t=t.replace("@",""),u=!0),(o||l||u)&&(i=o||s?""+e:"",a=""+n),u&&(e=i=i.toLowerCase(),n=a=a.toLowerCase()),t){case"*=":r=i.indexOf(a)>=0;break;case"$=":r=i.indexOf(a,i.length-a.length)>=0;break;case"^=":r=0===i.indexOf(a);break;case"=":r=e===n;break;case">":d=!0,r=e>n;break;case">=":d=!0,r=e>=n;break;case"<":d=!0,r=e0;){var u=i.shift();t(u),a.add(u.id()),o&&r(i,a,u)}return e}function Ba(e,t,n){if(n.isParent())for(var r=n._private.children,i=0;i1&&void 0!==arguments[1])||arguments[1];return Ma(this,e,t,Ba)},_a.forEachUp=function(e){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];return Ma(this,e,t,Na)},_a.forEachUpAndDown=function(e){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];return Ma(this,e,t,za)},_a.ancestors=_a.parents,(Pa=Da={data:Fi.data({field:"data",bindingEvent:"data",allowBinding:!0,allowSetting:!0,settingEvent:"data",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,immutableKeys:{id:!0,source:!0,target:!0,parent:!0},updateStyle:!0}),removeData:Fi.removeData({field:"data",event:"data",triggerFnName:"trigger",triggerEvent:!0,immutableKeys:{id:!0,source:!0,target:!0,parent:!0},updateStyle:!0}),scratch:Fi.data({field:"scratch",bindingEvent:"scratch",allowBinding:!0,allowSetting:!0,settingEvent:"scratch",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,updateStyle:!0}),removeScratch:Fi.removeData({field:"scratch",event:"scratch",triggerFnName:"trigger",triggerEvent:!0,updateStyle:!0}),rscratch:Fi.data({field:"rscratch",allowBinding:!1,allowSetting:!0,settingTriggersEvent:!1,allowGetting:!0}),removeRscratch:Fi.removeData({field:"rscratch",triggerEvent:!1}),id:function(){var e=this[0];if(e)return e._private.data.id}}).attr=Pa.data,Pa.removeAttr=Pa.removeData;var Ia,Aa,La=Da,Oa={};function Ra(e){return function(t){if(void 0===t&&(t=!0),0!==this.length&&this.isNode()&&!this.removed()){for(var n=0,r=this[0],i=r._private.edges,a=0;at})),minIndegree:Va("indegree",(function(e,t){return et})),minOutdegree:Va("outdegree",(function(e,t){return et}))}),L(Oa,{totalDegree:function(e){for(var t=0,n=this.nodes(),r=0;r0,c=u;u&&(l=l[0]);var d=c?l.position():{x:0,y:0};return i={x:s.x-d.x,y:s.y-d.y},void 0===e?i:i[e]}for(var h=0;h0,y=g;g&&(f=f[0]);var m=y?f.position():{x:0,y:0};void 0!==t?p.position(e,t+m[e]):void 0!==i&&p.position({x:i.x+m.x,y:i.y+m.y})}}else if(!a)return;return this}}).modelPosition=Ia.point=Ia.position,Ia.modelPositions=Ia.points=Ia.positions,Ia.renderedPoint=Ia.renderedPosition,Ia.relativePoint=Ia.relativePosition;var qa,Ya,Xa=Aa;qa=Ya={},Ya.renderedBoundingBox=function(e){var t=this.boundingBox(e),n=this.cy(),r=n.zoom(),i=n.pan(),a=t.x1*r+i.x,o=t.x2*r+i.x,s=t.y1*r+i.y,l=t.y2*r+i.y;return{x1:a,x2:o,y1:s,y2:l,w:o-a,h:l-s}},Ya.dirtyCompoundBoundsCache=function(){var e=arguments.length>0&&void 0!==arguments[0]&&arguments[0],t=this.cy();return t.styleEnabled()&&t.hasCompoundNodes()?(this.forEachUp((function(t){if(t.isParent()){var n=t._private;n.compoundBoundsClean=!1,n.bbCache=null,e||t.emitAndNotify("bounds")}})),this):this},Ya.updateCompoundBounds=function(){var e=arguments.length>0&&void 0!==arguments[0]&&arguments[0],t=this.cy();if(!t.styleEnabled()||!t.hasCompoundNodes())return this;if(!e&&t.batching())return this;function n(e){if(e.isParent()){var t=e._private,n=e.children(),r="include"===e.pstyle("compound-sizing-wrt-labels").value,i={width:{val:e.pstyle("min-width").pfValue,left:e.pstyle("min-width-bias-left"),right:e.pstyle("min-width-bias-right")},height:{val:e.pstyle("min-height").pfValue,top:e.pstyle("min-height-bias-top"),bottom:e.pstyle("min-height-bias-bottom")}},a=n.boundingBox({includeLabels:r,includeOverlays:!1,useCache:!1}),o=t.position;0!==a.w&&0!==a.h||((a={w:e.pstyle("width").pfValue,h:e.pstyle("height").pfValue}).x1=o.x-a.w/2,a.x2=o.x+a.w/2,a.y1=o.y-a.h/2,a.y2=o.y+a.h/2);var s=i.width.left.value;"px"===i.width.left.units&&i.width.val>0&&(s=100*s/i.width.val);var l=i.width.right.value;"px"===i.width.right.units&&i.width.val>0&&(l=100*l/i.width.val);var u=i.height.top.value;"px"===i.height.top.units&&i.height.val>0&&(u=100*u/i.height.val);var c=i.height.bottom.value;"px"===i.height.bottom.units&&i.height.val>0&&(c=100*c/i.height.val);var d=y(i.width.val-a.w,s,l),h=d.biasDiff,p=d.biasComplementDiff,f=y(i.height.val-a.h,u,c),g=f.biasDiff,v=f.biasComplementDiff;t.autoPadding=function(e,t,n,r){if("%"!==n.units)return"px"===n.units?n.pfValue:0;switch(r){case"width":return e>0?n.pfValue*e:0;case"height":return t>0?n.pfValue*t:0;case"average":return e>0&&t>0?n.pfValue*(e+t)/2:0;case"min":return e>0&&t>0?e>t?n.pfValue*t:n.pfValue*e:0;case"max":return e>0&&t>0?e>t?n.pfValue*e:n.pfValue*t:0;default:return 0}}(a.w,a.h,e.pstyle("padding"),e.pstyle("padding-relative-to").value),t.autoWidth=Math.max(a.w,i.width.val),o.x=(-h+a.x1+a.x2+p)/2,t.autoHeight=Math.max(a.h,i.height.val),o.y=(-g+a.y1+a.y2+v)/2}function y(e,t,n){var r=0,i=0,a=t+n;return e>0&&a>0&&(r=t/a*e,i=n/a*e),{biasDiff:r,biasComplementDiff:i}}}for(var r=0;re.x2?r:e.x2,e.y1=ne.y2?i:e.y2,e.w=e.x2-e.x1,e.h=e.y2-e.y1)},Ka=function(e,t){return null==t?e:Ha(e,t.x1,t.y1,t.x2,t.y2)},Ga=function(e,t,n){return Ue(e,t,n)},Ua=function(e,t,n){if(!t.cy().headless()){var r,i,a=t._private,o=a.rstyle,s=o.arrowWidth/2;if("none"!==t.pstyle(n+"-arrow-shape").value){"source"===n?(r=o.srcX,i=o.srcY):"target"===n?(r=o.tgtX,i=o.tgtY):(r=o.midX,i=o.midY);var l=a.arrowBounds=a.arrowBounds||{},u=l[n]=l[n]||{};u.x1=r-s,u.y1=i-s,u.x2=r+s,u.y2=i+s,u.w=u.x2-u.x1,u.h=u.y2-u.y1,Nt(u,1),Ha(e,u.x1,u.y1,u.x2,u.y2)}}},Za=function(e,t,n){if(!t.cy().headless()){var r;r=n?n+"-":"";var i=t._private,a=i.rstyle;if(t.pstyle(r+"label").strValue){var o,s,l,u,c=t.pstyle("text-halign"),d=t.pstyle("text-valign"),h=Ga(a,"labelWidth",n),p=Ga(a,"labelHeight",n),f=Ga(a,"labelX",n),g=Ga(a,"labelY",n),v=t.pstyle(r+"text-margin-x").pfValue,y=t.pstyle(r+"text-margin-y").pfValue,m=t.isEdge(),b=t.pstyle(r+"text-rotation"),x=t.pstyle("text-outline-width").pfValue,w=t.pstyle("text-border-width").pfValue/2,E=t.pstyle("text-background-padding").pfValue,k=p,C=h,S=C/2,P=k/2;if(m)o=f-S,s=f+S,l=g-P,u=g+P;else{switch(c.value){case"left":o=f-C,s=f;break;case"center":o=f-S,s=f+S;break;case"right":o=f,s=f+C}switch(d.value){case"top":l=g-k,u=g;break;case"center":l=g-P,u=g+P;break;case"bottom":l=g,u=g+k}}o+=v-Math.max(x,w)-E-2,s+=v+Math.max(x,w)+E+2,l+=y-Math.max(x,w)-E-2,u+=y+Math.max(x,w)+E+2;var D=n||"main",T=i.labelBounds,_=T[D]=T[D]||{};_.x1=o,_.y1=l,_.x2=s,_.y2=u,_.w=s-o,_.h=u-l;var M=m&&"autorotate"===b.strValue,B=null!=b.pfValue&&0!==b.pfValue;if(M||B){var N=M?Ga(i.rstyle,"labelAngle",n):b.pfValue,z=Math.cos(N),I=Math.sin(N),A=(o+s)/2,L=(l+u)/2;if(!m){switch(c.value){case"left":A=s;break;case"right":A=o}switch(d.value){case"top":L=u;break;case"bottom":L=l}}var O=function(e,t){return{x:(e-=A)*z-(t-=L)*I+A,y:e*I+t*z+L}},R=O(o,l),V=O(o,u),F=O(s,l),j=O(s,u);o=Math.min(R.x,V.x,F.x,j.x),s=Math.max(R.x,V.x,F.x,j.x),l=Math.min(R.y,V.y,F.y,j.y),u=Math.max(R.y,V.y,F.y,j.y)}var q=D+"Rot",Y=T[q]=T[q]||{};Y.x1=o,Y.y1=l,Y.x2=s,Y.y2=u,Y.w=s-o,Y.h=u-l,Ha(e,o,l,s,u),Ha(i.labelBounds.all,o,l,s,u)}return e}},$a=function(e,t){var n,r,i,a,o,s,l,u=e._private.cy,c=u.styleEnabled(),d=u.headless(),h=_t(),p=e._private,f=e.isNode(),g=e.isEdge(),v=p.rstyle,y=f&&c?e.pstyle("bounds-expansion").pfValue:[0],m=function(e){return"none"!==e.pstyle("display").value},b=!c||m(e)&&(!g||m(e.source())&&m(e.target()));if(b){var x=0;c&&t.includeOverlays&&0!==e.pstyle("overlay-opacity").value&&(x=e.pstyle("overlay-padding").value);var w=0;c&&t.includeUnderlays&&0!==e.pstyle("underlay-opacity").value&&(w=e.pstyle("underlay-padding").value);var E=Math.max(x,w),k=0;if(c&&(k=e.pstyle("width").pfValue/2),f&&t.includeNodes){var C=e.position();o=C.x,s=C.y;var S=e.outerWidth()/2,P=e.outerHeight()/2;Ha(h,n=o-S,i=s-P,r=o+S,a=s+P),c&&t.includeOutlines&&function(e,t){if(!t.cy().headless()){var n,r,i,a=t.pstyle("outline-opacity").value,o=t.pstyle("outline-width").value;if(a>0&&o>0){var s=t.pstyle("outline-offset").value,l=t.pstyle("shape").value,u=o+s,c=(e.w+2*u)/e.w,d=(e.h+2*u)/e.h,h=0;["diamond","pentagon","round-triangle"].includes(l)?(c=(e.w+2.4*u)/e.w,h=-u/3.6):["concave-hexagon","rhomboid","right-rhomboid"].includes(l)?c=(e.w+2.4*u)/e.w:"star"===l?(c=(e.w+2.8*u)/e.w,d=(e.h+2.6*u)/e.h,h=-u/3.8):"triangle"===l?(c=(e.w+2.8*u)/e.w,d=(e.h+2.4*u)/e.h,h=-u/1.4):"vee"===l&&(c=(e.w+4.4*u)/e.w,d=(e.h+3.8*u)/e.h,h=.5*-u);var p=e.h*d-e.h,f=e.w*c-e.w;if(zt(e,[Math.ceil(p/2),Math.ceil(f/2)]),0!==h){var g=(r=0,i=h,{x1:(n=e).x1+r,x2:n.x2+r,y1:n.y1+i,y2:n.y2+i,w:n.w,h:n.h});Mt(e,g)}}}}(h,e)}else if(g&&t.includeEdges)if(c&&!d){var D=e.pstyle("curve-style").strValue;if(n=Math.min(v.srcX,v.midX,v.tgtX),r=Math.max(v.srcX,v.midX,v.tgtX),i=Math.min(v.srcY,v.midY,v.tgtY),a=Math.max(v.srcY,v.midY,v.tgtY),Ha(h,n-=k,i-=k,r+=k,a+=k),"haystack"===D){var T=v.haystackPts;if(T&&2===T.length){if(n=T[0].x,i=T[0].y,n>(r=T[1].x)){var _=n;n=r,r=_}if(i>(a=T[1].y)){var M=i;i=a,a=M}Ha(h,n-k,i-k,r+k,a+k)}}else if("bezier"===D||"unbundled-bezier"===D||D.endsWith("segments")||D.endsWith("taxi")){var B;switch(D){case"bezier":case"unbundled-bezier":B=v.bezierPts;break;case"segments":case"taxi":case"round-segments":case"round-taxi":B=v.linePts}if(null!=B)for(var N=0;N(r=A.x)){var L=n;n=r,r=L}if((i=I.y)>(a=A.y)){var O=i;i=a,a=O}Ha(h,n-=k,i-=k,r+=k,a+=k)}if(c&&t.includeEdges&&g&&(Ua(h,e,"mid-source"),Ua(h,e,"mid-target"),Ua(h,e,"source"),Ua(h,e,"target")),c)if("yes"===e.pstyle("ghost").value){var R=e.pstyle("ghost-offset-x").pfValue,V=e.pstyle("ghost-offset-y").pfValue;Ha(h,h.x1+R,h.y1+V,h.x2+R,h.y2+V)}var F=p.bodyBounds=p.bodyBounds||{};It(F,h),zt(F,y),Nt(F,1),c&&(n=h.x1,r=h.x2,i=h.y1,a=h.y2,Ha(h,n-E,i-E,r+E,a+E));var j=p.overlayBounds=p.overlayBounds||{};It(j,h),zt(j,y),Nt(j,1);var q=p.labelBounds=p.labelBounds||{};null!=q.all?((l=q.all).x1=1/0,l.y1=1/0,l.x2=-1/0,l.y2=-1/0,l.w=0,l.h=0):q.all=_t(),c&&t.includeLabels&&(t.includeMainLabels&&Za(h,e,null),g&&(t.includeSourceLabels&&Za(h,e,"source"),t.includeTargetLabels&&Za(h,e,"target")))}return h.x1=Wa(h.x1),h.y1=Wa(h.y1),h.x2=Wa(h.x2),h.y2=Wa(h.y2),h.w=Wa(h.x2-h.x1),h.h=Wa(h.y2-h.y1),h.w>0&&h.h>0&&b&&(zt(h,y),Nt(h,1)),h},Qa=function(e){var t=0,n=function(e){return(e?1:0)<0&&void 0!==arguments[0]?arguments[0]:bo,t=arguments.length>1?arguments[1]:void 0,n=0;n=0;s--)o(s);return this},wo.removeAllListeners=function(){return this.removeListener("*")},wo.emit=wo.trigger=function(e,t,n){var r=this.listeners,i=r.length;return this.emitting++,m(t)||(t=[t]),Co(this,(function(e,a){null!=n&&(r=[{event:a.event,type:a.type,namespace:a.namespace,callback:n}],i=r.length);for(var o=function(n){var i=r[n];if(i.type===a.type&&(!i.namespace||i.namespace===a.namespace||".*"===i.namespace)&&e.eventMatches(e.context,i,a)){var o=[a];null!=t&&function(e,t){for(var n=0;n1&&!r){var i=this.length-1,a=this[i],o=a._private.data.id;this[i]=void 0,this[e]=a,n.set(o,{ele:a,index:e})}return this.length--,this},unmergeOne:function(e){e=e[0];var t=this._private,n=e._private.data.id,r=t.map.get(n);if(!r)return this;var i=r.index;return this.unmergeAt(i),this},unmerge:function(e){var t=this._private.cy;if(!e)return this;if(e&&v(e)){var n=e;e=t.mutableElements().filter(n)}for(var r=0;r=0;t--){e(this[t])&&this.unmergeAt(t)}return this},map:function(e,t){for(var n=[],r=0;rr&&(r=o,n=a)}return{value:r,ele:n}},min:function(e,t){for(var n,r=1/0,i=0;i=0&&i1&&void 0!==arguments[1])||arguments[1],n=this[0],r=n.cy();if(r.styleEnabled()&&n){this.cleanStyle();var i=n._private.style[e];return null!=i?i:t?r.style().getDefaultProperty(e):null}},numericStyle:function(e){var t=this[0];if(t.cy().styleEnabled()&&t){var n=t.pstyle(e);return void 0!==n.pfValue?n.pfValue:n.value}},numericStyleUnits:function(e){var t=this[0];if(t.cy().styleEnabled())return t?t.pstyle(e).units:void 0},renderedStyle:function(e){var t=this.cy();if(!t.styleEnabled())return this;var n=this[0];return n?t.style().getRenderedStyle(n,e):void 0},style:function(e,t){var n=this.cy();if(!n.styleEnabled())return this;var r=n.style();if(b(e)){var i=e;r.applyBypass(this,i,!1),this.emitAndNotify("style")}else if(v(e)){if(void 0===t){var a=this[0];return a?r.getStylePropertyValue(a,e):void 0}r.applyBypass(this,e,t,!1),this.emitAndNotify("style")}else if(void 0===e){var o=this[0];return o?r.getRawStyle(o):void 0}return this},removeStyle:function(e){var t=this.cy();if(!t.styleEnabled())return this;var n=t.style();if(void 0===e)for(var r=0;r0&&t.push(c[0]),t.push(s[0])}return this.spawn(t,!0).filter(e)}),"neighborhood"),closedNeighborhood:function(e){return this.neighborhood().add(this).filter(e)},openNeighborhood:function(e){return this.neighborhood(e)}}),Go.neighbourhood=Go.neighborhood,Go.closedNeighbourhood=Go.closedNeighborhood,Go.openNeighbourhood=Go.openNeighborhood,L(Go,{source:Ta((function(e){var t,n=this[0];return n&&(t=n._private.source||n.cy().collection()),t&&e?t.filter(e):t}),"source"),target:Ta((function(e){var t,n=this[0];return n&&(t=n._private.target||n.cy().collection()),t&&e?t.filter(e):t}),"target"),sources:Qo({attr:"source"}),targets:Qo({attr:"target"})}),L(Go,{edgesWith:Ta(Jo(),"edgesWith"),edgesTo:Ta(Jo({thisIsSrc:!0}),"edgesTo")}),L(Go,{connectedEdges:Ta((function(e){for(var t=[],n=0;n0);return a},component:function(){var e=this[0];return e.cy().mutableElements().components(e)[0]}}),Go.componentsOf=Go.components;var ts=function(e,t){var n=arguments.length>2&&void 0!==arguments[2]&&arguments[2],r=arguments.length>3&&void 0!==arguments[3]&&arguments[3];if(void 0!==e){var i=new $e,a=!1;if(t){if(t.length>0&&b(t[0])&&!k(t[0])){a=!0;for(var o=[],s=new Je,l=0,u=t.length;l0&&void 0!==arguments[0])||arguments[0],r=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],i=this,a=i.cy(),o=a._private,s=[],l=[],u=0,c=i.length;u0){for(var R=e.length===i.length?i:new ts(a,e),V=0;V0&&void 0!==arguments[0])||arguments[0],t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],n=this,r=[],i={},a=n._private.cy;function o(e){for(var t=e._private.edges,n=0;n0&&(e?D.emitAndNotify("remove"):t&&D.emit("remove"));for(var T=0;T1e-4&&Math.abs(s.v)>1e-4;);return a?function(e){return u[e*(u.length-1)|0]}:c}}(),as=function(e,t,n,r){var i=function(e,t,n,r){var i=4,a=.001,o=1e-7,s=10,l=11,u=1/(l-1),c="undefined"!=typeof Float32Array;if(4!==arguments.length)return!1;for(var d=0;d<4;++d)if("number"!=typeof arguments[d]||isNaN(arguments[d])||!isFinite(arguments[d]))return!1;e=Math.min(e,1),n=Math.min(n,1),e=Math.max(e,0),n=Math.max(n,0);var h=c?new Float32Array(l):new Array(l);function p(e,t){return 1-3*t+3*e}function f(e,t){return 3*t-6*e}function g(e){return 3*e}function v(e,t,n){return((p(t,n)*e+f(t,n))*e+g(t))*e}function y(e,t,n){return 3*p(t,n)*e*e+2*f(t,n)*e+g(t)}function m(t,r){for(var a=0;a0?i=l:r=l}while(Math.abs(a)>o&&++u=a?m(t,s):0===c?s:x(t,r,r+u)}var E=!1;function k(){E=!0,e===t&&n===r||b()}var C=function(i){return E||k(),e===t&&n===r?i:0===i?0:1===i?1:v(w(i),t,r)};C.getControlPoints=function(){return[{x:e,y:t},{x:n,y:r}]};var S="generateBezier("+[e,t,n,r]+")";return C.toString=function(){return S},C}(e,t,n,r);return function(e,t,n){return e+(t-e)*i(n)}},os={linear:function(e,t,n){return e+(t-e)*n},ease:as(.25,.1,.25,1),"ease-in":as(.42,0,1,1),"ease-out":as(0,0,.58,1),"ease-in-out":as(.42,0,.58,1),"ease-in-sine":as(.47,0,.745,.715),"ease-out-sine":as(.39,.575,.565,1),"ease-in-out-sine":as(.445,.05,.55,.95),"ease-in-quad":as(.55,.085,.68,.53),"ease-out-quad":as(.25,.46,.45,.94),"ease-in-out-quad":as(.455,.03,.515,.955),"ease-in-cubic":as(.55,.055,.675,.19),"ease-out-cubic":as(.215,.61,.355,1),"ease-in-out-cubic":as(.645,.045,.355,1),"ease-in-quart":as(.895,.03,.685,.22),"ease-out-quart":as(.165,.84,.44,1),"ease-in-out-quart":as(.77,0,.175,1),"ease-in-quint":as(.755,.05,.855,.06),"ease-out-quint":as(.23,1,.32,1),"ease-in-out-quint":as(.86,0,.07,1),"ease-in-expo":as(.95,.05,.795,.035),"ease-out-expo":as(.19,1,.22,1),"ease-in-out-expo":as(1,0,0,1),"ease-in-circ":as(.6,.04,.98,.335),"ease-out-circ":as(.075,.82,.165,1),"ease-in-out-circ":as(.785,.135,.15,.86),spring:function(e,t,n){if(0===n)return os.linear;var r=is(e,t,n);return function(e,t,n){return e+(t-e)*r(n)}},"cubic-bezier":as};function ss(e,t,n,r,i){if(1===r)return n;if(t===n)return n;var a=i(t,n,r);return null==e||((e.roundValue||e.color)&&(a=Math.round(a)),void 0!==e.min&&(a=Math.max(a,e.min)),void 0!==e.max&&(a=Math.min(a,e.max))),a}function ls(e,t){return null!=e.pfValue||null!=e.value?null==e.pfValue||null!=t&&"%"===t.type.units?e.value:e.pfValue:e}function us(e,t,n,r,i){var a=null!=i?i.type:null;n<0?n=0:n>1&&(n=1);var o=ls(e,i),s=ls(t,i);if(x(o)&&x(s))return ss(a,o,s,n,r);if(m(o)&&m(s)){for(var l=[],u=0;u0?("spring"===d&&h.push(o.duration),o.easingImpl=os[d].apply(null,h)):o.easingImpl=os[d]}var p,f=o.easingImpl;if(p=0===o.duration?1:(n-l)/o.duration,o.applying&&(p=o.progress),p<0?p=0:p>1&&(p=1),null==o.delay){var g=o.startPosition,y=o.position;if(y&&i&&!e.locked()){var m={};ds(g.x,y.x)&&(m.x=us(g.x,y.x,p,f)),ds(g.y,y.y)&&(m.y=us(g.y,y.y,p,f)),e.position(m)}var b=o.startPan,x=o.pan,w=a.pan,E=null!=x&&r;E&&(ds(b.x,x.x)&&(w.x=us(b.x,x.x,p,f)),ds(b.y,x.y)&&(w.y=us(b.y,x.y,p,f)),e.emit("pan"));var k=o.startZoom,C=o.zoom,S=null!=C&&r;S&&(ds(k,C)&&(a.zoom=Tt(a.minZoom,us(k,C,p,f),a.maxZoom)),e.emit("zoom")),(E||S)&&e.emit("viewport");var P=o.style;if(P&&P.length>0&&i){for(var D=0;D=0;t--){(0,e[t])()}e.splice(0,e.length)},c=a.length-1;c>=0;c--){var d=a[c],h=d._private;h.stopped?(a.splice(c,1),h.hooked=!1,h.playing=!1,h.started=!1,u(h.frames)):(h.playing||h.applying)&&(h.playing&&h.applying&&(h.applying=!1),h.started||hs(0,d,e),cs(t,d,e,n),h.applying&&(h.applying=!1),u(h.frames),null!=h.step&&h.step(e),d.completed()&&(a.splice(c,1),h.hooked=!1,h.playing=!1,h.started=!1,u(h.completes)),s=!0)}return n||0!==a.length||0!==o.length||r.push(t),s}for(var a=!1,o=0;o0?t.notify("draw",n):t.notify("draw")),n.unmerge(r),t.emit("step")}var fs={animate:Fi.animate(),animation:Fi.animation(),animated:Fi.animated(),clearQueue:Fi.clearQueue(),delay:Fi.delay(),delayAnimation:Fi.delayAnimation(),stop:Fi.stop(),addToAnimationPool:function(e){this.styleEnabled()&&this._private.aniEles.merge(e)},stopAnimationLoop:function(){this._private.animationsRunning=!1},startAnimationLoop:function(){var e=this;if(e._private.animationsRunning=!0,e.styleEnabled()){var t=e.renderer();t&&t.beforeRender?t.beforeRender((function(t,n){ps(n,e)}),t.beforeRenderPriorities.animations):function t(){e._private.animationsRunning&&xe((function(n){ps(n,e),t()}))}()}}},gs={qualifierCompare:function(e,t){return null==e||null==t?null==e&&null==t:e.sameText(t)},eventMatches:function(e,t,n){var r=t.qualifier;return null==r||e!==n.target&&k(n.target)&&r.matches(n.target)},addEventFields:function(e,t){t.cy=e,t.target=e},callbackContext:function(e,t,n){return null!=t.qualifier?n.target:e}},vs=function(e){return v(e)?new ka(e):e},ys={createEmitter:function(){var e=this._private;return e.emitter||(e.emitter=new xo(gs,this)),this},emitter:function(){return this._private.emitter},on:function(e,t,n){return this.emitter().on(e,vs(t),n),this},removeListener:function(e,t,n){return this.emitter().removeListener(e,vs(t),n),this},removeAllListeners:function(){return this.emitter().removeAllListeners(),this},one:function(e,t,n){return this.emitter().one(e,vs(t),n),this},once:function(e,t,n){return this.emitter().one(e,vs(t),n),this},emit:function(e,t){return this.emitter().emit(e,t),this},emitAndNotify:function(e,t){return this.emit(e),this.notify(e,t),this}};Fi.eventAliasesOn(ys);var ms={png:function(e){return e=e||{},this._private.renderer.png(e)},jpg:function(e){var t=this._private.renderer;return(e=e||{}).bg=e.bg||"#fff",t.jpg(e)}};ms.jpeg=ms.jpg;var bs={layout:function(e){if(null!=e)if(null!=e.name){var t=e.name,n=this.extension("layout",t);if(null!=n){var r;r=v(e.eles)?this.$(e.eles):null!=e.eles?e.eles:this.$();var i=new n(L({},e,{cy:this,eles:r}));return i}Ve("No such layout `"+t+"` found. Did you forget to import it and `cytoscape.use()` it?")}else Ve("A `name` must be specified to make a layout");else Ve("Layout options must be specified to make a layout")}};bs.createLayout=bs.makeLayout=bs.layout;var xs={notify:function(e,t){var n=this._private;if(this.batching()){n.batchNotifications=n.batchNotifications||{};var r=n.batchNotifications[e]=n.batchNotifications[e]||this.collection();null!=t&&r.merge(t)}else if(n.notificationsEnabled){var i=this.renderer();!this.destroyed()&&i&&i.notify(e,t)}},notifications:function(e){var t=this._private;return void 0===e?t.notificationsEnabled:(t.notificationsEnabled=!!e,this)},noNotifications:function(e){this.notifications(!1),e(),this.notifications(!0)},batching:function(){return this._private.batchCount>0},startBatch:function(){var e=this._private;return null==e.batchCount&&(e.batchCount=0),0===e.batchCount&&(e.batchStyleEles=this.collection(),e.batchNotifications={}),e.batchCount++,this},endBatch:function(){var e=this._private;if(0===e.batchCount)return this;if(e.batchCount--,0===e.batchCount){e.batchStyleEles.updateStyle();var t=this.renderer();Object.keys(e.batchNotifications).forEach((function(n){var r=e.batchNotifications[n];r.empty()?t.notify(n):t.notify(n,r)}))}return this},batch:function(e){return this.startBatch(),e(),this.endBatch(),this},batchData:function(e){var t=this;return this.batch((function(){for(var n=Object.keys(e),r=0;r0;)e.removeChild(e.childNodes[0]);this._private.renderer=null,this.mutableElements().forEach((function(e){var t=e._private;t.rscratch={},t.rstyle={},t.animation.current=[],t.animation.queue=[]}))},onRender:function(e){return this.on("render",e)},offRender:function(e){return this.off("render",e)}};Es.invalidateDimensions=Es.resize;var ks={collection:function(e,t){return v(e)?this.$(e):E(e)?e.collection():m(e)?(t||(t={}),new ts(this,e,t.unique,t.removed)):new ts(this)},nodes:function(e){var t=this.$((function(e){return e.isNode()}));return e?t.filter(e):t},edges:function(e){var t=this.$((function(e){return e.isEdge()}));return e?t.filter(e):t},$:function(e){var t=this._private.elements;return e?t.filter(e):t.spawnSelf()},mutableElements:function(){return this._private.elements}};ks.elements=ks.filter=ks.$;var Cs={};Cs.apply=function(e){for(var t=this._private.cy.collection(),n=0;n0;if(d||c&&h){var p=void 0;d&&h||d?p=l.properties:h&&(p=l.mappedProperties);for(var f=0;f1&&(g=1),s.color){var w=i.valueMin[0],E=i.valueMax[0],k=i.valueMin[1],C=i.valueMax[1],S=i.valueMin[2],P=i.valueMax[2],D=null==i.valueMin[3]?1:i.valueMin[3],T=null==i.valueMax[3]?1:i.valueMax[3],_=[Math.round(w+(E-w)*g),Math.round(k+(C-k)*g),Math.round(S+(P-S)*g),Math.round(D+(T-D)*g)];n={bypass:i.bypass,name:i.name,value:_,strValue:"rgb("+_[0]+", "+_[1]+", "+_[2]+")"}}else{if(!s.number)return!1;var M=i.valueMin+(i.valueMax-i.valueMin)*g;n=this.parse(i.name,M,i.bypass,"mapping")}if(!n)return f(),!1;n.mapping=i,i=n;break;case o.data:for(var B=i.field.split("."),N=d.data,z=0;z0&&a>0){for(var s={},l=!1,u=0;u0?e.delayAnimation(o).play().promise().then(t):t()})).then((function(){return e.animation({style:s,duration:a,easing:e.pstyle("transition-timing-function").value,queue:!1}).play().promise()})).then((function(){n.removeBypasses(e,i),e.emitAndNotify("style"),r.transitioning=!1}))}else r.transitioning&&(this.removeBypasses(e,i),e.emitAndNotify("style"),r.transitioning=!1)},Cs.checkTrigger=function(e,t,n,r,i,a){var o=this.properties[t],s=i(o);null!=s&&s(n,r)&&a(o)},Cs.checkZOrderTrigger=function(e,t,n,r){var i=this;this.checkTrigger(e,t,n,r,(function(e){return e.triggersZOrder}),(function(){i._private.cy.notify("zorder",e)}))},Cs.checkBoundsTrigger=function(e,t,n,r){this.checkTrigger(e,t,n,r,(function(e){return e.triggersBounds}),(function(i){e.dirtyCompoundBoundsCache(),e.dirtyBoundingBoxCache(),!i.triggersBoundsOfParallelBeziers||"curve-style"!==t||"bezier"!==n&&"bezier"!==r||e.parallelEdges().forEach((function(e){e.isBundledBezier()&&e.dirtyBoundingBoxCache()})),!i.triggersBoundsOfConnectedEdges||"display"!==t||"none"!==n&&"none"!==r||e.connectedEdges().forEach((function(e){e.dirtyBoundingBoxCache()}))}))},Cs.checkTriggers=function(e,t,n,r){e.dirtyStyleCache(),this.checkZOrderTrigger(e,t,n,r),this.checkBoundsTrigger(e,t,n,r)};var Ss={applyBypass:function(e,t,n,r){var i=[];if("*"===t||"**"===t){if(void 0!==n)for(var a=0;at.length?i.substr(t.length):""}function o(){n=n.length>r.length?n.substr(r.length):""}for(i=i.replace(/[/][*](\s|.)+?[*][/]/g,"");;){if(i.match(/^\s*$/))break;var s=i.match(/^\s*((?:.|\s)+?)\s*\{((?:.|\s)+?)\}/);if(!s){je("Halting stylesheet parsing: String stylesheet contains more to parse but no selector and block found in: "+i);break}t=s[0];var l=s[1];if("core"!==l)if(new ka(l).invalid){je("Skipping parsing of block: Invalid selector found in string stylesheet: "+l),a();continue}var u=s[2],c=!1;n=u;for(var d=[];;){if(n.match(/^\s*$/))break;var h=n.match(/^\s*(.+?)\s*:\s*(.+?)(?:\s*;|\s*$)/);if(!h){je("Skipping parsing of block: Invalid formatting of style property and value definitions found in:"+u),c=!0;break}r=h[0];var p=h[1],f=h[2];if(this.properties[p])this.parse(p,f)?(d.push({name:p,val:f}),o()):(je("Skipping property: Invalid property definition in: "+r),o());else je("Skipping property: Invalid property name in: "+r),o()}if(c){a();break}this.selector(l);for(var g=0;g=7&&"d"===t[0]&&(l=new RegExp(o.data.regex).exec(t))){if(n)return!1;var d=o.data;return{name:e,value:l,strValue:""+t,mapped:d,field:l[1],bypass:n}}if(t.length>=10&&"m"===t[0]&&(u=new RegExp(o.mapData.regex).exec(t))){if(n)return!1;if(c.multiple)return!1;var h=o.mapData;if(!c.color&&!c.number)return!1;var p=this.parse(e,u[4]);if(!p||p.mapped)return!1;var f=this.parse(e,u[5]);if(!f||f.mapped)return!1;if(p.pfValue===f.pfValue||p.strValue===f.strValue)return je("`"+e+": "+t+"` is not a valid mapper because the output range is zero; converting to `"+e+": "+p.strValue+"`"),this.parse(e,p.strValue);if(c.color){var g=p.value,b=f.value;if(!(g[0]!==b[0]||g[1]!==b[1]||g[2]!==b[2]||g[3]!==b[3]&&(null!=g[3]&&1!==g[3]||null!=b[3]&&1!==b[3])))return!1}return{name:e,value:u,strValue:""+t,mapped:h,field:u[1],fieldMin:parseFloat(u[2]),fieldMax:parseFloat(u[3]),valueMin:p.value,valueMax:f.value,bypass:n}}}if(c.multiple&&"multiple"!==r){var w;if(w=s?t.split(/\s+/):m(t)?t:[t],c.evenMultiple&&w.length%2!=0)return null;for(var E=[],k=[],C=[],S="",P=!1,D=0;D0?" ":"")+T.strValue}return c.validate&&!c.validate(E,k)?null:c.singleEnum&&P?1===E.length&&v(E[0])?{name:e,value:E[0],strValue:E[0],bypass:n}:null:{name:e,value:E,pfValue:C,strValue:S,bypass:n,units:k}}var _,B,N=function(){for(var r=0;rc.max||c.strictMax&&t===c.max))return null;var V={name:e,value:t,strValue:""+t+(z||""),units:z,bypass:n};return c.unitless||"px"!==z&&"em"!==z?V.pfValue=t:V.pfValue="px"!==z&&z?this.getEmSizeInPixels()*t:t,"ms"!==z&&"s"!==z||(V.pfValue="ms"===z?t:1e3*t),"deg"!==z&&"rad"!==z||(V.pfValue="rad"===z?t:(_=t,Math.PI*_/180)),"%"===z&&(V.pfValue=t/100),V}if(c.propList){var F=[],j=""+t;if("none"===j);else{for(var q=j.split(/\s*,\s*|\s+/),Y=0;Y0&&l>0&&!isNaN(n.w)&&!isNaN(n.h)&&n.w>0&&n.h>0)return{zoom:o=(o=(o=Math.min((s-2*t)/n.w,(l-2*t)/n.h))>this._private.maxZoom?this._private.maxZoom:o)=n.minZoom&&(n.maxZoom=t),this},minZoom:function(e){return void 0===e?this._private.minZoom:this.zoomRange({min:e})},maxZoom:function(e){return void 0===e?this._private.maxZoom:this.zoomRange({max:e})},getZoomedViewport:function(e){var t,n,r=this._private,i=r.pan,a=r.zoom,o=!1;if(r.zoomingEnabled||(o=!0),x(e)?n=e:b(e)&&(n=e.level,null!=e.position?t=yt(e.position,a,i):null!=e.renderedPosition&&(t=e.renderedPosition),null==t||r.panningEnabled||(o=!0)),n=(n=n>r.maxZoom?r.maxZoom:n)t.maxZoom||!t.zoomingEnabled?a=!0:(t.zoom=s,i.push("zoom"))}if(r&&(!a||!e.cancelOnFailedZoom)&&t.panningEnabled){var l=e.pan;x(l.x)&&(t.pan.x=l.x,o=!1),x(l.y)&&(t.pan.y=l.y,o=!1),o||i.push("pan")}return i.length>0&&(i.push("viewport"),this.emit(i.join(" ")),this.notify("viewport")),this},center:function(e){var t=this.getCenterPan(e);return t&&(this._private.pan=t,this.emit("pan viewport"),this.notify("viewport")),this},getCenterPan:function(e,t){if(this._private.panningEnabled){if(v(e)){var n=e;e=this.mutableElements().filter(n)}else E(e)||(e=this.mutableElements());if(0!==e.length){var r=e.boundingBox(),i=this.width(),a=this.height();return{x:(i-(t=void 0===t?this._private.zoom:t)*(r.x1+r.x2))/2,y:(a-t*(r.y1+r.y2))/2}}}},reset:function(){return this._private.panningEnabled&&this._private.zoomingEnabled?(this.viewport({pan:{x:0,y:0},zoom:1}),this):this},invalidateSize:function(){this._private.sizeCache=null},size:function(){var e,t,n=this._private,r=n.container,i=this;return n.sizeCache=n.sizeCache||(r?(e=i.window().getComputedStyle(r),t=function(t){return parseFloat(e.getPropertyValue(t))},{width:r.clientWidth-t("padding-left")-t("padding-right"),height:r.clientHeight-t("padding-top")-t("padding-bottom")}):{width:1,height:1})},width:function(){return this.size().width},height:function(){return this.size().height},extent:function(){var e=this._private.pan,t=this._private.zoom,n=this.renderedExtent(),r={x1:(n.x1-e.x)/t,x2:(n.x2-e.x)/t,y1:(n.y1-e.y)/t,y2:(n.y2-e.y)/t};return r.w=r.x2-r.x1,r.h=r.y2-r.y1,r},renderedExtent:function(){var e=this.width(),t=this.height();return{x1:0,y1:0,x2:e,y2:t,w:e,h:t}},multiClickDebounceTime:function(e){return e?(this._private.multiClickDebounceTime=e,this):this._private.multiClickDebounceTime}};As.centre=As.center,As.autolockNodes=As.autolock,As.autoungrabifyNodes=As.autoungrabify;var Ls={data:Fi.data({field:"data",bindingEvent:"data",allowBinding:!0,allowSetting:!0,settingEvent:"data",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,updateStyle:!0}),removeData:Fi.removeData({field:"data",event:"data",triggerFnName:"trigger",triggerEvent:!0,updateStyle:!0}),scratch:Fi.data({field:"scratch",bindingEvent:"scratch",allowBinding:!0,allowSetting:!0,settingEvent:"scratch",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,updateStyle:!0}),removeScratch:Fi.removeData({field:"scratch",event:"scratch",triggerFnName:"trigger",triggerEvent:!0,updateStyle:!0})};Ls.attr=Ls.data,Ls.removeAttr=Ls.removeData;var Os=function(e){var t=this,n=(e=L({},e)).container;n&&!w(n)&&w(n[0])&&(n=n[0]);var r=n?n._cyreg:null;(r=r||{})&&r.cy&&(r.cy.destroy(),r={});var i=r.readies=r.readies||[];n&&(n._cyreg=r),r.cy=t;var a=void 0!==u&&void 0!==n&&!e.headless,o=e;o.layout=L({name:a?"grid":"null"},o.layout),o.renderer=L({name:a?"canvas":"null"},o.renderer);var s=function(e,t,n){return void 0!==t?t:void 0!==n?n:e},l=this._private={container:n,ready:!1,options:o,elements:new ts(this),listeners:[],aniEles:new ts(this),data:o.data||{},scratch:{},layout:null,renderer:null,destroyed:!1,notificationsEnabled:!0,minZoom:1e-50,maxZoom:1e50,zoomingEnabled:s(!0,o.zoomingEnabled),userZoomingEnabled:s(!0,o.userZoomingEnabled),panningEnabled:s(!0,o.panningEnabled),userPanningEnabled:s(!0,o.userPanningEnabled),boxSelectionEnabled:s(!0,o.boxSelectionEnabled),autolock:s(!1,o.autolock,o.autolockNodes),autoungrabify:s(!1,o.autoungrabify,o.autoungrabifyNodes),autounselectify:s(!1,o.autounselectify),styleEnabled:void 0===o.styleEnabled?a:o.styleEnabled,zoom:x(o.zoom)?o.zoom:1,pan:{x:b(o.pan)&&x(o.pan.x)?o.pan.x:0,y:b(o.pan)&&x(o.pan.y)?o.pan.y:0},animation:{current:[],queue:[]},hasCompoundNodes:!1,multiClickDebounceTime:s(250,o.multiClickDebounceTime)};this.createEmitter(),this.selectionType(o.selectionType),this.zoomRange({min:o.minZoom,max:o.maxZoom});l.styleEnabled&&t.setStyle([]);var c=L({},o,o.renderer);t.initRenderer(c);!function(e,t){if(e.some(T))return vr.all(e).then(t);t(e)}([o.style,o.elements],(function(e){var n=e[0],a=e[1];l.styleEnabled&&t.style().append(n),function(e,n,r){t.notifications(!1);var i=t.mutableElements();i.length>0&&i.remove(),null!=e&&(b(e)||m(e))&&t.add(e),t.one("layoutready",(function(e){t.notifications(!0),t.emit(e),t.one("load",n),t.emitAndNotify("load")})).one("layoutstop",(function(){t.one("done",r),t.emit("done")}));var a=L({},t._private.options.layout);a.eles=t.elements(),t.layout(a).run()}(a,(function(){t.startAnimationLoop(),l.ready=!0,y(o.ready)&&t.on("ready",o.ready);for(var e=0;e0,u=_t(n.boundingBox?n.boundingBox:{x1:0,y1:0,w:r.width(),h:r.height()});if(E(n.roots))e=n.roots;else if(m(n.roots)){for(var c=[],d=0;d0;){var N=_.shift(),z=T(N,M);if(z)N.outgoers().filter((function(e){return e.isNode()&&i.has(e)})).forEach(B);else if(null===z){je("Detected double maximal shift for node `"+N.id()+"`. Bailing maximal adjustment due to cycle. Use `options.maximal: true` only on DAGs.");break}}}D();var I=0;if(n.avoidOverlap)for(var L=0;L0&&b[0].length<=3?l/2:0),d=2*Math.PI/b[r].length*i;return 0===r&&1===b[0].length&&(c=1),{x:G+c*Math.cos(d),y:U+c*Math.sin(d)}}return{x:G+(i+1-(a+1)/2)*o,y:(r+1)*s}})),this};var Xs={fit:!0,padding:30,boundingBox:void 0,avoidOverlap:!0,nodeDimensionsIncludeLabels:!1,spacingFactor:void 0,radius:void 0,startAngle:1.5*Math.PI,sweep:void 0,clockwise:!0,sort:void 0,animate:!1,animationDuration:500,animationEasing:void 0,animateFilter:function(e,t){return!0},ready:void 0,stop:void 0,transform:function(e,t){return t}};function Ws(e){this.options=L({},Xs,e)}Ws.prototype.run=function(){var e=this.options,t=e,n=e.cy,r=t.eles,i=void 0!==t.counterclockwise?!t.counterclockwise:t.clockwise,a=r.nodes().not(":parent");t.sort&&(a=a.sort(t.sort));for(var o,s=_t(t.boundingBox?t.boundingBox:{x1:0,y1:0,w:n.width(),h:n.height()}),l=s.x1+s.w/2,u=s.y1+s.h/2,c=(void 0===t.sweep?2*Math.PI-2*Math.PI/a.length:t.sweep)/Math.max(1,a.length-1),d=0,h=0;h1&&t.avoidOverlap){d*=1.75;var v=Math.cos(c)-Math.cos(0),y=Math.sin(c)-Math.sin(0),m=Math.sqrt(d*d/(v*v+y*y));o=Math.max(m,o)}return r.nodes().layoutPositions(this,t,(function(e,n){var r=t.startAngle+n*c*(i?1:-1),a=o*Math.cos(r),s=o*Math.sin(r);return{x:l+a,y:u+s}})),this};var Hs,Ks={fit:!0,padding:30,startAngle:1.5*Math.PI,sweep:void 0,clockwise:!0,equidistant:!1,minNodeSpacing:10,boundingBox:void 0,avoidOverlap:!0,nodeDimensionsIncludeLabels:!1,height:void 0,width:void 0,spacingFactor:void 0,concentric:function(e){return e.degree()},levelWidth:function(e){return e.maxDegree()/4},animate:!1,animationDuration:500,animationEasing:void 0,animateFilter:function(e,t){return!0},ready:void 0,stop:void 0,transform:function(e,t){return t}};function Gs(e){this.options=L({},Ks,e)}Gs.prototype.run=function(){for(var e=this.options,t=e,n=void 0!==t.counterclockwise?!t.counterclockwise:t.clockwise,r=e.cy,i=t.eles,a=i.nodes().not(":parent"),o=_t(t.boundingBox?t.boundingBox:{x1:0,y1:0,w:r.width(),h:r.height()}),s=o.x1+o.w/2,l=o.y1+o.h/2,u=[],c=0,d=0;d0)Math.abs(m[0].value-x.value)>=v&&(m=[],y.push(m));m.push(x)}var w=c+t.minNodeSpacing;if(!t.avoidOverlap){var E=y.length>0&&y[0].length>1,k=(Math.min(o.w,o.h)/2-w)/(y.length+E?1:0);w=Math.min(w,k)}for(var C=0,S=0;S1&&t.avoidOverlap){var _=Math.cos(T)-Math.cos(0),M=Math.sin(T)-Math.sin(0),B=Math.sqrt(w*w/(_*_+M*M));C=Math.max(B,C)}P.r=C,C+=w}if(t.equidistant){for(var N=0,z=0,I=0;I=e.numIter)&&(rl(r,e),r.temperature=r.temperature*e.coolingFactor,!(r.temperature=e.animationThreshold&&a(),xe(t)):(gl(r,e),s())}()}else{for(;u;)u=o(l),l++;gl(r,e),s()}return this},Zs.prototype.stop=function(){return this.stopped=!0,this.thread&&this.thread.stop(),this.emit("layoutstop"),this},Zs.prototype.destroy=function(){return this.thread&&this.thread.stop(),this};var $s=function(e,t,n){for(var r=n.eles.edges(),i=n.eles.nodes(),a=_t(n.boundingBox?n.boundingBox:{x1:0,y1:0,w:e.width(),h:e.height()}),o={isCompound:e.hasCompoundNodes(),layoutNodes:[],idToIndex:{},nodeSize:i.size(),graphSet:[],indexToGraph:[],layoutEdges:[],edgeSize:r.size(),temperature:n.initialTemp,clientWidth:a.w,clientHeight:a.h,boundingBox:a},s=n.eles.components(),l={},u=0;u0){o.graphSet.push(E);for(u=0;ur.count?0:r.graph},Js=function e(t,n,r,i){var a=i.graphSet[r];if(-10)var s=(u=r.nodeOverlap*o)*i/(g=Math.sqrt(i*i+a*a)),l=u*a/g;else{var u,c=ll(e,i,a),d=ll(t,-1*i,-1*a),h=d.x-c.x,p=d.y-c.y,f=h*h+p*p,g=Math.sqrt(f);s=(u=(e.nodeRepulsion+t.nodeRepulsion)/f)*h/g,l=u*p/g}e.isLocked||(e.offsetX-=s,e.offsetY-=l),t.isLocked||(t.offsetX+=s,t.offsetY+=l)}},sl=function(e,t,n,r){if(n>0)var i=e.maxX-t.minX;else i=t.maxX-e.minX;if(r>0)var a=e.maxY-t.minY;else a=t.maxY-e.minY;return i>=0&&a>=0?Math.sqrt(i*i+a*a):0},ll=function(e,t,n){var r=e.positionX,i=e.positionY,a=e.height||1,o=e.width||1,s=n/t,l=a/o,u={};return 0===t&&0n?(u.x=r,u.y=i+a/2,u):0t&&-1*l<=s&&s<=l?(u.x=r-o/2,u.y=i-o*n/2/t,u):0=l)?(u.x=r+a*t/2/n,u.y=i+a/2,u):0>n&&(s<=-1*l||s>=l)?(u.x=r-a*t/2/n,u.y=i-a/2,u):u},ul=function(e,t){for(var n=0;n1){var f=t.gravity*d/p,g=t.gravity*h/p;c.offsetX+=f,c.offsetY+=g}}}}},dl=function(e,t){var n=[],r=0,i=-1;for(n.push.apply(n,e.graphSet[0]),i+=e.graphSet[0].length;r<=i;){var a=n[r++],o=e.idToIndex[a],s=e.layoutNodes[o],l=s.children;if(0n)var i={x:n*e/r,y:n*t/r};else i={x:e,y:t};return i},fl=function e(t,n){var r=t.parentId;if(null!=r){var i=n.layoutNodes[n.idToIndex[r]],a=!1;return(null==i.maxX||t.maxX+i.padRight>i.maxX)&&(i.maxX=t.maxX+i.padRight,a=!0),(null==i.minX||t.minX-i.padLefti.maxY)&&(i.maxY=t.maxY+i.padBottom,a=!0),(null==i.minY||t.minY-i.padTopf&&(d+=p+t.componentSpacing,c=0,h=0,p=0)}}},vl={fit:!0,padding:30,boundingBox:void 0,avoidOverlap:!0,avoidOverlapPadding:10,nodeDimensionsIncludeLabels:!1,spacingFactor:void 0,condense:!1,rows:void 0,cols:void 0,position:function(e){},sort:void 0,animate:!1,animationDuration:500,animationEasing:void 0,animateFilter:function(e,t){return!0},ready:void 0,stop:void 0,transform:function(e,t){return t}};function yl(e){this.options=L({},vl,e)}yl.prototype.run=function(){var e=this.options,t=e,n=e.cy,r=t.eles,i=r.nodes().not(":parent");t.sort&&(i=i.sort(t.sort));var a=_t(t.boundingBox?t.boundingBox:{x1:0,y1:0,w:n.width(),h:n.height()});if(0===a.h||0===a.w)r.nodes().layoutPositions(this,t,(function(e){return{x:a.x1,y:a.y1}}));else{var o=i.size(),s=Math.sqrt(o*a.h/a.w),l=Math.round(s),u=Math.round(a.w/a.h*s),c=function(e){if(null==e)return Math.min(l,u);Math.min(l,u)==l?l=e:u=e},d=function(e){if(null==e)return Math.max(l,u);Math.max(l,u)==l?l=e:u=e},h=t.rows,p=null!=t.cols?t.cols:t.columns;if(null!=h&&null!=p)l=h,u=p;else if(null!=h&&null==p)l=h,u=Math.ceil(o/l);else if(null==h&&null!=p)u=p,l=Math.ceil(o/u);else if(u*l>o){var f=c(),g=d();(f-1)*g>=o?c(f-1):(g-1)*f>=o&&d(g-1)}else for(;u*l=o?d(y+1):c(v+1)}var m=a.w/u,b=a.h/l;if(t.condense&&(m=0,b=0),t.avoidOverlap)for(var x=0;x=u&&(B=0,M++)},z={},I=0;I(r=qt(e,t,x[w],x[w+1],x[w+2],x[w+3])))return v(n,r),!0}else if("bezier"===a.edgeType||"multibezier"===a.edgeType||"self"===a.edgeType||"compound"===a.edgeType)for(x=a.allpts,w=0;w+5(r=jt(e,t,x[w],x[w+1],x[w+2],x[w+3],x[w+4],x[w+5])))return v(n,r),!0;m=m||i.source,b=b||i.target;var E=o.getArrowWidth(l,c),k=[{name:"source",x:a.arrowStartX,y:a.arrowStartY,angle:a.srcArrowAngle},{name:"target",x:a.arrowEndX,y:a.arrowEndY,angle:a.tgtArrowAngle},{name:"mid-source",x:a.midX,y:a.midY,angle:a.midsrcArrowAngle},{name:"mid-target",x:a.midX,y:a.midY,angle:a.midtgtArrowAngle}];for(w=0;w0&&(y(m),y(b))}function b(e,t,n){return Ue(e,t,n)}function x(n,r){var i,a=n._private,o=f;i=r?r+"-":"",n.boundingBox();var s=a.labelBounds[r||"main"],l=n.pstyle(i+"label").value;if("yes"===n.pstyle("text-events").strValue&&l){var u=b(a.rscratch,"labelX",r),c=b(a.rscratch,"labelY",r),d=b(a.rscratch,"labelAngle",r),h=n.pstyle(i+"text-margin-x").pfValue,p=n.pstyle(i+"text-margin-y").pfValue,g=s.x1-o-h,y=s.x2+o-h,m=s.y1-o-p,x=s.y2+o-p;if(d){var w=Math.cos(d),E=Math.sin(d),k=function(e,t){return{x:(e-=u)*w-(t-=c)*E+u,y:e*E+t*w+c}},C=k(g,m),S=k(g,x),P=k(y,m),D=k(y,x),T=[C.x+h,C.y+p,P.x+h,P.y+p,D.x+h,D.y+p,S.x+h,S.y+p];if(Yt(e,t,T))return v(n),!0}else if(Lt(s,e,t))return v(n),!0}}n&&(l=l.interactive);for(var w=l.length-1;w>=0;w--){var E=l[w];E.isNode()?y(E)||x(E):m(E)||x(E)||x(E,"source")||x(E,"target")}return u},getAllInBox:function(e,t,n,r){for(var i,a,o=this.getCachedZSortedEles().interactive,s=[],l=Math.min(e,n),u=Math.max(e,n),c=Math.min(t,r),d=Math.max(t,r),h=_t({x1:e=l,y1:t=c,x2:n=u,y2:r=d}),p=0;p0?-(Math.PI-a.ang):Math.PI+a.ang),Zl(t,n,Ul),zl=Gl.nx*Ul.ny-Gl.ny*Ul.nx,Il=Gl.nx*Ul.nx-Gl.ny*-Ul.ny,Ol=Math.asin(Math.max(-1,Math.min(1,zl))),Math.abs(Ol)<1e-6)return Bl=t.x,Nl=t.y,void(Vl=jl=0);Al=1,Ll=!1,Il<0?Ol<0?Ol=Math.PI+Ol:(Ol=Math.PI-Ol,Al=-1,Ll=!0):Ol>0&&(Al=-1,Ll=!0),jl=void 0!==t.radius?t.radius:r,Rl=Ol/2,ql=Math.min(Gl.len/2,Ul.len/2),i?(Fl=Math.abs(Math.cos(Rl)*jl/Math.sin(Rl)))>ql?(Fl=ql,Vl=Math.abs(Fl*Math.sin(Rl)/Math.cos(Rl))):Vl=jl:(Fl=Math.min(ql,jl),Vl=Math.abs(Fl*Math.sin(Rl)/Math.cos(Rl))),Wl=t.x+Ul.nx*Fl,Hl=t.y+Ul.ny*Fl,Bl=Wl-Ul.ny*Vl*Al,Nl=Hl+Ul.nx*Vl*Al,Yl=t.x+Gl.nx*Fl,Xl=t.y+Gl.ny*Fl,Kl=t};function Ql(e,t){0===t.radius?e.lineTo(t.cx,t.cy):e.arc(t.cx,t.cy,t.radius,t.startAngle,t.endAngle,t.counterClockwise)}function Jl(e,t,n,r){var i=!(arguments.length>4&&void 0!==arguments[4])||arguments[4];return 0===r||0===t.radius?{cx:t.x,cy:t.y,radius:0,startX:t.x,startY:t.y,stopX:t.x,stopY:t.y,startAngle:void 0,endAngle:void 0,counterClockwise:void 0}:($l(e,t,n,r,i),{cx:Bl,cy:Nl,radius:Vl,startX:Yl,startY:Xl,stopX:Wl,stopY:Hl,startAngle:Gl.ang+Math.PI/2*Al,endAngle:Ul.ang-Math.PI/2*Al,counterClockwise:Ll})}var eu={};function tu(e){var t=[];if(null!=e){for(var n=0;n0?Math.max(e-t,0):Math.min(e+t,0)},w=x(m,v),E=x(b,y),k=!1;"auto"===c?u=Math.abs(w)>Math.abs(E)?"horizontal":"vertical":"upward"===c||"downward"===c?(u="vertical",k=!0):"leftward"!==c&&"rightward"!==c||(u="horizontal",k=!0);var C,S="vertical"===u,P=S?E:w,D=S?b:m,T=Et(D),_=!1;(k&&(h||f)||!("downward"===c&&D<0||"upward"===c&&D>0||"leftward"===c&&D>0||"rightward"===c&&D<0)||(P=(T*=-1)*Math.abs(P),_=!0),h)?C=(p<0?1+p:p)*P:C=(p<0?P:0)+p*T;var M=function(e){return Math.abs(e)=Math.abs(P)},B=M(C),N=M(Math.abs(P)-Math.abs(C));if((B||N)&&!_)if(S){var z=Math.abs(D)<=a/2,I=Math.abs(m)<=o/2;if(z){var A=(r.x1+r.x2)/2,L=r.y1,O=r.y2;n.segpts=[A,L,A,O]}else if(I){var R=(r.y1+r.y2)/2,V=r.x1,F=r.x2;n.segpts=[V,R,F,R]}else n.segpts=[r.x1,r.y2]}else{var j=Math.abs(D)<=i/2,q=Math.abs(b)<=s/2;if(j){var Y=(r.y1+r.y2)/2,X=r.x1,W=r.x2;n.segpts=[X,Y,W,Y]}else if(q){var H=(r.x1+r.x2)/2,K=r.y1,G=r.y2;n.segpts=[H,K,H,G]}else n.segpts=[r.x2,r.y1]}else if(S){var U=r.y1+C+(l?a/2*T:0),Z=r.x1,$=r.x2;n.segpts=[Z,U,$,U]}else{var Q=r.x1+C+(l?i/2*T:0),J=r.y1,ee=r.y2;n.segpts=[Q,J,Q,ee]}if(n.isRound){var te=e.pstyle("taxi-radius").value,ne="arc-radius"===e.pstyle("radius-type").value[0];n.radii=new Array(n.segpts.length/2).fill(te),n.isArcRadius=new Array(n.segpts.length/2).fill(ne)}},eu.tryToCorrectInvalidPoints=function(e,t){var n=e._private.rscratch;if("bezier"===n.edgeType){var r=t.srcPos,i=t.tgtPos,a=t.srcW,o=t.srcH,s=t.tgtW,l=t.tgtH,u=t.srcShape,c=t.tgtShape,d=t.srcCornerRadius,h=t.tgtCornerRadius,p=t.srcRs,f=t.tgtRs,g=!x(n.startX)||!x(n.startY),v=!x(n.arrowStartX)||!x(n.arrowStartY),y=!x(n.endX)||!x(n.endY),m=!x(n.arrowEndX)||!x(n.arrowEndY),b=3*(this.getArrowWidth(e.pstyle("width").pfValue,e.pstyle("arrow-scale").value)*this.arrowShapeWidth),w=kt({x:n.ctrlpts[0],y:n.ctrlpts[1]},{x:n.startX,y:n.startY}),E=wh.poolIndex()){var p=d;d=h,h=p}var f=s.srcPos=d.position(),g=s.tgtPos=h.position(),v=s.srcW=d.outerWidth(),y=s.srcH=d.outerHeight(),m=s.tgtW=h.outerWidth(),b=s.tgtH=h.outerHeight(),w=s.srcShape=n.nodeShapes[t.getNodeShape(d)],E=s.tgtShape=n.nodeShapes[t.getNodeShape(h)],k=s.srcCornerRadius="auto"===d.pstyle("corner-radius").value?"auto":d.pstyle("corner-radius").pfValue,C=s.tgtCornerRadius="auto"===h.pstyle("corner-radius").value?"auto":h.pstyle("corner-radius").pfValue,S=s.tgtRs=h._private.rscratch,P=s.srcRs=d._private.rscratch;s.dirCounts={north:0,west:0,south:0,east:0,northwest:0,southwest:0,northeast:0,southeast:0};for(var D=0;D0){var H=u,K=Ct(H,bt(t)),G=Ct(H,bt(W)),U=K;if(G2)Ct(H,{x:W[2],y:W[3]})0){var le=c,ue=Ct(le,bt(t)),ce=Ct(le,bt(se)),de=ue;if(ce2)Ct(le,{x:se[2],y:se[3]})=c||b){d={cp:v,segment:m};break}}if(d)break}var x=d.cp,w=d.segment,E=(c-p)/w.length,k=w.t1-w.t0,C=u?w.t0+k*E:w.t1-k*E;C=Tt(0,C,1),t=Dt(x.p0,x.p1,x.p2,C),l=function(e,t,n,r){var i=Tt(0,r-.001,1),a=Tt(0,r+.001,1),o=Dt(e,t,n,i),s=Dt(e,t,n,a);return su(o,s)}(x.p0,x.p1,x.p2,C);break;case"straight":case"segments":case"haystack":for(var S,P,D,T,_=0,M=r.allpts.length,B=0;B+3=c));B+=2);var N=(c-P)/S;N=Tt(0,N,1),t=function(e,t,n,r){var i=t.x-e.x,a=t.y-e.y,o=kt(e,t),s=i/o,l=a/o;return n=null==n?0:n,r=null!=r?r:n*o,{x:e.x+s*r,y:e.y+l*r}}(D,T,N),l=su(D,T)}o("labelX",s,t.x),o("labelY",s,t.y),o("labelAutoAngle",s,l)}};l("source"),l("target"),this.applyLabelDimensions(e)}},au.applyLabelDimensions=function(e){this.applyPrefixedLabelDimensions(e),e.isEdge()&&(this.applyPrefixedLabelDimensions(e,"source"),this.applyPrefixedLabelDimensions(e,"target"))},au.applyPrefixedLabelDimensions=function(e,t){var n=e._private,r=this.getLabelText(e,t),i=this.calculateLabelDimensions(e,r),a=e.pstyle("line-height").pfValue,o=e.pstyle("text-wrap").strValue,s=Ue(n.rscratch,"labelWrapCachedLines",t)||[],l="wrap"!==o?1:Math.max(s.length,1),u=i.height/l,c=u*a,d=i.width,h=i.height+(l-1)*(a-1)*u;Ze(n.rstyle,"labelWidth",t,d),Ze(n.rscratch,"labelWidth",t,d),Ze(n.rstyle,"labelHeight",t,h),Ze(n.rscratch,"labelHeight",t,h),Ze(n.rscratch,"labelLineHeight",t,c)},au.getLabelText=function(e,t){var n=e._private,r=t?t+"-":"",i=e.pstyle(r+"label").strValue,a=e.pstyle("text-transform").value,o=function(e,r){return r?(Ze(n.rscratch,e,t,r),r):Ue(n.rscratch,e,t)};if(!i)return"";"none"==a||("uppercase"==a?i=i.toUpperCase():"lowercase"==a&&(i=i.toLowerCase()));var s=e.pstyle("text-wrap").value;if("wrap"===s){var u=o("labelKey");if(null!=u&&o("labelWrapKey")===u)return o("labelWrapCachedText");for(var c=i.split("\n"),d=e.pstyle("text-max-width").pfValue,h="anywhere"===e.pstyle("text-overflow-wrap").value,p=[],f=/[\s\u200b]+|$/g,g=0;gd){var b,x="",w=0,E=l(v.matchAll(f));try{for(E.s();!(b=E.n()).done;){var k=b.value,C=k[0],S=v.substring(w,k.index);w=k.index+C.length;var P=0===x.length?S:x+S+C;this.calculateLabelDimensions(e,P).width<=d?x+=S+C:(x&&p.push(x),x=S+C)}}catch(e){E.e(e)}finally{E.f()}x.match(/^[\s\u200b]+$/)||p.push(x)}else p.push(v)}o("labelWrapCachedLines",p),i=o("labelWrapCachedText",p.join("\n")),o("labelWrapKey",u)}else if("ellipsis"===s){var D=e.pstyle("text-max-width").pfValue,T="",_=!1;if(this.calculateLabelDimensions(e,i).widthD)break;T+=i[M],M===i.length-1&&(_=!0)}return _||(T+="…"),T}return i},au.getLabelJustification=function(e){var t=e.pstyle("text-justification").strValue,n=e.pstyle("text-halign").strValue;if("auto"!==t)return t;if(!e.isNode())return"center";switch(n){case"left":return"right";case"right":return"left";default:return"center"}},au.calculateLabelDimensions=function(e,t){var n=this,r=n.cy.window().document,i=Te(t,e._private.labelDimsKey),a=n.labelDimCache||(n.labelDimCache=[]),o=a[i];if(null!=o)return o;var s=e.pstyle("font-style").strValue,l=e.pstyle("font-size").pfValue,u=e.pstyle("font-family").strValue,c=e.pstyle("font-weight").strValue,d=this.labelCalcCanvas,h=this.labelCalcCanvasContext;if(!d){d=this.labelCalcCanvas=r.createElement("canvas"),h=this.labelCalcCanvasContext=d.getContext("2d");var p=d.style;p.position="absolute",p.left="-9999px",p.top="-9999px",p.zIndex="-1",p.visibility="hidden",p.pointerEvents="none"}h.font="".concat(s," ").concat(c," ").concat(l,"px ").concat(u);for(var f=0,g=0,v=t.split("\n"),y=0;y1&&void 0!==arguments[1])||arguments[1];if(t.merge(e),n)for(var r=0;r=e.desktopTapThreshold2}var D=i(t);v&&(e.hoverData.tapholdCancelled=!0);n=!0,r(g,["mousemove","vmousemove","tapdrag"],t,{x:c[0],y:c[1]});var T=function(){e.data.bgActivePosistion=void 0,e.hoverData.selecting||o.emit({originalEvent:t,type:"boxstart",position:{x:c[0],y:c[1]}}),f[4]=1,e.hoverData.selecting=!0,e.redrawHint("select",!0),e.redraw()};if(3===e.hoverData.which){if(v){var _={originalEvent:t,type:"cxtdrag",position:{x:c[0],y:c[1]}};m?m.emit(_):o.emit(_),e.hoverData.cxtDragged=!0,e.hoverData.cxtOver&&g===e.hoverData.cxtOver||(e.hoverData.cxtOver&&e.hoverData.cxtOver.emit({originalEvent:t,type:"cxtdragout",position:{x:c[0],y:c[1]}}),e.hoverData.cxtOver=g,g&&g.emit({originalEvent:t,type:"cxtdragover",position:{x:c[0],y:c[1]}}))}}else if(e.hoverData.dragging){if(n=!0,o.panningEnabled()&&o.userPanningEnabled()){var M;if(e.hoverData.justStartedPan){var B=e.hoverData.mdownPos;M={x:(c[0]-B[0])*s,y:(c[1]-B[1])*s},e.hoverData.justStartedPan=!1}else M={x:b[0]*s,y:b[1]*s};o.panBy(M),o.emit("dragpan"),e.hoverData.dragged=!0}c=e.projectIntoViewport(t.clientX,t.clientY)}else if(1!=f[4]||null!=m&&!m.pannable()){if(m&&m.pannable()&&m.active()&&m.unactivate(),m&&m.grabbed()||g==y||(y&&r(y,["mouseout","tapdragout"],t,{x:c[0],y:c[1]}),g&&r(g,["mouseover","tapdragover"],t,{x:c[0],y:c[1]}),e.hoverData.last=g),m)if(v){if(o.boxSelectionEnabled()&&D)m&&m.grabbed()&&(d(w),m.emit("freeon"),w.emit("free"),e.dragData.didDrag&&(m.emit("dragfreeon"),w.emit("dragfree"))),T();else if(m&&m.grabbed()&&e.nodeIsDraggable(m)){var N=!e.dragData.didDrag;N&&e.redrawHint("eles",!0),e.dragData.didDrag=!0,e.hoverData.draggingEles||u(w,{inDragLayer:!0});var z={x:0,y:0};if(x(b[0])&&x(b[1])&&(z.x+=b[0],z.y+=b[1],N)){var I=e.hoverData.dragDelta;I&&x(I[0])&&x(I[1])&&(z.x+=I[0],z.y+=I[1])}e.hoverData.draggingEles=!0,w.silentShift(z).emit("position drag"),e.redrawHint("drag",!0),e.redraw()}}else!function(){var t=e.hoverData.dragDelta=e.hoverData.dragDelta||[];0===t.length?(t.push(b[0]),t.push(b[1])):(t[0]+=b[0],t[1]+=b[1])}();n=!0}else if(v){if(e.hoverData.dragging||!o.boxSelectionEnabled()||!D&&o.panningEnabled()&&o.userPanningEnabled()){if(!e.hoverData.selecting&&o.panningEnabled()&&o.userPanningEnabled()){a(m,e.hoverData.downs)&&(e.hoverData.dragging=!0,e.hoverData.justStartedPan=!0,f[4]=0,e.data.bgActivePosistion=bt(h),e.redrawHint("select",!0),e.redraw())}}else T();m&&m.pannable()&&m.active()&&m.unactivate()}return f[2]=c[0],f[3]=c[1],n?(t.stopPropagation&&t.stopPropagation(),t.preventDefault&&t.preventDefault(),!1):void 0}}),!1),e.registerBinding(t,"mouseup",(function(t){if((1!==e.hoverData.which||1===t.which||!e.hoverData.capture)&&e.hoverData.capture){e.hoverData.capture=!1;var a=e.cy,o=e.projectIntoViewport(t.clientX,t.clientY),s=e.selection,l=e.findNearestElement(o[0],o[1],!0,!1),u=e.dragData.possibleDragElements,c=e.hoverData.down,h=i(t);if(e.data.bgActivePosistion&&(e.redrawHint("select",!0),e.redraw()),e.hoverData.tapholdCancelled=!0,e.data.bgActivePosistion=void 0,c&&c.unactivate(),3===e.hoverData.which){var p={originalEvent:t,type:"cxttapend",position:{x:o[0],y:o[1]}};if(c?c.emit(p):a.emit(p),!e.hoverData.cxtDragged){var f={originalEvent:t,type:"cxttap",position:{x:o[0],y:o[1]}};c?c.emit(f):a.emit(f)}e.hoverData.cxtDragged=!1,e.hoverData.which=null}else if(1===e.hoverData.which){if(r(l,["mouseup","tapend","vmouseup"],t,{x:o[0],y:o[1]}),e.dragData.didDrag||e.hoverData.dragged||e.hoverData.selecting||e.hoverData.isOverThresholdDrag||(r(c,["click","tap","vclick"],t,{x:o[0],y:o[1]}),b=!1,t.timeStamp-w<=a.multiClickDebounceTime()?(m&&clearTimeout(m),b=!0,w=null,r(c,["dblclick","dbltap","vdblclick"],t,{x:o[0],y:o[1]})):(m=setTimeout((function(){b||r(c,["oneclick","onetap","voneclick"],t,{x:o[0],y:o[1]})}),a.multiClickDebounceTime()),w=t.timeStamp)),null!=c||e.dragData.didDrag||e.hoverData.selecting||e.hoverData.dragged||i(t)||(a.$(n).unselect(["tapunselect"]),u.length>0&&e.redrawHint("eles",!0),e.dragData.possibleDragElements=u=a.collection()),l!=c||e.dragData.didDrag||e.hoverData.selecting||null!=l&&l._private.selectable&&(e.hoverData.dragging||("additive"===a.selectionType()||h?l.selected()?l.unselect(["tapunselect"]):l.select(["tapselect"]):h||(a.$(n).unmerge(l).unselect(["tapunselect"]),l.select(["tapselect"]))),e.redrawHint("eles",!0)),e.hoverData.selecting){var g=a.collection(e.getAllInBox(s[0],s[1],s[2],s[3]));e.redrawHint("select",!0),g.length>0&&e.redrawHint("eles",!0),a.emit({type:"boxend",originalEvent:t,position:{x:o[0],y:o[1]}});var v=function(e){return e.selectable()&&!e.selected()};"additive"===a.selectionType()||h||a.$(n).unmerge(g).unselect(),g.emit("box").stdFilter(v).select().emit("boxselect"),e.redraw()}if(e.hoverData.dragging&&(e.hoverData.dragging=!1,e.redrawHint("select",!0),e.redrawHint("eles",!0),e.redraw()),!s[4]){e.redrawHint("drag",!0),e.redrawHint("eles",!0);var y=c&&c.grabbed();d(u),y&&(c.emit("freeon"),u.emit("free"),e.dragData.didDrag&&(c.emit("dragfreeon"),u.emit("dragfree")))}}s[4]=0,e.hoverData.down=null,e.hoverData.cxtStarted=!1,e.hoverData.draggingEles=!1,e.hoverData.selecting=!1,e.hoverData.isOverThresholdDrag=!1,e.dragData.didDrag=!1,e.hoverData.dragged=!1,e.hoverData.dragDelta=[],e.hoverData.mdownPos=null,e.hoverData.mdownGPos=null,e.hoverData.which=null}}),!1);var k,C,S,P,D,T,_,M,B,N,z,I,A,L=function(t){if(!e.scrollingPage){var n=e.cy,r=n.zoom(),i=n.pan(),a=e.projectIntoViewport(t.clientX,t.clientY),o=[a[0]*r+i.x,a[1]*r+i.y];if(e.hoverData.draggingEles||e.hoverData.dragging||e.hoverData.cxtStarted||0!==e.selection[4])t.preventDefault();else if(n.panningEnabled()&&n.userPanningEnabled()&&n.zoomingEnabled()&&n.userZoomingEnabled()){var s;t.preventDefault(),e.data.wheelZooming=!0,clearTimeout(e.data.wheelTimeout),e.data.wheelTimeout=setTimeout((function(){e.data.wheelZooming=!1,e.redrawHint("eles",!0),e.redraw()}),150),s=null!=t.deltaY?t.deltaY/-250:null!=t.wheelDeltaY?t.wheelDeltaY/1e3:t.wheelDelta/1e3,s*=e.wheelSensitivity,1===t.deltaMode&&(s*=33);var l=n.zoom()*Math.pow(10,s);"gesturechange"===t.type&&(l=e.gestureStartZoom*t.scale),n.zoom({level:l,renderedPosition:{x:o[0],y:o[1]}}),n.emit("gesturechange"===t.type?"pinchzoom":"scrollzoom")}}};e.registerBinding(e.container,"wheel",L,!0),e.registerBinding(t,"scroll",(function(t){e.scrollingPage=!0,clearTimeout(e.scrollingPageTimeout),e.scrollingPageTimeout=setTimeout((function(){e.scrollingPage=!1}),250)}),!0),e.registerBinding(e.container,"gesturestart",(function(t){e.gestureStartZoom=e.cy.zoom(),e.hasTouchStarted||t.preventDefault()}),!0),e.registerBinding(e.container,"gesturechange",(function(t){e.hasTouchStarted||L(t)}),!0),e.registerBinding(e.container,"mouseout",(function(t){var n=e.projectIntoViewport(t.clientX,t.clientY);e.cy.emit({originalEvent:t,type:"mouseout",position:{x:n[0],y:n[1]}})}),!1),e.registerBinding(e.container,"mouseover",(function(t){var n=e.projectIntoViewport(t.clientX,t.clientY);e.cy.emit({originalEvent:t,type:"mouseover",position:{x:n[0],y:n[1]}})}),!1);var O,R,V,F,j,q,Y,X=function(e,t,n,r){return Math.sqrt((n-e)*(n-e)+(r-t)*(r-t))},W=function(e,t,n,r){return(n-e)*(n-e)+(r-t)*(r-t)};if(e.registerBinding(e.container,"touchstart",O=function(t){if(e.hasTouchStarted=!0,E(t)){p(),e.touchData.capture=!0,e.data.bgActivePosistion=void 0;var n=e.cy,i=e.touchData.now,a=e.touchData.earlier;if(t.touches[0]){var o=e.projectIntoViewport(t.touches[0].clientX,t.touches[0].clientY);i[0]=o[0],i[1]=o[1]}if(t.touches[1]){o=e.projectIntoViewport(t.touches[1].clientX,t.touches[1].clientY);i[2]=o[0],i[3]=o[1]}if(t.touches[2]){o=e.projectIntoViewport(t.touches[2].clientX,t.touches[2].clientY);i[4]=o[0],i[5]=o[1]}if(t.touches[1]){e.touchData.singleTouchMoved=!0,d(e.dragData.touchDragEles);var l=e.findContainerClientCoords();B=l[0],N=l[1],z=l[2],I=l[3],k=t.touches[0].clientX-B,C=t.touches[0].clientY-N,S=t.touches[1].clientX-B,P=t.touches[1].clientY-N,A=0<=k&&k<=z&&0<=S&&S<=z&&0<=C&&C<=I&&0<=P&&P<=I;var h=n.pan(),f=n.zoom();D=X(k,C,S,P),T=W(k,C,S,P),M=[((_=[(k+S)/2,(C+P)/2])[0]-h.x)/f,(_[1]-h.y)/f];if(T<4e4&&!t.touches[2]){var g=e.findNearestElement(i[0],i[1],!0,!0),v=e.findNearestElement(i[2],i[3],!0,!0);return g&&g.isNode()?(g.activate().emit({originalEvent:t,type:"cxttapstart",position:{x:i[0],y:i[1]}}),e.touchData.start=g):v&&v.isNode()?(v.activate().emit({originalEvent:t,type:"cxttapstart",position:{x:i[0],y:i[1]}}),e.touchData.start=v):n.emit({originalEvent:t,type:"cxttapstart",position:{x:i[0],y:i[1]}}),e.touchData.start&&(e.touchData.start._private.grabbed=!1),e.touchData.cxt=!0,e.touchData.cxtDragged=!1,e.data.bgActivePosistion=void 0,void e.redraw()}}if(t.touches[2])n.boxSelectionEnabled()&&t.preventDefault();else if(t.touches[1]);else if(t.touches[0]){var y=e.findNearestElements(i[0],i[1],!0,!0),m=y[0];if(null!=m&&(m.activate(),e.touchData.start=m,e.touchData.starts=y,e.nodeIsGrabbable(m))){var b=e.dragData.touchDragEles=n.collection(),x=null;e.redrawHint("eles",!0),e.redrawHint("drag",!0),m.selected()?(x=n.$((function(t){return t.selected()&&e.nodeIsGrabbable(t)})),u(x,{addToList:b})):c(m,{addToList:b}),s(m);var w=function(e){return{originalEvent:t,type:e,position:{x:i[0],y:i[1]}}};m.emit(w("grabon")),x?x.forEach((function(e){e.emit(w("grab"))})):m.emit(w("grab"))}r(m,["touchstart","tapstart","vmousedown"],t,{x:i[0],y:i[1]}),null==m&&(e.data.bgActivePosistion={x:o[0],y:o[1]},e.redrawHint("select",!0),e.redraw()),e.touchData.singleTouchMoved=!1,e.touchData.singleTouchStartTime=+new Date,clearTimeout(e.touchData.tapholdTimeout),e.touchData.tapholdTimeout=setTimeout((function(){!1!==e.touchData.singleTouchMoved||e.pinching||e.touchData.selecting||r(e.touchData.start,["taphold"],t,{x:i[0],y:i[1]})}),e.tapholdDuration)}if(t.touches.length>=1){for(var L=e.touchData.startPosition=[null,null,null,null,null,null],O=0;O=e.touchTapThreshold2}if(n&&e.touchData.cxt){t.preventDefault();var w=t.touches[0].clientX-B,_=t.touches[0].clientY-N,z=t.touches[1].clientX-B,I=t.touches[1].clientY-N,L=W(w,_,z,I);if(L/T>=2.25||L>=22500){e.touchData.cxt=!1,e.data.bgActivePosistion=void 0,e.redrawHint("select",!0);var O={originalEvent:t,type:"cxttapend",position:{x:s[0],y:s[1]}};e.touchData.start?(e.touchData.start.unactivate().emit(O),e.touchData.start=null):o.emit(O)}}if(n&&e.touchData.cxt){O={originalEvent:t,type:"cxtdrag",position:{x:s[0],y:s[1]}};e.data.bgActivePosistion=void 0,e.redrawHint("select",!0),e.touchData.start?e.touchData.start.emit(O):o.emit(O),e.touchData.start&&(e.touchData.start._private.grabbed=!1),e.touchData.cxtDragged=!0;var R=e.findNearestElement(s[0],s[1],!0,!0);e.touchData.cxtOver&&R===e.touchData.cxtOver||(e.touchData.cxtOver&&e.touchData.cxtOver.emit({originalEvent:t,type:"cxtdragout",position:{x:s[0],y:s[1]}}),e.touchData.cxtOver=R,R&&R.emit({originalEvent:t,type:"cxtdragover",position:{x:s[0],y:s[1]}}))}else if(n&&t.touches[2]&&o.boxSelectionEnabled())t.preventDefault(),e.data.bgActivePosistion=void 0,this.lastThreeTouch=+new Date,e.touchData.selecting||o.emit({originalEvent:t,type:"boxstart",position:{x:s[0],y:s[1]}}),e.touchData.selecting=!0,e.touchData.didSelect=!0,i[4]=1,i&&0!==i.length&&void 0!==i[0]?(i[2]=(s[0]+s[2]+s[4])/3,i[3]=(s[1]+s[3]+s[5])/3):(i[0]=(s[0]+s[2]+s[4])/3,i[1]=(s[1]+s[3]+s[5])/3,i[2]=(s[0]+s[2]+s[4])/3+1,i[3]=(s[1]+s[3]+s[5])/3+1),e.redrawHint("select",!0),e.redraw();else if(n&&t.touches[1]&&!e.touchData.didSelect&&o.zoomingEnabled()&&o.panningEnabled()&&o.userZoomingEnabled()&&o.userPanningEnabled()){if(t.preventDefault(),e.data.bgActivePosistion=void 0,e.redrawHint("select",!0),ee=e.dragData.touchDragEles){e.redrawHint("drag",!0);for(var V=0;V0&&!e.hoverData.draggingEles&&!e.swipePanning&&null!=e.data.bgActivePosistion&&(e.data.bgActivePosistion=void 0,e.redrawHint("select",!0),e.redraw())}},!1),e.registerBinding(t,"touchcancel",V=function(t){var n=e.touchData.start;e.touchData.capture=!1,n&&n.unactivate()}),e.registerBinding(t,"touchend",F=function(t){var i=e.touchData.start;if(e.touchData.capture){0===t.touches.length&&(e.touchData.capture=!1),t.preventDefault();var a=e.selection;e.swipePanning=!1,e.hoverData.draggingEles=!1;var o,s=e.cy,l=s.zoom(),u=e.touchData.now,c=e.touchData.earlier;if(t.touches[0]){var h=e.projectIntoViewport(t.touches[0].clientX,t.touches[0].clientY);u[0]=h[0],u[1]=h[1]}if(t.touches[1]){h=e.projectIntoViewport(t.touches[1].clientX,t.touches[1].clientY);u[2]=h[0],u[3]=h[1]}if(t.touches[2]){h=e.projectIntoViewport(t.touches[2].clientX,t.touches[2].clientY);u[4]=h[0],u[5]=h[1]}if(i&&i.unactivate(),e.touchData.cxt){if(o={originalEvent:t,type:"cxttapend",position:{x:u[0],y:u[1]}},i?i.emit(o):s.emit(o),!e.touchData.cxtDragged){var p={originalEvent:t,type:"cxttap",position:{x:u[0],y:u[1]}};i?i.emit(p):s.emit(p)}return e.touchData.start&&(e.touchData.start._private.grabbed=!1),e.touchData.cxt=!1,e.touchData.start=null,void e.redraw()}if(!t.touches[2]&&s.boxSelectionEnabled()&&e.touchData.selecting){e.touchData.selecting=!1;var f=s.collection(e.getAllInBox(a[0],a[1],a[2],a[3]));a[0]=void 0,a[1]=void 0,a[2]=void 0,a[3]=void 0,a[4]=0,e.redrawHint("select",!0),s.emit({type:"boxend",originalEvent:t,position:{x:u[0],y:u[1]}});f.emit("box").stdFilter((function(e){return e.selectable()&&!e.selected()})).select().emit("boxselect"),f.nonempty()&&e.redrawHint("eles",!0),e.redraw()}if(null!=i&&i.unactivate(),t.touches[2])e.data.bgActivePosistion=void 0,e.redrawHint("select",!0);else if(t.touches[1]);else if(t.touches[0]);else if(!t.touches[0]){e.data.bgActivePosistion=void 0,e.redrawHint("select",!0);var g=e.dragData.touchDragEles;if(null!=i){var v=i._private.grabbed;d(g),e.redrawHint("drag",!0),e.redrawHint("eles",!0),v&&(i.emit("freeon"),g.emit("free"),e.dragData.didDrag&&(i.emit("dragfreeon"),g.emit("dragfree"))),r(i,["touchend","tapend","vmouseup","tapdragout"],t,{x:u[0],y:u[1]}),i.unactivate(),e.touchData.start=null}else{var y=e.findNearestElement(u[0],u[1],!0,!0);r(y,["touchend","tapend","vmouseup","tapdragout"],t,{x:u[0],y:u[1]})}var m=e.touchData.startPosition[0]-u[0],b=m*m,x=e.touchData.startPosition[1]-u[1],w=(b+x*x)*l*l;e.touchData.singleTouchMoved||(i||s.$(":selected").unselect(["tapunselect"]),r(i,["tap","vclick"],t,{x:u[0],y:u[1]}),j=!1,t.timeStamp-Y<=s.multiClickDebounceTime()?(q&&clearTimeout(q),j=!0,Y=null,r(i,["dbltap","vdblclick"],t,{x:u[0],y:u[1]})):(q=setTimeout((function(){j||r(i,["onetap","voneclick"],t,{x:u[0],y:u[1]})}),s.multiClickDebounceTime()),Y=t.timeStamp)),null!=i&&!e.dragData.didDrag&&i._private.selectable&&w2){for(var p=[c[0],c[1]],f=Math.pow(p[0]-e,2)+Math.pow(p[1]-t,2),g=1;g0)return g[0]}return null},p=Object.keys(d),f=0;f0?u:Rt(i,a,e,t,n,r,o,s)},checkPoint:function(e,t,n,r,i,a,o,s){var l=2*(s="auto"===s?nn(r,i):s);if(Xt(e,t,this.points,a,o,r,i-l,[0,-1],n))return!0;if(Xt(e,t,this.points,a,o,r-l,i,[0,-1],n))return!0;var u=r/2+2*n,c=i/2+2*n;return!!Yt(e,t,[a-u,o-c,a-u,o,a+u,o,a+u,o-c])||(!!Kt(e,t,l,l,a+r/2-s,o+i/2-s,n)||!!Kt(e,t,l,l,a-r/2+s,o+i/2-s,n))}}},gu.registerNodeShapes=function(){var e=this.nodeShapes={},t=this;this.generateEllipse(),this.generatePolygon("triangle",Jt(3,0)),this.generateRoundPolygon("round-triangle",Jt(3,0)),this.generatePolygon("rectangle",Jt(4,0)),e.square=e.rectangle,this.generateRoundRectangle(),this.generateCutRectangle(),this.generateBarrel(),this.generateBottomRoundrectangle();var n=[0,1,1,0,0,-1,-1,0];this.generatePolygon("diamond",n),this.generateRoundPolygon("round-diamond",n),this.generatePolygon("pentagon",Jt(5,0)),this.generateRoundPolygon("round-pentagon",Jt(5,0)),this.generatePolygon("hexagon",Jt(6,0)),this.generateRoundPolygon("round-hexagon",Jt(6,0)),this.generatePolygon("heptagon",Jt(7,0)),this.generateRoundPolygon("round-heptagon",Jt(7,0)),this.generatePolygon("octagon",Jt(8,0)),this.generateRoundPolygon("round-octagon",Jt(8,0));var r=new Array(20),i=tn(5,0),a=tn(5,Math.PI/5),o=.5*(3-Math.sqrt(5));o*=1.57;for(var s=0;s=e.deqFastCost*g)break}else if(i){if(p>=e.deqCost*l||p>=e.deqAvgCost*s)break}else if(f>=e.deqNoDrawCost*(1e3/60))break;var v=e.deq(t,d,c);if(!(v.length>0))break;for(var y=0;y0&&(e.onDeqd(t,u),!i&&e.shouldRedraw(t,u,d,c)&&r())}),i(t))}}},wu=function(){function e(n){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:Le;t(this,e),this.idsByKey=new $e,this.keyForId=new $e,this.cachesByLvl=new $e,this.lvls=[],this.getKey=n,this.doesEleInvalidateKey=r}return r(e,[{key:"getIdsFor",value:function(e){null==e&&Ve("Can not get id list for null key");var t=this.idsByKey,n=this.idsByKey.get(e);return n||(n=new Je,t.set(e,n)),n}},{key:"addIdForKey",value:function(e,t){null!=e&&this.getIdsFor(e).add(t)}},{key:"deleteIdForKey",value:function(e,t){null!=e&&this.getIdsFor(e).delete(t)}},{key:"getNumberOfIdsForKey",value:function(e){return null==e?0:this.getIdsFor(e).size}},{key:"updateKeyMappingFor",value:function(e){var t=e.id(),n=this.keyForId.get(t),r=this.getKey(e);this.deleteIdForKey(n,t),this.addIdForKey(r,t),this.keyForId.set(t,r)}},{key:"deleteKeyMappingFor",value:function(e){var t=e.id(),n=this.keyForId.get(t);this.deleteIdForKey(n,t),this.keyForId.delete(t)}},{key:"keyHasChangedFor",value:function(e){var t=e.id();return this.keyForId.get(t)!==this.getKey(e)}},{key:"isInvalid",value:function(e){return this.keyHasChangedFor(e)||this.doesEleInvalidateKey(e)}},{key:"getCachesAt",value:function(e){var t=this.cachesByLvl,n=this.lvls,r=t.get(e);return r||(r=new $e,t.set(e,r),n.push(e)),r}},{key:"getCache",value:function(e,t){return this.getCachesAt(t).get(e)}},{key:"get",value:function(e,t){var n=this.getKey(e),r=this.getCache(n,t);return null!=r&&this.updateKeyMappingFor(e),r}},{key:"getForCachedKey",value:function(e,t){var n=this.keyForId.get(e.id());return this.getCache(n,t)}},{key:"hasCache",value:function(e,t){return this.getCachesAt(t).has(e)}},{key:"has",value:function(e,t){var n=this.getKey(e);return this.hasCache(n,t)}},{key:"setCache",value:function(e,t,n){n.key=e,this.getCachesAt(t).set(e,n)}},{key:"set",value:function(e,t,n){var r=this.getKey(e);this.setCache(r,t,n),this.updateKeyMappingFor(e)}},{key:"deleteCache",value:function(e,t){this.getCachesAt(t).delete(e)}},{key:"delete",value:function(e,t){var n=this.getKey(e);this.deleteCache(n,t)}},{key:"invalidateKey",value:function(e){var t=this;this.lvls.forEach((function(n){return t.deleteCache(e,n)}))}},{key:"invalidate",value:function(e){var t=e.id(),n=this.keyForId.get(t);this.deleteKeyMappingFor(e);var r=this.doesEleInvalidateKey(e);return r&&this.invalidateKey(n),r||0===this.getNumberOfIdsForKey(n)}}]),e}(),Eu={dequeue:"dequeue",downscale:"downscale",highQuality:"highQuality"},ku=He({getKey:null,doesEleInvalidateKey:Le,drawElement:null,getBoundingBox:null,getRotationPoint:null,getRotationOffset:null,isVisible:Ae,allowEdgeTxrCaching:!0,allowParentTxrCaching:!0}),Cu=function(e,t){this.renderer=e,this.onDequeues=[];var n=ku(t);L(this,n),this.lookup=new wu(n.getKey,n.doesEleInvalidateKey),this.setupDequeueing()},Su=Cu.prototype;Su.reasons=Eu,Su.getTextureQueue=function(e){return this.eleImgCaches=this.eleImgCaches||{},this.eleImgCaches[e]=this.eleImgCaches[e]||[]},Su.getRetiredTextureQueue=function(e){var t=this.eleImgCaches.retired=this.eleImgCaches.retired||{};return t[e]=t[e]||[]},Su.getElementQueue=function(){return this.eleCacheQueue=this.eleCacheQueue||new rt((function(e,t){return t.reqs-e.reqs}))},Su.getElementKeyToQueue=function(){return this.eleKeyToCacheQueue=this.eleKeyToCacheQueue||{}},Su.getElement=function(e,t,n,r,i){var a=this,o=this.renderer,s=o.cy.zoom(),l=this.lookup;if(!t||0===t.w||0===t.h||isNaN(t.w)||isNaN(t.h)||!e.visible()||e.removed())return null;if(!a.allowEdgeTxrCaching&&e.isEdge()||!a.allowParentTxrCaching&&e.isParent())return null;if(null==r&&(r=Math.ceil(wt(s*n))),r<-4)r=-4;else if(s>=7.99||r>3)return null;var u=Math.pow(2,r),c=t.h*u,d=t.w*u,h=o.eleTextBiggerThanMin(e,u);if(!this.isVisible(e,h))return null;var p,f=l.get(e,r);if(f&&f.invalidated&&(f.invalidated=!1,f.texture.invalidatedWidth-=f.width),f)return f;if(p=c<=25?25:c<=50?50:50*Math.ceil(c/50),c>1024||d>1024)return null;var g=a.getTextureQueue(p),v=g[g.length-2],y=function(){return a.recycleTexture(p,d)||a.addTexture(p,d)};v||(v=g[g.length-1]),v||(v=y()),v.width-v.usedWidthr;D--)S=a.getElement(e,t,n,D,Eu.downscale);P()}else{var T;if(!x&&!w&&!E)for(var _=r-1;_>=-4;_--){var M=l.get(e,_);if(M){T=M;break}}if(b(T))return a.queueElement(e,r),T;v.context.translate(v.usedWidth,0),v.context.scale(u,u),this.drawElement(v.context,e,t,h,!1),v.context.scale(1/u,1/u),v.context.translate(-v.usedWidth,0)}return f={x:v.usedWidth,texture:v,level:r,scale:u,width:d,height:c,scaledLabelShown:h},v.usedWidth+=Math.ceil(d+8),v.eleCaches.push(f),l.set(e,r,f),a.checkTextureFullness(v),f},Su.invalidateElements=function(e){for(var t=0;t=.2*e.width&&this.retireTexture(e)},Su.checkTextureFullness=function(e){var t=this.getTextureQueue(e.height);e.usedWidth/e.width>.8&&e.fullnessChecks>=10?Ke(t,e):e.fullnessChecks++},Su.retireTexture=function(e){var t=e.height,n=this.getTextureQueue(t),r=this.lookup;Ke(n,e),e.retired=!0;for(var i=e.eleCaches,a=0;a=t)return a.retired=!1,a.usedWidth=0,a.invalidatedWidth=0,a.fullnessChecks=0,Ge(a.eleCaches),a.context.setTransform(1,0,0,1,0,0),a.context.clearRect(0,0,a.width,a.height),Ke(r,a),n.push(a),a}},Su.queueElement=function(e,t){var n=this.getElementQueue(),r=this.getElementKeyToQueue(),i=this.getKey(e),a=r[i];if(a)a.level=Math.max(a.level,t),a.eles.merge(e),a.reqs++,n.updateItem(a);else{var o={eles:e.spawn().merge(e),level:t,reqs:1,key:i};n.push(o),r[i]=o}},Su.dequeue=function(e){for(var t=this.getElementQueue(),n=this.getElementKeyToQueue(),r=[],i=this.lookup,a=0;a<1&&t.size()>0;a++){var o=t.pop(),s=o.key,l=o.eles[0],u=i.hasCache(l,o.level);if(n[s]=null,!u){r.push(o);var c=this.getBoundingBox(l);this.getElement(l,c,e,o.level,Eu.dequeue)}}return r},Su.removeFromQueue=function(e){var t=this.getElementQueue(),n=this.getElementKeyToQueue(),r=this.getKey(e),i=n[r];null!=i&&(1===i.eles.length?(i.reqs=Ie,t.updateItem(i),t.pop(),n[r]=null):i.eles.unmerge(e))},Su.onDequeue=function(e){this.onDequeues.push(e)},Su.offDequeue=function(e){Ke(this.onDequeues,e)},Su.setupDequeueing=xu({deqRedrawThreshold:100,deqCost:.15,deqAvgCost:.1,deqNoDrawCost:.9,deqFastCost:.9,deq:function(e,t,n){return e.dequeue(t,n)},onDeqd:function(e,t){for(var n=0;n=3.99||n>2)return null;r.validateLayersElesOrdering(n,e);var o,s,l=r.layersByLevel,u=Math.pow(2,n),c=l[n]=l[n]||[];if(r.levelIsComplete(n,e))return c;!function(){var t=function(t){if(r.validateLayersElesOrdering(t,e),r.levelIsComplete(t,e))return s=l[t],!0},i=function(e){if(!s)for(var r=n+e;-4<=r&&r<=2&&!t(r);r+=e);};i(1),i(-1);for(var a=c.length-1;a>=0;a--){var o=c[a];o.invalid&&Ke(c,o)}}();var d=function(t){var i=(t=t||{}).after;if(function(){if(!o){o=_t();for(var t=0;t16e6)return null;var a=r.makeLayer(o,n);if(null!=i){var s=c.indexOf(i)+1;c.splice(s,0,a)}else(void 0===t.insert||t.insert)&&c.unshift(a);return a};if(r.skipping&&!a)return null;for(var h=null,p=e.length/1,f=!a,g=0;g=p||!Ot(h.bb,v.boundingBox()))&&!(h=d({insert:!0,after:h})))return null;s||f?r.queueLayer(h,v):r.drawEleInLayer(h,v,n,t),h.eles.push(v),m[n]=h}}return s||(f?null:c)},Du.getEleLevelForLayerLevel=function(e,t){return e},Du.drawEleInLayer=function(e,t,n,r){var i=this.renderer,a=e.context,o=t.boundingBox();0!==o.w&&0!==o.h&&t.visible()&&(n=this.getEleLevelForLayerLevel(n,r),i.setImgSmoothing(a,!1),i.drawCachedElement(a,t,null,null,n,!0),i.setImgSmoothing(a,!0))},Du.levelIsComplete=function(e,t){var n=this.layersByLevel[e];if(!n||0===n.length)return!1;for(var r=0,i=0;i0)return!1;if(a.invalid)return!1;r+=a.eles.length}return r===t.length},Du.validateLayersElesOrdering=function(e,t){var n=this.layersByLevel[e];if(n)for(var r=0;r0){e=!0;break}}return e},Du.invalidateElements=function(e){var t=this;0!==e.length&&(t.lastInvalidationTime=we(),0!==e.length&&t.haveLayers()&&t.updateElementsInLayers(e,(function(e,n,r){t.invalidateLayer(e)})))},Du.invalidateLayer=function(e){if(this.lastInvalidationTime=we(),!e.invalid){var t=e.level,n=e.eles,r=this.layersByLevel[t];Ke(r,e),e.elesQueue=[],e.invalid=!0,e.replacement&&(e.replacement.invalid=!0);for(var i=0;i3&&void 0!==arguments[3])||arguments[3],i=!(arguments.length>4&&void 0!==arguments[4])||arguments[4],a=!(arguments.length>5&&void 0!==arguments[5])||arguments[5],o=this,s=t._private.rscratch;if((!a||t.visible())&&!s.badLine&&null!=s.allpts&&!isNaN(s.allpts[0])){var l;n&&(l=n,e.translate(-l.x1,-l.y1));var u=a?t.pstyle("opacity").value:1,c=a?t.pstyle("line-opacity").value:1,d=t.pstyle("curve-style").value,h=t.pstyle("line-style").value,p=t.pstyle("width").pfValue,f=t.pstyle("line-cap").value,g=t.pstyle("line-outline-width").value,v=t.pstyle("line-outline-color").value,y=u*c,m=u*c,b=function(){var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:y;"straight-triangle"===d?(o.eleStrokeStyle(e,t,n),o.drawEdgeTrianglePath(t,e,s.allpts)):(e.lineWidth=p,e.lineCap=f,o.eleStrokeStyle(e,t,n),o.drawEdgePath(t,e,s.allpts,h),e.lineCap="butt")},x=function(){var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:y;e.lineWidth=p+g,e.lineCap=f,g>0?(o.colorStrokeStyle(e,v[0],v[1],v[2],n),"straight-triangle"===d?o.drawEdgeTrianglePath(t,e,s.allpts):(o.drawEdgePath(t,e,s.allpts,h),e.lineCap="butt")):e.lineCap="butt"},w=function(){i&&o.drawEdgeOverlay(e,t)},E=function(){i&&o.drawEdgeUnderlay(e,t)},k=function(){var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:m;o.drawArrowheads(e,t,n)},C=function(){o.drawElementText(e,t,null,r)};e.lineJoin="round";var S="yes"===t.pstyle("ghost").value;if(S){var P=t.pstyle("ghost-offset-x").pfValue,D=t.pstyle("ghost-offset-y").pfValue,T=t.pstyle("ghost-opacity").value,_=y*T;e.translate(P,D),b(_),k(_),e.translate(-P,-D)}else x();E(),b(),k(),w(),C(),n&&e.translate(l.x1,l.y1)}}},Wu=function(e){if(!["overlay","underlay"].includes(e))throw new Error("Invalid state");return function(t,n){if(n.visible()){var r=n.pstyle("".concat(e,"-opacity")).value;if(0!==r){var i=this,a=i.usePaths(),o=n._private.rscratch,s=2*n.pstyle("".concat(e,"-padding")).pfValue,l=n.pstyle("".concat(e,"-color")).value;t.lineWidth=s,"self"!==o.edgeType||a?t.lineCap="round":t.lineCap="butt",i.colorStrokeStyle(t,l[0],l[1],l[2],r),i.drawEdgePath(n,t,o.allpts,"solid")}}}};Xu.drawEdgeOverlay=Wu("overlay"),Xu.drawEdgeUnderlay=Wu("underlay"),Xu.drawEdgePath=function(e,t,n,r){var i,a=e._private.rscratch,o=t,s=!1,u=this.usePaths(),c=e.pstyle("line-dash-pattern").pfValue,d=e.pstyle("line-dash-offset").pfValue;if(u){var h=n.join("$");a.pathCacheKey&&a.pathCacheKey===h?(i=t=a.pathCache,s=!0):(i=t=new Path2D,a.pathCacheKey=h,a.pathCache=i)}if(o.setLineDash)switch(r){case"dotted":o.setLineDash([1,1]);break;case"dashed":o.setLineDash(c),o.lineDashOffset=d;break;case"solid":o.setLineDash([])}if(!s&&!a.badLine)switch(t.beginPath&&t.beginPath(),t.moveTo(n[0],n[1]),a.edgeType){case"bezier":case"self":case"compound":case"multibezier":for(var p=2;p+35&&void 0!==arguments[5]?arguments[5]:5,o=arguments.length>6?arguments[6]:void 0;e.beginPath(),e.moveTo(t+a,n),e.lineTo(t+r-a,n),e.quadraticCurveTo(t+r,n,t+r,n+a),e.lineTo(t+r,n+i-a),e.quadraticCurveTo(t+r,n+i,t+r-a,n+i),e.lineTo(t+a,n+i),e.quadraticCurveTo(t,n+i,t,n+i-a),e.lineTo(t,n+a),e.quadraticCurveTo(t,n,t+a,n),e.closePath(),o?e.stroke():e.fill()}Ku.eleTextBiggerThanMin=function(e,t){if(!t){var n=e.cy().zoom(),r=this.getPixelRatio(),i=Math.ceil(wt(n*r));t=Math.pow(2,i)}return!(e.pstyle("font-size").pfValue*t5&&void 0!==arguments[5])||arguments[5],o=this;if(null==r){if(a&&!o.eleTextBiggerThanMin(t))return}else if(!1===r)return;if(t.isNode()){var s=t.pstyle("label");if(!s||!s.value)return;var l=o.getLabelJustification(t);e.textAlign=l,e.textBaseline="bottom"}else{var u=t.element()._private.rscratch.badLine,c=t.pstyle("label"),d=t.pstyle("source-label"),h=t.pstyle("target-label");if(u||(!c||!c.value)&&(!d||!d.value)&&(!h||!h.value))return;e.textAlign="center",e.textBaseline="bottom"}var p,f=!n;n&&(p=n,e.translate(-p.x1,-p.y1)),null==i?(o.drawText(e,t,null,f,a),t.isEdge()&&(o.drawText(e,t,"source",f,a),o.drawText(e,t,"target",f,a))):o.drawText(e,t,i,f,a),n&&e.translate(p.x1,p.y1)},Ku.getFontCache=function(e){var t;this.fontCaches=this.fontCaches||[];for(var n=0;n2&&void 0!==arguments[2])||arguments[2],r=t.pstyle("font-style").strValue,i=t.pstyle("font-size").pfValue+"px",a=t.pstyle("font-family").strValue,o=t.pstyle("font-weight").strValue,s=n?t.effectiveOpacity()*t.pstyle("text-opacity").value:1,l=t.pstyle("text-outline-opacity").value*s,u=t.pstyle("color").value,c=t.pstyle("text-outline-color").value;e.font=r+" "+o+" "+i+" "+a,e.lineJoin="round",this.colorFillStyle(e,u[0],u[1],u[2],s),this.colorStrokeStyle(e,c[0],c[1],c[2],l)},Ku.getTextAngle=function(e,t){var n=e._private.rscratch,r=t?t+"-":"",i=e.pstyle(r+"text-rotation"),a=Ue(n,"labelAngle",t);return"autorotate"===i.strValue?e.isEdge()?a:0:"none"===i.strValue?0:i.pfValue},Ku.drawText=function(e,t,n){var r=!(arguments.length>3&&void 0!==arguments[3])||arguments[3],i=!(arguments.length>4&&void 0!==arguments[4])||arguments[4],a=t._private,o=a.rscratch,s=i?t.effectiveOpacity():1;if(!i||0!==s&&0!==t.pstyle("text-opacity").value){"main"===n&&(n=null);var l,u,c=Ue(o,"labelX",n),d=Ue(o,"labelY",n),h=this.getLabelText(t,n);if(null!=h&&""!==h&&!isNaN(c)&&!isNaN(d)){this.setupTextStyle(e,t,i);var p,f=n?n+"-":"",g=Ue(o,"labelWidth",n),v=Ue(o,"labelHeight",n),y=t.pstyle(f+"text-margin-x").pfValue,m=t.pstyle(f+"text-margin-y").pfValue,b=t.isEdge(),x=t.pstyle("text-halign").value,w=t.pstyle("text-valign").value;switch(b&&(x="center",w="center"),c+=y,d+=m,0!==(p=r?this.getTextAngle(t,n):0)&&(l=c,u=d,e.translate(l,u),e.rotate(p),c=0,d=0),w){case"top":break;case"center":d+=v/2;break;case"bottom":d+=v}var E=t.pstyle("text-background-opacity").value,k=t.pstyle("text-border-opacity").value,C=t.pstyle("text-border-width").pfValue,S=t.pstyle("text-background-padding").pfValue,P=t.pstyle("text-background-shape").strValue,D=0===P.indexOf("round"),T=2;if(E>0||C>0&&k>0){var _=c-S;switch(x){case"left":_-=g;break;case"center":_-=g/2}var M=d-v-S,B=g+2*S,N=v+2*S;if(E>0){var z=e.fillStyle,I=t.pstyle("text-background-color").value;e.fillStyle="rgba("+I[0]+","+I[1]+","+I[2]+","+E*s+")",D?Gu(e,_,M,B,N,T):e.fillRect(_,M,B,N),e.fillStyle=z}if(C>0&&k>0){var A=e.strokeStyle,L=e.lineWidth,O=t.pstyle("text-border-color").value,R=t.pstyle("text-border-style").value;if(e.strokeStyle="rgba("+O[0]+","+O[1]+","+O[2]+","+k*s+")",e.lineWidth=C,e.setLineDash)switch(R){case"dotted":e.setLineDash([1,1]);break;case"dashed":e.setLineDash([4,2]);break;case"double":e.lineWidth=C/4,e.setLineDash([]);break;case"solid":e.setLineDash([])}if(D?Gu(e,_,M,B,N,T,"stroke"):e.strokeRect(_,M,B,N),"double"===R){var V=C/2;D?Gu(e,_+V,M+V,B-2*V,N-2*V,T,"stroke"):e.strokeRect(_+V,M+V,B-2*V,N-2*V)}e.setLineDash&&e.setLineDash([]),e.lineWidth=L,e.strokeStyle=A}}var F=2*t.pstyle("text-outline-width").pfValue;if(F>0&&(e.lineWidth=F),"wrap"===t.pstyle("text-wrap").value){var j=Ue(o,"labelWrapCachedLines",n),q=Ue(o,"labelLineHeight",n),Y=g/2,X=this.getLabelJustification(t);switch("auto"===X||("left"===x?"left"===X?c+=-g:"center"===X&&(c+=-Y):"center"===x?"left"===X?c+=-Y:"right"===X&&(c+=Y):"right"===x&&("center"===X?c+=Y:"right"===X&&(c+=g))),w){case"top":d-=(j.length-1)*q;break;case"center":case"bottom":d-=(j.length-1)*q}for(var W=0;W0&&e.strokeText(j[W],c,d),e.fillText(j[W],c,d),d+=q}else F>0&&e.strokeText(h,c,d),e.fillText(h,c,d);0!==p&&(e.rotate(-p),e.translate(-l,-u))}}};var Uu={drawNode:function(e,t,n){var r,i,a=!(arguments.length>3&&void 0!==arguments[3])||arguments[3],o=!(arguments.length>4&&void 0!==arguments[4])||arguments[4],s=!(arguments.length>5&&void 0!==arguments[5])||arguments[5],l=this,u=t._private,c=u.rscratch,d=t.position();if(x(d.x)&&x(d.y)&&(!s||t.visible())){var h,p,f=s?t.effectiveOpacity():1,g=l.usePaths(),v=!1,y=t.padding();r=t.width()+2*y,i=t.height()+2*y,n&&(p=n,e.translate(-p.x1,-p.y1));for(var m=t.pstyle("background-image"),b=m.value,w=new Array(b.length),E=new Array(b.length),k=0,C=0;C0&&void 0!==arguments[0]?arguments[0]:M;l.eleFillStyle(e,t,n)},H=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:R;l.colorStrokeStyle(e,B[0],B[1],B[2],t)},K=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:q;l.colorStrokeStyle(e,F[0],F[1],F[2],t)},G=function(e,t,n,r){var i,a=l.nodePathCache=l.nodePathCache||[],o=_e("polygon"===n?n+","+r.join(","):n,""+t,""+e,""+X),s=a[o],u=!1;return null!=s?(i=s,u=!0,c.pathCache=i):(i=new Path2D,a[o]=c.pathCache=i),{path:i,cacheHit:u}},U=t.pstyle("shape").strValue,Z=t.pstyle("shape-polygon-points").pfValue;if(g){e.translate(d.x,d.y);var $=G(r,i,U,Z);h=$.path,v=$.cacheHit}var Q=function(){if(!v){var n=d;g&&(n={x:0,y:0}),l.nodeShapes[l.getNodeShape(t)].draw(h||e,n.x,n.y,r,i,X,c)}g?e.fill(h):e.fill()},J=function(){for(var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:f,r=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],i=u.backgrounding,a=0,o=0;o0&&void 0!==arguments[0]&&arguments[0],a=arguments.length>1&&void 0!==arguments[1]?arguments[1]:f;l.hasPie(t)&&(l.drawPie(e,t,a),n&&(g||l.nodeShapes[l.getNodeShape(t)].draw(e,d.x,d.y,r,i,X,c)))},te=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:f,n=(T>0?T:-T)*t,r=T>0?0:255;0!==T&&(l.colorFillStyle(e,r,r,r,n),g?e.fill(h):e.fill())},ne=function(){if(_>0){if(e.lineWidth=_,e.lineCap=I,e.lineJoin=z,e.setLineDash)switch(N){case"dotted":e.setLineDash([1,1]);break;case"dashed":e.setLineDash(L),e.lineDashOffset=O;break;case"solid":case"double":e.setLineDash([])}if("center"!==A){if(e.save(),e.lineWidth*=2,"inside"===A)g?e.clip(h):e.clip();else{var t=new Path2D;t.rect(-r/2-_,-i/2-_,r+2*_,i+2*_),t.addPath(h),e.clip(t,"evenodd")}g?e.stroke(h):e.stroke(),e.restore()}else g?e.stroke(h):e.stroke();if("double"===N){e.lineWidth=_/3;var n=e.globalCompositeOperation;e.globalCompositeOperation="destination-out",g?e.stroke(h):e.stroke(),e.globalCompositeOperation=n}e.setLineDash&&e.setLineDash([])}},re=function(){if(V>0){if(e.lineWidth=V,e.lineCap="butt",e.setLineDash)switch(j){case"dotted":e.setLineDash([1,1]);break;case"dashed":e.setLineDash([4,2]);break;case"solid":case"double":e.setLineDash([])}var n=d;g&&(n={x:0,y:0});var a=l.getNodeShape(t),o=_;"inside"===A&&(o=0),"outside"===A&&(o*=2);var s,u=(r+o+(V+Y))/r,c=(i+o+(V+Y))/i,h=r*u,p=i*c,f=l.nodeShapes[a].points;if(g)s=G(h,p,a,f).path;if("ellipse"===a)l.drawEllipsePath(s||e,n.x,n.y,h,p);else if(["round-diamond","round-heptagon","round-hexagon","round-octagon","round-pentagon","round-polygon","round-triangle","round-tag"].includes(a)){var v=0,y=0,m=0;"round-diamond"===a?v=1.4*(o+Y+V):"round-heptagon"===a?(v=1.075*(o+Y+V),m=-(o/2+Y+V)/35):"round-hexagon"===a?v=1.12*(o+Y+V):"round-pentagon"===a?(v=1.13*(o+Y+V),m=-(o/2+Y+V)/15):"round-tag"===a?(v=1.12*(o+Y+V),y=.07*(o/2+V+Y)):"round-triangle"===a&&(v=(o+Y+V)*(Math.PI/2),m=-(o+Y/2+V)/Math.PI),0!==v&&(h=r*(u=(r+v)/r),["round-hexagon","round-tag"].includes(a)||(p=i*(c=(i+v)/i)));for(var b=h/2,x=p/2,w=(X="auto"===X?rn(h,p):X)+(o+V+Y)/2,E=new Array(f.length/2),k=new Array(f.length/2),C=0;C0){if(r=r||n.position(),null==i||null==a){var d=n.padding();i=n.width()+2*d,a=n.height()+2*d}this.colorFillStyle(t,l[0],l[1],l[2],s),this.nodeShapes[u].draw(t,r.x,r.y,i+2*o,a+2*o,c),t.fill()}}}};Uu.drawNodeOverlay=Zu("overlay"),Uu.drawNodeUnderlay=Zu("underlay"),Uu.hasPie=function(e){return(e=e[0])._private.hasPie},Uu.drawPie=function(e,t,n,r){t=t[0],r=r||t.position();var i=t.cy().style(),a=t.pstyle("pie-size"),o=r.x,s=r.y,l=t.width(),u=t.height(),c=Math.min(l,u)/2,d=0;this.usePaths()&&(o=0,s=0),"%"===a.units?c*=a.pfValue:void 0!==a.pfValue&&(c=a.pfValue/2);for(var h=1;h<=i.pieBackgroundN;h++){var p=t.pstyle("pie-"+h+"-background-size").value,f=t.pstyle("pie-"+h+"-background-color").value,g=t.pstyle("pie-"+h+"-background-opacity").value*n,v=p/100;v+d>1&&(v=1-d);var y=1.5*Math.PI+2*Math.PI*d,m=y+2*Math.PI*v;0===p||d>=1||d+v>1||(e.beginPath(),e.moveTo(o,s),e.arc(o,s,c,y,m),e.closePath(),this.colorFillStyle(e,f[0],f[1],f[2],g),e.fill(),d+=v)}};var $u={};$u.getPixelRatio=function(){var e=this.data.contexts[0];if(null!=this.forcedPixelRatio)return this.forcedPixelRatio;var t=this.cy.window(),n=e.backingStorePixelRatio||e.webkitBackingStorePixelRatio||e.mozBackingStorePixelRatio||e.msBackingStorePixelRatio||e.oBackingStorePixelRatio||e.backingStorePixelRatio||1;return(t.devicePixelRatio||1)/n},$u.paintCache=function(e){for(var t,n=this.paintCaches=this.paintCaches||[],r=!0,i=0;io.minMbLowQualFrames&&(o.motionBlurPxRatio=o.mbPxRBlurry)),o.clearingMotionBlur&&(o.motionBlurPxRatio=1),o.textureDrawLastFrame&&!d&&(c[o.NODE]=!0,c[o.SELECT_BOX]=!0);var m=l.style(),b=l.zoom(),x=void 0!==i?i:b,w=l.pan(),E={x:w.x,y:w.y},k={zoom:b,pan:{x:w.x,y:w.y}},C=o.prevViewport;void 0===C||k.zoom!==C.zoom||k.pan.x!==C.pan.x||k.pan.y!==C.pan.y||g&&!f||(o.motionBlurPxRatio=1),a&&(E=a),x*=s,E.x*=s,E.y*=s;var S=o.getCachedZSortedEles();function P(e,t,n,r,i){var a=e.globalCompositeOperation;e.globalCompositeOperation="destination-out",o.colorFillStyle(e,255,255,255,o.motionBlurTransparency),e.fillRect(t,n,r,i),e.globalCompositeOperation=a}function D(e,r){var s,l,c,d;o.clearingMotionBlur||e!==u.bufferContexts[o.MOTIONBLUR_BUFFER_NODE]&&e!==u.bufferContexts[o.MOTIONBLUR_BUFFER_DRAG]?(s=E,l=x,c=o.canvasWidth,d=o.canvasHeight):(s={x:w.x*p,y:w.y*p},l=b*p,c=o.canvasWidth*p,d=o.canvasHeight*p),e.setTransform(1,0,0,1,0,0),"motionBlur"===r?P(e,0,0,c,d):t||void 0!==r&&!r||e.clearRect(0,0,c,d),n||(e.translate(s.x,s.y),e.scale(l,l)),a&&e.translate(a.x,a.y),i&&e.scale(i,i)}if(d||(o.textureDrawLastFrame=!1),d){if(o.textureDrawLastFrame=!0,!o.textureCache){o.textureCache={},o.textureCache.bb=l.mutableElements().boundingBox(),o.textureCache.texture=o.data.bufferCanvases[o.TEXTURE_BUFFER];var T=o.data.bufferContexts[o.TEXTURE_BUFFER];T.setTransform(1,0,0,1,0,0),T.clearRect(0,0,o.canvasWidth*o.textureMult,o.canvasHeight*o.textureMult),o.render({forcedContext:T,drawOnlyNodeLayer:!0,forcedPxRatio:s*o.textureMult}),(k=o.textureCache.viewport={zoom:l.zoom(),pan:l.pan(),width:o.canvasWidth,height:o.canvasHeight}).mpan={x:(0-k.pan.x)/k.zoom,y:(0-k.pan.y)/k.zoom}}c[o.DRAG]=!1,c[o.NODE]=!1;var _=u.contexts[o.NODE],M=o.textureCache.texture;k=o.textureCache.viewport;_.setTransform(1,0,0,1,0,0),h?P(_,0,0,k.width,k.height):_.clearRect(0,0,k.width,k.height);var B=m.core("outside-texture-bg-color").value,N=m.core("outside-texture-bg-opacity").value;o.colorFillStyle(_,B[0],B[1],B[2],N),_.fillRect(0,0,k.width,k.height);b=l.zoom();D(_,!1),_.clearRect(k.mpan.x,k.mpan.y,k.width/k.zoom/s,k.height/k.zoom/s),_.drawImage(M,k.mpan.x,k.mpan.y,k.width/k.zoom/s,k.height/k.zoom/s)}else o.textureOnViewport&&!t&&(o.textureCache=null);var z=l.extent(),I=o.pinching||o.hoverData.dragging||o.swipePanning||o.data.wheelZooming||o.hoverData.draggingEles||o.cy.animated(),A=o.hideEdgesOnViewport&&I,L=[];if(L[o.NODE]=!c[o.NODE]&&h&&!o.clearedForMotionBlur[o.NODE]||o.clearingMotionBlur,L[o.NODE]&&(o.clearedForMotionBlur[o.NODE]=!0),L[o.DRAG]=!c[o.DRAG]&&h&&!o.clearedForMotionBlur[o.DRAG]||o.clearingMotionBlur,L[o.DRAG]&&(o.clearedForMotionBlur[o.DRAG]=!0),c[o.NODE]||n||r||L[o.NODE]){var O=h&&!L[o.NODE]&&1!==p;D(_=t||(O?o.data.bufferContexts[o.MOTIONBLUR_BUFFER_NODE]:u.contexts[o.NODE]),h&&!O?"motionBlur":void 0),A?o.drawCachedNodes(_,S.nondrag,s,z):o.drawLayeredElements(_,S.nondrag,s,z),o.debug&&o.drawDebugPoints(_,S.nondrag),n||h||(c[o.NODE]=!1)}if(!r&&(c[o.DRAG]||n||L[o.DRAG])){O=h&&!L[o.DRAG]&&1!==p;D(_=t||(O?o.data.bufferContexts[o.MOTIONBLUR_BUFFER_DRAG]:u.contexts[o.DRAG]),h&&!O?"motionBlur":void 0),A?o.drawCachedNodes(_,S.drag,s,z):o.drawCachedElements(_,S.drag,s,z),o.debug&&o.drawDebugPoints(_,S.drag),n||h||(c[o.DRAG]=!1)}if(o.showFps||!r&&c[o.SELECT_BOX]&&!n){if(D(_=t||u.contexts[o.SELECT_BOX]),1==o.selection[4]&&(o.hoverData.selecting||o.touchData.selecting)){b=o.cy.zoom();var R=m.core("selection-box-border-width").value/b;_.lineWidth=R,_.fillStyle="rgba("+m.core("selection-box-color").value[0]+","+m.core("selection-box-color").value[1]+","+m.core("selection-box-color").value[2]+","+m.core("selection-box-opacity").value+")",_.fillRect(o.selection[0],o.selection[1],o.selection[2]-o.selection[0],o.selection[3]-o.selection[1]),R>0&&(_.strokeStyle="rgba("+m.core("selection-box-border-color").value[0]+","+m.core("selection-box-border-color").value[1]+","+m.core("selection-box-border-color").value[2]+","+m.core("selection-box-opacity").value+")",_.strokeRect(o.selection[0],o.selection[1],o.selection[2]-o.selection[0],o.selection[3]-o.selection[1]))}if(u.bgActivePosistion&&!o.hoverData.selecting){b=o.cy.zoom();var V=u.bgActivePosistion;_.fillStyle="rgba("+m.core("active-bg-color").value[0]+","+m.core("active-bg-color").value[1]+","+m.core("active-bg-color").value[2]+","+m.core("active-bg-opacity").value+")",_.beginPath(),_.arc(V.x,V.y,m.core("active-bg-size").pfValue/b,0,2*Math.PI),_.fill()}var F=o.lastRedrawTime;if(o.showFps&&F){F=Math.round(F);var j=Math.round(1e3/F);_.setTransform(1,0,0,1,0,0),_.fillStyle="rgba(255, 0, 0, 0.75)",_.strokeStyle="rgba(255, 0, 0, 0.75)",_.lineWidth=1,_.fillText("1 frame = "+F+" ms = "+j+" fps",0,20);_.strokeRect(0,30,250,20),_.fillRect(0,30,250*Math.min(j/60,1),20)}n||(c[o.SELECT_BOX]=!1)}if(h&&1!==p){var q=u.contexts[o.NODE],Y=o.data.bufferCanvases[o.MOTIONBLUR_BUFFER_NODE],X=u.contexts[o.DRAG],W=o.data.bufferCanvases[o.MOTIONBLUR_BUFFER_DRAG],H=function(e,t,n){e.setTransform(1,0,0,1,0,0),n||!y?e.clearRect(0,0,o.canvasWidth,o.canvasHeight):P(e,0,0,o.canvasWidth,o.canvasHeight);var r=p;e.drawImage(t,0,0,o.canvasWidth*r,o.canvasHeight*r,0,0,o.canvasWidth,o.canvasHeight)};(c[o.NODE]||L[o.NODE])&&(H(q,Y,L[o.NODE]),c[o.NODE]=!1),(c[o.DRAG]||L[o.DRAG])&&(H(X,W,L[o.DRAG]),c[o.DRAG]=!1)}o.prevViewport=k,o.clearingMotionBlur&&(o.clearingMotionBlur=!1,o.motionBlurCleared=!0,o.motionBlur=!0),h&&(o.motionBlurTimeout=setTimeout((function(){o.motionBlurTimeout=null,o.clearedForMotionBlur[o.NODE]=!1,o.clearedForMotionBlur[o.DRAG]=!1,o.motionBlur=!1,o.clearingMotionBlur=!d,o.mbFrames=0,c[o.NODE]=!0,c[o.DRAG]=!0,o.redraw()}),100)),t||l.emit("render")};for(var Qu={drawPolygonPath:function(e,t,n,r,i,a){var o=r/2,s=i/2;e.beginPath&&e.beginPath(),e.moveTo(t+o*a[0],n+s*a[1]);for(var l=1;l0&&a>0){h.clearRect(0,0,i,a),h.globalCompositeOperation="source-over";var p=this.getCachedZSortedEles();if(e.full)h.translate(-n.x1*l,-n.y1*l),h.scale(l,l),this.drawElements(h,p),h.scale(1/l,1/l),h.translate(n.x1*l,n.y1*l);else{var f=t.pan(),g={x:f.x*l,y:f.y*l};l*=t.zoom(),h.translate(g.x,g.y),h.scale(l,l),this.drawElements(h,p),h.scale(1/l,1/l),h.translate(-g.x,-g.y)}e.bg&&(h.globalCompositeOperation="destination-over",h.fillStyle=e.bg,h.rect(0,0,i,a),h.fill())}return d},ac.png=function(e){return sc(e,this.bufferCanvasImage(e),"image/png")},ac.jpg=function(e){return sc(e,this.bufferCanvasImage(e),"image/jpeg")};var lc={nodeShapeImpl:function(e,t,n,r,i,a,o,s){switch(e){case"ellipse":return this.drawEllipsePath(t,n,r,i,a);case"polygon":return this.drawPolygonPath(t,n,r,i,a,o);case"round-polygon":return this.drawRoundPolygonPath(t,n,r,i,a,o,s);case"roundrectangle":case"round-rectangle":return this.drawRoundRectanglePath(t,n,r,i,a,s);case"cutrectangle":case"cut-rectangle":return this.drawCutRectanglePath(t,n,r,i,a,o,s);case"bottomroundrectangle":case"bottom-round-rectangle":return this.drawBottomRoundRectanglePath(t,n,r,i,a,s);case"barrel":return this.drawBarrelPath(t,n,r,i,a)}}},uc=dc,cc=dc.prototype;function dc(e){var t=this,n=t.cy.window().document;t.data={canvases:new Array(cc.CANVAS_LAYERS),contexts:new Array(cc.CANVAS_LAYERS),canvasNeedsRedraw:new Array(cc.CANVAS_LAYERS),bufferCanvases:new Array(cc.BUFFER_COUNT),bufferContexts:new Array(cc.CANVAS_LAYERS)};t.data.canvasContainer=n.createElement("div");var r=t.data.canvasContainer.style;t.data.canvasContainer.style["-webkit-tap-highlight-color"]="rgba(0,0,0,0)",r.position="relative",r.zIndex="0",r.overflow="hidden";var i=e.cy.container();i.appendChild(t.data.canvasContainer),i.style["-webkit-tap-highlight-color"]="rgba(0,0,0,0)";var a={"-webkit-user-select":"none","-moz-user-select":"-moz-none","user-select":"none","-webkit-tap-highlight-color":"rgba(0,0,0,0)","outline-style":"none"};c&&c.userAgent.match(/msie|trident|edge/i)&&(a["-ms-touch-action"]="none",a["touch-action"]="none");for(var o=0;o=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function ne(t,n){let r=null;q(t,function(e){return!!(r=o(t,ce(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function A(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function L(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function N(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=te().createElement("script");ie(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){R(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=A(t);let r;if(n==="html"){r=new DocumentFragment;const i=L(e);N(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=L(t);N(r,i.body);r.title=i.title}else{const i=L('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function re(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function D(e){return typeof e==="function"}function k(e){return t(e,"Object")}function oe(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function F(t){const n=[];if(t){for(let e=0;e=0}function se(e){return e.getRootNode({composed:true})===document}function X(e){return e.trim().split(/\s+/)}function le(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function v(e){try{return JSON.parse(e)}catch(e){R(e);return null}}function B(){const e="htmx:sessionStorageTest";try{sessionStorage.setItem(e,e);sessionStorage.removeItem(e);return true}catch(e){return false}}function U(e){const t=new URL(e,"http://x");if(t){e=t.pathname+t.search}if(e!="/"){e=e.replace(/\/+$/,"")}return e}function e(e){return On(te().body,function(){return eval(e)})}function V(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function j(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function $(){Q.logger=null}function f(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return f(te(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(te(),e)}}function b(){return window}function _(e,t){e=w(e);if(t){b().setTimeout(function(){_(e);e=null},t)}else{u(e).removeChild(e)}}function ce(e){return e instanceof Element?e:null}function z(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function p(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ce(w(e));if(!e){return}if(n){b().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ce(w(e));if(!r){return}if(n){b().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=w(e);e.classList.toggle(t)}function Z(e,t){e=w(e);ie(e.parentElement.children,function(e){G(e,t)});K(ce(e),t)}function g(e,t){e=ce(w(e));if(e){return e.closest(t)}return null}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function pe(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function m(t,r,n){if(r.indexOf("global ")===0){return m(t,r.slice(7),true)}t=w(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=pe(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ce(t),pe(r.slice(8)))}else if(r.indexOf("find ")===0){e=f(p(t),pe(r.slice(5)))}else if(r==="next"||r==="nextElementSibling"){e=ce(t).nextElementSibling}else if(r.indexOf("next ")===0){e=ge(t,pe(r.slice(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ce(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,pe(r.slice(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=y(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=p(y(t,!!n));i.push(...F(c.querySelectorAll(e)))}return i}var ge=function(t,e,n){const r=p(y(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ue(e,t){if(typeof e!=="string"){return m(e,t)[0]}else{return m(te().body,e)[0]}}function w(e,t){if(typeof e==="string"){return f(p(t)||document,e)}else{return e}}function ye(e,t,n,r){if(D(t)){return{target:te().body,event:J(e),listener:t,options:n}}else{return{target:w(e),event:J(t),listener:n,options:r}}}function xe(t,n,r,o){Gn(function(){const e=ye(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=D(n);return e?n:r}function be(t,n,r){Gn(function(){const e=ye(t,n,r);e.target.removeEventListener(e.event,e.listener)});return D(n)?n:r}const ve=te().createElement("output");function we(t,n){const e=ne(t,n);if(e){if(e==="this"){return[Se(t,n)]}else{const r=m(t,e);const o=/(^|,)(\s*)inherit(\s*)($|,)/.test(e);if(o){const i=ce(q(t,function(e){return e!==t&&s(ce(e),n)}));if(i){r.push(...we(i,n))}}if(r.length===0){R('The selector "'+e+'" on '+n+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ce(q(e,function(e){return a(ce(e),t)!=null}))}function Ee(e){const t=ne(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ue(e,t)}}else{const n=oe(e);if(n.boosted){return te().body}else{return e}}}function Ce(e){return Q.config.attributesToSettle.includes(e)}function Oe(t,n){ie(Array.from(t.attributes),function(e){if(!n.hasAttribute(e.name)&&Ce(e.name)){t.removeAttribute(e.name)}});ie(n.attributes,function(e){if(Ce(e.name)){t.setAttribute(e.name,e.value)}})}function Re(t,e){const n=Jn(e);for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=m(t,n,false);if(r.length){ie(r,function(e){let t;const n=o.cloneNode(true);t=te().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=p(n)}const r={shouldSwap:true,target:e,fragment:t};if(!ae(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);$e(s,e,e,t,i);Te()}ie(i.elts,function(e){ae(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(te().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=f("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=f("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){ie(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=a(e,"id");const n=te().getElementById(t);if(n!=null){if(e.moveBefore){let e=f("#--htmx-preserve-pantry--");if(e==null){te().body.insertAdjacentHTML("afterend","
");e=f("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Ae(l,e,c){ie(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=p(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Le(e){return function(){G(e,Q.config.addedClass);Mt(ce(e));Ne(p(e));ae(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=z(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function c(e,t,n,r){Ae(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ce(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Le(o))}}}function Ie(e,t){let n=0;while(n0}function ze(h,d,p,g){if(!g){g={}}let m=null;let n=null;let e=function(){re(g.beforeSwapCallback);h=w(h);const r=g.contextElement?y(g.contextElement,false):te();const e=document.activeElement;let t={};t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null};const o=Sn(h);if(p.swapStyle==="textContent"){h.textContent=d}else{let n=P(d);o.title=g.title||n.title;if(g.historyRequest){n=n.querySelector("[hx-history-elt],[data-hx-history-elt]")||n}if(g.selectOOB){const i=g.selectOOB.split(",");for(let t=0;t0){b().setTimeout(n,p.settleDelay)}else{n()}};let t=Q.config.globalViewTransitions;if(p.hasOwnProperty("transition")){t=p.transition}const r=g.contextElement||te();if(t&&ae(r,"htmx:beforeTransition",g.eventInfo)&&typeof Promise!=="undefined"&&document.startViewTransition){const o=new Promise(function(e,t){m=e;n=t});const i=e;e=function(){document.startViewTransition(function(){i();return o})}}try{if(p?.swapDelay&&p.swapDelay>0){b().setTimeout(e,p.swapDelay)}else{e()}}catch(e){fe(r,"htmx:swapError",g.eventInfo);re(n);throw e}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=v(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(k(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}ae(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=On(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(te().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function O(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=O(e,Qe).trim();e.shift()}else{t=O(e,E)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{O(o,C);const l=o.length;const c=O(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};O(o,C);u.pollInterval=d(O(o,/[,\[\s]/));O(o,C);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const f={trigger:c};var i=nt(e,o,"event");if(i){f.eventFilter=i}O(o,C);while(o.length>0&&o[0]!==","){const a=o.shift();if(a==="changed"){f.changed=true}else if(a==="once"){f.once=true}else if(a==="consume"){f.consume=true}else if(a==="delay"&&o[0]===":"){o.shift();f.delay=d(O(o,E))}else if(a==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=O(o,E);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}f.from=s}else if(a==="target"&&o[0]===":"){o.shift();f.target=rt(o)}else if(a==="throttle"&&o[0]===":"){o.shift();f.throttle=d(O(o,E))}else if(a==="queue"&&o[0]===":"){o.shift();f.queue=O(o,E)}else if(a==="root"&&o[0]===":"){o.shift();f[a]=rt(o)}else if(a==="threshold"&&o[0]===":"){o.shift();f[a]=O(o,E)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}O(o,C)}r.push(f)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}O(o,C)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=a(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){oe(e).cancelled=true}function ct(e,t,n){const r=oe(e);r.timeout=b().setTimeout(function(){if(se(e)&&r.cancelled!==true){if(!pt(n,e,Bt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function ft(e){return g(e,Q.config.disableSelector)}function at(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){gt(t,function(e,t){const n=ce(e);if(ft(n)){S(n);return}he(r,o,n,t)},n,e,true)})}}function ht(e,t){if(e.type==="submit"&&t.tagName==="FORM"){return true}else if(e.type==="click"){const n=t.closest('input[type="submit"], button');if(n&&n.form&&n.type==="submit"){return true}const r=t.closest("a");const o=/^#.+/;if(r&&r.href&&!o.test(r.getAttribute("href"))){return true}}return false}function dt(e,t){return oe(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function pt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(te().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function gt(l,c,e,u,f){const a=oe(l);let t;if(u.from){t=m(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in a)){a.lastValue=new WeakMap}t.forEach(function(e){if(!a.lastValue.has(u)){a.lastValue.set(u,new WeakMap)}a.lastValue.get(u).set(e,e.value)})}ie(t,function(i){const s=function(e){if(!se(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(f||ht(e,i)){e.preventDefault()}if(pt(u,l,e)){return}const t=oe(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ce(e.target),u.target)){return}}if(u.once){if(a.triggeredOnce){return}else{a.triggeredOnce=true}}if(u.changed){const n=e.target;const r=n.value;const o=a.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(a.delayed){clearTimeout(a.delayed)}if(a.throttle){return}if(u.throttle>0){if(!a.throttle){ae(l,"htmx:trigger");c(l,e);a.throttle=b().setTimeout(function(){a.throttle=null},u.throttle)}}else if(u.delay>0){a.delayed=b().setTimeout(function(){ae(l,"htmx:trigger");c(l,e)},u.delay)}else{ae(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let yt=null;function xt(){if(!yt){yt=function(){mt=true};window.addEventListener("scroll",yt);window.addEventListener("resize",yt);setInterval(function(){if(mt){mt=false;ie(te().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&M(e)){e.setAttribute("data-hx-revealed","true");const t=oe(e);if(t.initHash){ae(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){ae(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;ae(e,"htmx:trigger");t(e)}};if(r>0){b().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;ie(de,function(r){if(s(t,"hx-"+r)){const o=a(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ce(e);if(ft(n)){S(n);return}he(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){xt();gt(r,n,t,e);bt(ce(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ue(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ce(r),n,e)}else{gt(r,n,t,e)}}function Et(e){const t=ce(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=At(e.target);const n=Nt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Nt(e);if(t){t.lastButtonClicked=null}}function At(e){return g(ce(e),"button, input[type='submit']")}function Lt(e){return e.form||g(e,"form")}function Nt(e){const t=At(e.target);if(!t){return}const n=Lt(t);if(!n){return}return oe(n)}function It(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Pt(t,e,n){const r=oe(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){On(t,function(){if(ft(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function Dt(t){De(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(te().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Jt(t){if(!B()){return null}t=U(t);const n=v(sessionStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){r.response=this.response;ae(te().body,"htmx:historyCacheMissLoad",r);ze(r.historyElt,r.response,n,{contextElement:r.historyElt,historyRequest:true});$t(r.path);ae(te().body,"htmx:historyRestore",{path:e,cacheMiss:true,serverResponse:r.response})}else{fe(te().body,"htmx:historyCacheMissLoadError",r)}};if(ae(te().body,"htmx:historyCacheMiss",r)){t.send()}}function en(e){Gt();e=e||location.pathname+location.search;const t=Jt(e);if(t){const n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:t.scroll};const r={path:e,item:t,historyElt:_t(),swapSpec:n};if(ae(te().body,"htmx:historyCacheHit",r)){ze(r.historyElt,t.content,n,{contextElement:r.historyElt,title:t.title});$t(r.path);ae(te().body,"htmx:historyRestore",r)}}else{if(Q.config.refreshOnHistoryMiss){Q.location.reload(true)}else{Qt(e)}}}function tn(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}ie(t,function(e){const t=oe(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function nn(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}ie(t,function(e){const t=oe(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function rn(e,t){ie(e.concat(t),function(e){const t=oe(e);t.requestCount=(t.requestCount||1)-1});ie(e,function(e){const t=oe(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});ie(t,function(e){const t=oe(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function on(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);ie(e,e=>r.append(t,e))}}function un(e){if(e instanceof HTMLSelectElement&&e.multiple){return F(e.querySelectorAll("option:checked")).map(function(e){return e.value})}if(e instanceof HTMLInputElement&&e.files){return F(e.files)}return e.value}function fn(t,n,r,e,o){if(e==null||on(t,e)){return}else{t.push(e)}if(sn(e)){const i=ee(e,"name");ln(i,un(e),n);if(o){an(e,r)}}if(e instanceof HTMLFormElement){ie(e.elements,function(e){if(t.indexOf(e)>=0){cn(e.name,un(e),n)}else{t.push(e)}if(o){an(e,r)}});new FormData(e).forEach(function(e,t){if(e instanceof File&&e.name===""){return}ln(t,e,n)})}}function an(e,t){const n=e;if(n.willValidate){ae(n,"htmx:validation:validate");if(!n.checkValidity()){if(ae(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})&&!t.length&&Q.config.reportValidityOfForms){n.reportValidity()}t.push({elt:n,message:n.validationMessage,validity:n.validity})}}}function hn(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function dn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=oe(e);if(s.lastButtonClicked&&!se(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||a(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){fn(n,o,i,Lt(e),l)}fn(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const f=ee(u,"name");ln(f,u.value,o)}const c=we(e,"hx-include");ie(c,function(e){fn(n,r,i,ce(e),l);if(!h(e,"form")){ie(p(e).querySelectorAll(ot),function(e){fn(n,r,i,e,l)})}});hn(r,o);return{errors:i,formData:r,values:kn(r)}}function pn(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function gn(e){e=Pn(e);let n="";e.forEach(function(e,t){n=pn(n,t,e)});return n}function mn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":a(t,"id"),"HX-Current-URL":location.href};Cn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(oe(e).boosted){r["HX-Boosted"]="true"}return r}function yn(n,e){const t=ne(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){ie(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;ie(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function xn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function bn(e,t){const n=t||ne(e,"hx-swap");const r={swapStyle:oe(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&oe(e).boosted&&!xn(e)){r.show="top"}if(n){const s=X(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const f=l.slice(5);var o=f.split(":");const a=o.pop();var i=o.length>0?o.join(":"):null;r.show=a;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{R("Unknown modifier in hx-swap: "+l)}}}}return r}function vn(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function wn(t,n,r){let o=null;Vt(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(vn(n)){return hn(new FormData,Pn(r))}else{return gn(r)}}}function Sn(e){return{tasks:[],elts:[e]}}function En(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ce(ue(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}if(typeof t.scroll==="number"){b().setTimeout(function(){window.scrollTo(0,t.scroll)},0)}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ce(ue(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Cn(r,e,o,i,s){if(i==null){i={}}if(r==null){return i}const l=a(r,e);if(l){let e=l.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=On(r,function(){if(s){return Function("event","return ("+e+")").call(r,s)}else{return Function("return ("+e+")").call(r)}},{})}else{n=v(e)}for(const c in n){if(n.hasOwnProperty(c)){if(i[c]==null){i[c]=n[c]}}}}return Cn(ce(u(r)),e,o,i,s)}function On(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function Rn(e,t,n){return Cn(e,"hx-vars",true,n,t)}function Hn(e,t,n){return Cn(e,"hx-vals",false,n,t)}function Tn(e,t){return le(Rn(e,t),Hn(e,t))}function qn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function An(t){if(t.responseURL){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(te().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function H(e,t){return t.test(e.getAllResponseHeaders())}function Ln(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return he(t,n,null,null,{targetOverride:w(r)||ve,returnPromise:true})}else{let e=w(r.target);if(r.target&&!e||r.source&&!e&&!w(r.source)){e=ve}return he(t,n,w(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return he(t,n,null,null,{returnPromise:true})}}function Nn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function In(e,t,n){const r=new URL(t,location.protocol!=="about:"?location.href:window.origin);const o=location.protocol!=="about:"?location.origin:window.origin;const i=o===r.origin;if(Q.config.selfRequestsOnly){if(!i){return false}}return ae(e,"htmx:validateUrl",le({url:r,sameHost:i},n))}function Pn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Dn(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function kn(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Dn(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function he(t,n,r,o,i,k){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=te().body}const F=i.handler||Vn;const M=i.select||null;if(!se(r)){re(s);return e}const c=i.targetOverride||ce(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:ne(r,"hx-target")});re(l);return e}let u=oe(r);const f=u.lastButtonClicked;if(f){const A=ee(f,"formaction");if(A!=null){n=A}const L=ee(f,"formmethod");if(L!=null){if(de.includes(L.toLowerCase())){t=L}else{re(s);return e}}}const a=ne(r,"hx-confirm");if(k===undefined){const K=function(e){return he(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:a};if(ae(r,"htmx:confirm",G)===false){re(s);return e}}let h=r;let d=ne(r,"hx-sync");let p=null;let X=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ce(ue(r,I))}d=(N[1]||"drop").trim();u=oe(h);if(d==="drop"&&u.xhr&&u.abortable!==true){re(s);return e}else if(d==="abort"){if(u.xhr){re(s);return e}else{X=true}}else if(d==="replace"){ae(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");p=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){ae(h,"htmx:abort")}else{if(p==null){if(o){const P=oe(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){p=P.triggerSpec.queue}}if(p==null){p="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(p==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(p==="all"){u.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(p==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){he(t,n,r,o,i)})}re(s);return e}}const g=new XMLHttpRequest;u.xhr=g;u.abortable=X;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=ne(r,"hx-prompt");if(B){var y=prompt(B);if(y===null||!ae(r,"htmx:prompt",{prompt:y,target:c})){re(s);m();return e}}if(a&&!k){if(!confirm(a)){re(s);m();return e}}let x=mn(r,c,y);if(t!=="get"&&!vn(r)){x["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){x=le(x,i.headers)}const U=dn(r,t);let b=U.errors;const V=U.formData;if(i.values){hn(V,Pn(i.values))}const j=Pn(Tn(r,o));const v=hn(V,j);let w=yn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=location.href}const S=Cn(r,"hx-request");const $=oe(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:$,useUrlParams:E,formData:w,parameters:kn(w),unfilteredFormData:v,unfilteredParameters:kn(v),headers:x,elt:r,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!ae(r,"htmx:configRequest",C)){re(s);m();return e}n=C.path;t=C.verb;x=C.headers;w=Pn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){ae(r,"htmx:validation:halted",C);re(s);m();return e}const _=n.split("#");const z=_[0];const O=_[1];let R=n;if(E){R=z;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=gn(w);if(O){R+="#"+O}}}if(!In(r,R,C)){fe(r,"htmx:invalidPath",C);re(l);m();return e}g.open(t.toUpperCase(),R,true);g.overrideMimeType("text/html");g.withCredentials=C.withCredentials;g.timeout=C.timeout;if(S.noHeaders){}else{for(const D in x){if(x.hasOwnProperty(D)){const Y=x[D];qn(g,D,Y)}}}const H={xhr:g,target:c,requestConfig:C,etc:i,boosted:$,select:M,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};g.onload=function(){try{const t=Nn(r);H.pathInfo.responsePath=An(g);F(r,H);if(H.keepIndicators!==true){rn(T,q)}ae(r,"htmx:afterRequest",H);ae(r,"htmx:afterOnLoad",H);if(!se(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(se(n)){e=n}}if(e){ae(e,"htmx:afterRequest",H);ae(e,"htmx:afterOnLoad",H)}}re(s)}catch(e){fe(r,"htmx:onLoadError",le({error:e},H));throw e}finally{m()}};g.onerror=function(){rn(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);re(l);m()};g.onabort=function(){rn(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);re(l);m()};g.ontimeout=function(){rn(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);re(l);m()};if(!ae(r,"htmx:beforeRequest",H)){re(s);m();return e}var T=tn(r);var q=nn(r);ie(["loadstart","loadend","progress","abort"],function(t){ie([g,g.upload],function(e){e.addEventListener(t,function(e){ae(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ae(r,"htmx:beforeSend",H);const J=E?null:wn(g,r,w);g.send(J);return e}function Fn(e,t){const n=t.xhr;let r=null;let o=null;if(H(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(H(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(H(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=ne(e,"hx-push-url");const c=ne(e,"hx-replace-url");const u=oe(e).boosted;let f=null;let a=null;if(l){f="push";a=l}else if(c){f="replace";a=c}else if(u){f="push";a=s||i}if(a){if(a==="false"){return{}}if(a==="true"){a=s||i}if(t.pathInfo.anchor&&a.indexOf("#")===-1){a=a+"#"+t.pathInfo.anchor}return{type:f,path:a}}else{return{}}}function Mn(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Xn(e){for(var t=0;t`+`.${t}{opacity:0;visibility: hidden} `+`.${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`+"")}}function Zn(){const e=te().querySelector('meta[name="htmx-config"]');if(e){return v(e.content)}else{return null}}function Yn(){const e=Zn();if(e){Q.config=le(Q.config,e)}}Gn(function(){Yn();Wn();let e=te().body;Mt(e);const t=te().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=oe(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){en();ie(t,function(e){ae(e,"htmx:restored",{document:te(),triggerEvent:ae})})}else{if(n){n(e)}}};b().setTimeout(function(){ae(e,"htmx:load",{});e=null},0)});return Q}(); diff --git a/crates/provisioning-daemon/ui/templates/base.html b/crates/provisioning-daemon/ui/templates/base.html new file mode 100644 index 0000000..213e843 --- /dev/null +++ b/crates/provisioning-daemon/ui/templates/base.html @@ -0,0 +1,675 @@ + + + + + + {% block title %}{{ project_name }}{% endblock title %} — Provisioning + + + + + + {% block head %}{% endblock head %} + + + + + +{% if ws_name %} +
+ ⚙ {{ project_name }} + / + workspaces + / + {% if current_env %} + {{ ws_name }} + / + {{ current_env }} + {% else %} + {{ ws_name }} + {% endif %} + {% block breadcrumb_extra %}{% endblock %} +
+{% endif %} + +
+ {% block content %}{% endblock content %} +
+ +{% block scripts %}{% endblock scripts %} + + + + + + +
+ provisioning + v{{ daemon_version }} + | + 2026 + {% if now_hms %} + | + {{ now_hms }} UTC + {% endif %} +
+ + + diff --git a/crates/provisioning-daemon/ui/templates/macros/ui.html b/crates/provisioning-daemon/ui/templates/macros/ui.html new file mode 100644 index 0000000..a55fca7 --- /dev/null +++ b/crates/provisioning-daemon/ui/templates/macros/ui.html @@ -0,0 +1,294 @@ +{% macro stat(title, value, desc="", accent="") %} +
+
{{ title }}
+
{{ value }}
+ {% if desc %}
{{ desc }}
{% endif %} +
+{% endmacro stat %} + +{% macro badge(text, kind="neutral") %} +{{ text }} +{% endmacro badge %} + +{% macro category_badge(cat) %} +{% if cat == "Read" %} + read +{% elif cat == "Mutation" %} + mutation +{% elif cat == "Destructive" %} + destructive +{% elif cat == "Admin" %} + admin +{% else %} + {{ cat }} +{% endif %} +{% endmacro category_badge %} + +{% macro outcome_badge(record) %} +{% if record.outcome.status == "ok" %} + ok +{% else %} + err {{ record.outcome.code }} +{% endif %} +{% endmacro outcome_badge %} + +{% macro server_status(status) %} +{% if status == "running" %} + running +{% elif status == "off" %} + off +{% elif status == "not_provisioned" %} + declared +{% elif status == "initializing" or status == "starting" %} + {{ status }} +{% elif status %} + {{ status }} +{% else %} + +{% endif %} +{% endmacro server_status %} + +{% macro ws_tabs(ws_name, active, ws_envs, ws_has_clusters=false, ws_has_workflows=false, current_env="") %} +{# + Row 1 (.ws-nav): Overview | Infras▼ | [spacer] | Reflect▼ | Track▼ | Knowledge▼ | Dev▼ + Row 2 (.ws-subnav): Servers | Components | Settings | [Workflow] (only when current_env set) + nav-icon / nav-label classes hook into the global html.nav-icons / html.nav-names CSS. + Mobile (