diff --git a/.github/image/Dockerfile b/.github/image/Dockerfile index 9c86ee6..2504c91 100644 --- a/.github/image/Dockerfile +++ b/.github/image/Dockerfile @@ -1,6 +1,10 @@ FROM debian:12-slim -RUN apt-get update && apt-get install -y ca-certificates build-essential libssl-dev pkg-config && rm -rf /var/lib/apt/lists/* +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash \ + && apt-get purge -y --auto-remove curl \ + && rm -rf /var/lib/apt/lists/* ARG TARGETARCH ARG BIN diff --git a/.github/workflows/build_mcp_server.yml b/.github/workflows/build_mcp_server.yml new file mode 100644 index 0000000..e799217 --- /dev/null +++ b/.github/workflows/build_mcp_server.yml @@ -0,0 +1,108 @@ +name: Build MCP server + +on: + push: + branches: + - "main" + paths: + - ".github/image/Dockerfile" + - ".github/workflows/build_mcp_server.yml" + - "mcp-server/**" + +jobs: + build: + continue-on-error: true + + strategy: + matrix: + include: + - release_for: Linux-x86_64 + build_on: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + args: "--locked --release" + + - release_for: Linux-arm64 + build_on: ubuntu-22.04-arm + target: "aarch64-unknown-linux-gnu" + args: "--locked --release" + + runs-on: ${{ matrix.build_on }} + + steps: + - name: checkout repository + uses: actions/checkout@v4 + + - uses: Swatinem/rust-cache@v2 + with: + shared-key: "release" + workspaces: mcp-server -> target + + - name: Install stable toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + + - name: Run cargo build + run: cargo build --target ${{ matrix.target }} ${{ matrix.args }} + working-directory: mcp-server + + - name: rename binaries + run: | + mv target/${{ matrix.target }}/release/supernode-mcp${{ matrix.ext }} supernode-mcp-${{ matrix.release_for }}${{ matrix.ext }} + + - name: upload + uses: actions/upload-artifact@v4 + with: + name: binaries-supernode-mcp-${{ matrix.release_for }} + path: supernode-mcp-${{ matrix.release_for }}${{ matrix.ext }} + + docker: + runs-on: ubuntu-latest + needs: [build] + + strategy: + matrix: + include: + - tags: ghcr.io/txpipe/metis-supernode-mcp,ghcr.io/txpipe/metis-supernode-mcp:${{ github.sha }} + binary: supernode-mcp + + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + pattern: binaries-* + merge-multiple: true + path: .github/image/bin + + - uses: satackey/action-docker-layer-caching@v0.0.11 + continue-on-error: true + + - name: Rename artifacts + run: |+ + mv .github/image/bin/supernode-mcp-Linux-x86_64 .github/image/bin/supernode-mcp-Linux-amd64 + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: .github/image + platforms: linux/arm64,linux/amd64 + push: true + tags: ${{ matrix.tags }} + build-args: BIN=${{ matrix.binary }} diff --git a/.github/workflows/check_mcp_server.yml b/.github/workflows/check_mcp_server.yml new file mode 100644 index 0000000..4fa3f5d --- /dev/null +++ b/.github/workflows/check_mcp_server.yml @@ -0,0 +1,28 @@ +name: Check MCP server + +on: + push: + branches: + - main + paths: + - ".github/workflows/check_mcp_server.yml" + - "mcp-server/**" + pull_request: + branches: + - main + paths: + - ".github/workflows/check_mcp_server.yml" + - "mcp-server/**" + +jobs: + lint: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./mcp-server + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Clippy check lints + run: cargo clippy -- -D warnings diff --git a/.github/workflows/test_mcp_server.yml b/.github/workflows/test_mcp_server.yml new file mode 100644 index 0000000..bbba2f0 --- /dev/null +++ b/.github/workflows/test_mcp_server.yml @@ -0,0 +1,28 @@ +name: Test MCP server + +on: + push: + branches: + - main + paths: + - ".github/workflows/test_mcp_server.yml" + - "mcp-server/**" + pull_request: + branches: + - main + paths: + - ".github/workflows/test_mcp_server.yml" + - "mcp-server/**" + +jobs: + lint: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./mcp-server + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Test + run: cargo test diff --git a/.gitignore b/.gitignore index 14a4408..8fe0f1a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /cli/target /backend/target /operator/target +/mcp-server/target .env cert diff --git a/bootstrap/kind/README.md b/bootstrap/kind/README.md index 949d556..0675221 100644 --- a/bootstrap/kind/README.md +++ b/bootstrap/kind/README.md @@ -64,6 +64,71 @@ If you want to configure the shared VSO auth resources after the install, use: VAULT_TOKEN=root ../extensions/control-plane/scripts/post_install.sh ``` +## Local Control Plane With Local MCP Image + +Use this flow when testing local MCP server changes in Kind before publishing an +image. + +Create or reuse the Kind cluster: + +```bash +./bootstrap/kind/bootstrap.sh --config ./bootstrap/kind/config.yml +``` + +Build and load the local MCP image into Kind: + +```bash +docker build -t metis-supernode-mcp:dev ./mcp-server +kind load docker-image metis-supernode-mcp:dev --name "${KIND_CLUSTER_NAME:-supernode}" +``` + +Install the local control-plane chart with the local MCP image and dev Vault: + +```bash +helm dependency build ./extensions/control-plane +helm upgrade --install control-plane ./extensions/control-plane \ + --namespace control-plane \ + --create-namespace \ + --values ./bootstrap/kind/values.yml \ + --values ./extensions/control-plane/examples/dev-values.yaml \ + --set supernodeMcp.image.repository=metis-supernode-mcp \ + --set supernodeMcp.image.tag=dev \ + --set supernodeMcp.image.pullPolicy=Never +``` + +The control-plane chart enables a PVC-backed SQLite MCP session store by +default. This lets existing MCP sessions survive a pod restart, as long as the +client keeps using the same `Mcp-Session-Id` and the session has not expired. + +Configure Vault for local MCP runtime-secret operations: + +```bash +VAULT_TOKEN=root ./extensions/control-plane/scripts/post_install.sh +``` + +Forward the MCP service and check health: + +```bash +kubectl -n control-plane rollout status deployment/supernode-mcp +kubectl -n control-plane port-forward service/supernode-mcp 8082:8443 +curl http://127.0.0.1:8082/healthz +``` + +Live `workloads.install` calls are enabled through MCP. Use `dryRun: true` to +inspect the generated Helm values, then call the same tool with `dryRun: false` +to apply the install. + +When rebuilding the MCP image, load it again and restart the deployment: + +```bash +docker build -t metis-supernode-mcp:dev ./mcp-server +kind load docker-image metis-supernode-mcp:dev --name "${KIND_CLUSTER_NAME:-supernode}" +kubectl -n control-plane rollout restart deployment/supernode-mcp +``` + +MCP clients should reinitialize only when the server returns `404 Session not +found`, which means the session was deleted, expired, or the PVC was replaced. + ## Outputs - Creates (or reuses) a Kind cluster named `supernode` by default. diff --git a/extensions/cardano-node/templates/configmap-metrics.yaml b/extensions/cardano-node/templates/configmap-metrics.yaml index de16922..6ad1436 100644 --- a/extensions/cardano-node/templates/configmap-metrics.yaml +++ b/extensions/cardano-node/templates/configmap-metrics.yaml @@ -456,6 +456,7 @@ data: (if $kesExpirationSecondsValue != null then $kesExpirationSecondsValue elif $kesRemainingValue != null and $slotsPerKESPeriodValue != null and $slotLengthValue != null then (($kesRemainingValue * $slotsPerKESPeriodValue * $slotLengthValue) | floor) else null end) as $resolvedKesExpirationSeconds | { type: "cardano-node", + role: "relay", blockHeight: nullable_number($blockHeight), epoch: nullable_number($epoch), slotNum: $slotNumValue, diff --git a/extensions/control-plane/README.md b/extensions/control-plane/README.md index 0748c16..f1a3b5f 100644 --- a/extensions/control-plane/README.md +++ b/extensions/control-plane/README.md @@ -168,8 +168,10 @@ For dev mode, skip initialization and unseal entirely. requires the `vault` CLI to be installed on your machine, assumes `vault operator init` has already been run for standalone or HA modes, port-forwards to `service/control-plane-vault`, and configures the shared - Kubernetes auth mount, KV v2 mount, policy, and role using the local - `vault` CLI. By default, the shared role can read only `kv/runtime/...`. + Kubernetes auth mount, KV v2 mount, shared workload policy/role, and MCP + Vault token Secret using the local `vault` CLI. By default, the shared role + can read only `kv/runtime/...`, while the MCP token can read/write only + `kv/runtime/...`. ```shell VAULT_TOKEN= ./scripts/post_install.sh @@ -189,7 +191,8 @@ kubectl -n control-plane exec -it control-plane-vault-0 -- \ ``` The `post_install.sh` script is safe to rerun when you intentionally want to -reconcile the Vault auth mount, policy, role, or KV mount. +reconcile the Vault auth mount, policy, role, KV mount, or MCP Vault token +Secret. The default post-install policy is intentionally read-only and prefix-scoped. Workload charts that create `VaultStaticSecret` resources are expected to @@ -346,6 +349,15 @@ helm template control-plane . -f examples/aws-values.yaml | kubeconform -strict | `prometheusOperator.tolerations` | Tolerations applied to the Prometheus Operator deployment | `[]` | | `grafana.tolerations` | Tolerations applied to the Grafana StatefulSet | `[]` | | `prometheus.tolerations` | Tolerations applied to the Prometheus CRD | `[]` | +| `supernodeMcp.sessionStore.type` | MCP Streamable HTTP session store (`sqlite` or `memory`) | `sqlite` | +| `supernodeMcp.sessionStore.sqlitePath` | SQLite database path for restart-resistant MCP sessions | `/var/lib/supernode-mcp/sessions.sqlite3` | +| `supernodeMcp.sessionStore.ttlSeconds` | Session restore TTL in seconds | `86400` | +| `supernodeMcp.helm.valuesDir` | Writable directory used for generated MCP Helm values files | `/var/lib/supernode-mcp/helm/values` | +| `supernodeMcp.helm.cacheHome` | Helm cache directory for the MCP container | `/var/lib/supernode-mcp/helm/cache` | +| `supernodeMcp.helm.configHome` | Helm config directory for the MCP container | `/var/lib/supernode-mcp/helm/config` | +| `supernodeMcp.helm.dataHome` | Helm data directory for the MCP container | `/var/lib/supernode-mcp/helm/data` | +| `supernodeMcp.persistence.enabled` | Creates a PVC for the MCP SQLite session store | `true` | +| `supernodeMcp.persistence.size` | MCP session store PVC size | `1Gi` | Consult `values.yaml` for the authoritative list. diff --git a/extensions/control-plane/scripts/post_install.sh b/extensions/control-plane/scripts/post_install.sh index 0e3e83e..f6cbdf6 100755 --- a/extensions/control-plane/scripts/post_install.sh +++ b/extensions/control-plane/scripts/post_install.sh @@ -15,6 +15,7 @@ VAULT_TOKEN="${VAULT_TOKEN:?set VAULT_TOKEN}" RELEASE_NAME="${RELEASE_NAME:-control-plane}" NAMESPACE="${NAMESPACE:-control-plane}" +KUBECTL_CONTEXT="${KUBECTL_CONTEXT:-}" LOCAL_PORT="${LOCAL_PORT:-8200}" VAULT_AUTH_MOUNT="${VAULT_AUTH_MOUNT:-kubernetes}" VAULT_AUTH_PATH="${VAULT_AUTH_PATH:-auth/kubernetes}" @@ -30,10 +31,32 @@ VAULT_ROLE_POLICIES="${VAULT_ROLE_POLICIES:-control-plane}" VAULT_ROLE_AUDIENCE="${VAULT_ROLE_AUDIENCE:-}" VAULT_DISABLE_ISS_VALIDATION="${VAULT_DISABLE_ISS_VALIDATION:-true}" KUBERNETES_HOST="${KUBERNETES_HOST:-https://kubernetes.default.svc:443}" +MCP_VAULT_ENABLED="${MCP_VAULT_ENABLED:-true}" +MCP_VAULT_POLICY_NAME="${MCP_VAULT_POLICY_NAME:-supernode-mcp}" +MCP_VAULT_TOKEN_SECRET_NAME="${MCP_VAULT_TOKEN_SECRET_NAME:-supernode-mcp-vault-token}" +MCP_VAULT_TOKEN_SECRET_KEY="${MCP_VAULT_TOKEN_SECRET_KEY:-token}" +MCP_VAULT_TOKEN_DISPLAY_NAME="${MCP_VAULT_TOKEN_DISPLAY_NAME:-supernode-mcp}" +MCP_VAULT_TOKEN_TTL="${MCP_VAULT_TOKEN_TTL:-720h}" +MCP_VAULT_TOKEN_PERIOD="${MCP_VAULT_TOKEN_PERIOD:-}" +MCP_VAULT_TOKEN_ORPHAN="${MCP_VAULT_TOKEN_ORPHAN:-true}" VAULT_ADDR="http://127.0.0.1:${LOCAL_PORT}" PORT_FORWARD_LOG="$(mktemp)" PORT_FORWARD_PID="" +MCP_VAULT_TOKEN_FILE="" +KUBECTL_CONTEXT_TEXT="" + +if [[ -n "$KUBECTL_CONTEXT" ]]; then + KUBECTL_CONTEXT_TEXT="--context ${KUBECTL_CONTEXT} " +fi + +kubectl_cmd() { + if [[ -n "$KUBECTL_CONTEXT" ]]; then + kubectl --context "$KUBECTL_CONTEXT" "$@" + else + kubectl "$@" + fi +} normalize_prefix() { local prefix="$1" @@ -54,6 +77,9 @@ cleanup() { wait "${PORT_FORWARD_PID}" >/dev/null 2>&1 || true fi rm -f "${PORT_FORWARD_LOG}" + if [[ -n "${MCP_VAULT_TOKEN_FILE}" ]]; then + rm -f "${MCP_VAULT_TOKEN_FILE}" + fi } trap cleanup EXIT @@ -75,8 +101,29 @@ if prefix_overlaps "$VAULT_KV_RUNTIME_PREFIX" "$VAULT_KV_OPERATOR_PREFIX"; then exit 1 fi +if [[ "$MCP_VAULT_ENABLED" == "true" ]]; then + if [[ -z "$MCP_VAULT_POLICY_NAME" ]]; then + printf 'MCP_VAULT_POLICY_NAME must not be empty when MCP_VAULT_ENABLED=true.\n' >&2 + exit 1 + fi + + if [[ -z "$MCP_VAULT_TOKEN_SECRET_NAME" ]]; then + printf 'MCP_VAULT_TOKEN_SECRET_NAME must not be empty when MCP_VAULT_ENABLED=true.\n' >&2 + exit 1 + fi + + if [[ -z "$MCP_VAULT_TOKEN_SECRET_KEY" ]]; then + printf 'MCP_VAULT_TOKEN_SECRET_KEY must not be empty when MCP_VAULT_ENABLED=true.\n' >&2 + exit 1 + fi + + if [[ -n "$MCP_VAULT_TOKEN_PERIOD" && -n "$MCP_VAULT_TOKEN_TTL" ]]; then + printf 'MCP_VAULT_TOKEN_PERIOD is set; MCP_VAULT_TOKEN_TTL will be ignored for the MCP token.\n' + fi +fi + printf 'Starting kubectl port-forward to service/%s-vault in namespace %s...\n' "$RELEASE_NAME" "$NAMESPACE" -kubectl -n "$NAMESPACE" port-forward "service/${RELEASE_NAME}-vault" "${LOCAL_PORT}:8200" >"${PORT_FORWARD_LOG}" 2>&1 & +kubectl_cmd -n "$NAMESPACE" port-forward "service/${RELEASE_NAME}-vault" "${LOCAL_PORT}:8200" >"${PORT_FORWARD_LOG}" 2>&1 & PORT_FORWARD_PID="$!" wait_for_vault() { @@ -152,6 +199,50 @@ fi vault write "$VAULT_AUTH_PATH/role/${VAULT_ROLE_NAME}" "${role_args[@]}" +if [[ "$MCP_VAULT_ENABLED" == "true" ]]; then + printf 'Writing MCP Vault policy %s...\n' "$MCP_VAULT_POLICY_NAME" + vault policy write "$MCP_VAULT_POLICY_NAME" - <"$MCP_VAULT_TOKEN_FILE" + + kubectl_cmd -n "$NAMESPACE" create secret generic "$MCP_VAULT_TOKEN_SECRET_NAME" \ + "--from-file=${MCP_VAULT_TOKEN_SECRET_KEY}=${MCP_VAULT_TOKEN_FILE}" \ + --dry-run=client \ + -o yaml \ + | kubectl_cmd -n "$NAMESPACE" apply -f - +fi + cat < /tmp/hydra-v | Value | Description | Default | |-------|-------------|---------| -| `node.offlineMode` | Run `hydra-node offline` with pre-seeded ledger state | `true` | +| `node.offlineMode` | Run without Cardano L1 connectivity using pre-seeded ledger state | `true` | +| `node.offlineHeadSeed` | Hexadecimal offline head seed shared by offline participants | `0001` | +| `node.peers` | Static peer endpoints passed as `--peer` values | `[]` | | `keys.vaultAuth.ref` | Shared VaultAuth reference used by chart-managed VaultStaticSecret resources | `control-plane/default` | -| `keys.hydraSigning.*` | Source of the Hydra signing key (`hydra.sk`) | empty | -| `keys.hydraSigning.vaultStaticSecret.*` | Optional VaultStaticSecret that syncs the Hydra signing key into Kubernetes | disabled | +| `keys.hydraSigning.vaultStaticSecret.*` | Required VaultStaticSecret that syncs the Hydra signing key into Kubernetes | path empty | | `keys.hydraVerification.items` | Filenames (and optional inline payloads) for verification keys | `[]` | | `ledger.protocolParameters` | Provides `protocol-parameters.json` for offline heads | inline demo JSON | | `ledger.initialUtxo` | Provides `utxo.json` for offline heads | inline demo JSON | | `keys.cardano.enabled` | Switch on online mode support (requires additional settings) | `false` | -| `keys.cardano.signing.vaultStaticSecret.*` | Optional VaultStaticSecret that syncs the Cardano signing key into Kubernetes | disabled | +| `keys.cardano.signing.vaultStaticSecret.*` | Required VaultStaticSecret that syncs the Cardano signing key into Kubernetes when online mode is enabled | path empty | | `node.cardanoSocketProxy.enabled` | Launch a `socat` sidecar that exposes a remote Cardano node as a Unix socket | `false` | | `service.apiPort` | WebSocket API port exposed on the Service | `4001` | +| `service.monitoringPort` | Hydra Prometheus metrics port scraped by the mandatory PodMonitor | `6001` | | `persistence.size` | Persistent volume claim size for the Hydra state | `5Gi` | Consult `values.yaml` for the full matrix of options and tailoring knobs. diff --git a/extensions/hydra-node/ci/values-offline-existing.yaml b/extensions/hydra-node/ci/values-offline-existing.yaml index be366cf..4e8e9a1 100644 --- a/extensions/hydra-node/ci/values-offline-existing.yaml +++ b/extensions/hydra-node/ci/values-offline-existing.yaml @@ -8,9 +8,8 @@ ledger: keys: hydraSigning: - existingSecret: - name: hydra-existing-signing - key: hydra.sk + vaultStaticSecret: + path: runtime/hydra/offline-existing/hydra-signing hydraVerification: existingConfigMap: name: hydra-existing-verification diff --git a/extensions/hydra-node/ci/values-offline-inline.yaml b/extensions/hydra-node/ci/values-offline-inline.yaml index 9c3476a..188595b 100644 --- a/extensions/hydra-node/ci/values-offline-inline.yaml +++ b/extensions/hydra-node/ci/values-offline-inline.yaml @@ -1,7 +1,7 @@ keys: hydraSigning: - value: |- - offline-hydra-signing-key + vaultStaticSecret: + path: runtime/hydra/offline/hydra-signing hydraVerification: items: - filename: hydra.vk diff --git a/extensions/hydra-node/ci/values-online-inline.yaml b/extensions/hydra-node/ci/values-online-inline.yaml index c00e1b3..3c2505d 100644 --- a/extensions/hydra-node/ci/values-online-inline.yaml +++ b/extensions/hydra-node/ci/values-online-inline.yaml @@ -11,8 +11,8 @@ node: keys: hydraSigning: - value: |- - online-hydra-signing-key + vaultStaticSecret: + path: runtime/hydra/online/hydra-signing hydraVerification: items: - filename: hydra-self.vk @@ -25,8 +25,8 @@ keys: enabled: true socketPath: /ipc/node.socket signing: - value: |- - online-cardano-signing-key + vaultStaticSecret: + path: runtime/hydra/online/cardano-signing verification: value: |- online-cardano-verification-key diff --git a/extensions/hydra-node/ci/values-vault-static-secret.yaml b/extensions/hydra-node/ci/values-vault-static-secret.yaml index bf3b1be..458215f 100644 --- a/extensions/hydra-node/ci/values-vault-static-secret.yaml +++ b/extensions/hydra-node/ci/values-vault-static-secret.yaml @@ -1,8 +1,7 @@ keys: hydraSigning: vaultStaticSecret: - enabled: true - path: hydra/hydra-signing + path: runtime/hydra/hydra-signing hydraVerification: existingConfigMap: name: hydra-verification diff --git a/extensions/hydra-node/templates/_helpers.tpl b/extensions/hydra-node/templates/_helpers.tpl index 682ce0a..47e62af 100644 --- a/extensions/hydra-node/templates/_helpers.tpl +++ b/extensions/hydra-node/templates/_helpers.tpl @@ -88,25 +88,15 @@ Resolve ConfigMap name for initial UTxO. Resolve Secret name for Hydra signing key. */}} {{- define "hydra-node.hydraSigningSecretName" -}} -{{- if .Values.keys.hydraSigning.existingSecret.name }} -{{- .Values.keys.hydraSigning.existingSecret.name }} -{{- else if or .Values.keys.hydraSigning.value .Values.keys.hydraSigning.vaultStaticSecret.enabled }} {{- printf "%s-hydra-signing" (include "hydra-node.fullname" .) | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- "" }} -{{- end }} {{- end }} {{/* Resolve Secret key for Hydra signing key. */}} {{- define "hydra-node.hydraSigningSecretKey" -}} -{{- if .Values.keys.hydraSigning.existingSecret.key }} -{{- .Values.keys.hydraSigning.existingSecret.key }} -{{- else }} {{- default "hydra.sk" .Values.keys.hydraSigning.filename }} {{- end }} -{{- end }} {{/* Resolve ConfigMap name for Hydra verification keys. @@ -125,9 +115,7 @@ Resolve ConfigMap name for Hydra verification keys. Resolve Secret name for Cardano signing key. */}} {{- define "hydra-node.cardanoSigningSecretName" -}} -{{- if .Values.keys.cardano.signing.existingSecret.name }} -{{- .Values.keys.cardano.signing.existingSecret.name }} -{{- else if and .Values.keys.cardano.enabled (or .Values.keys.cardano.signing.value .Values.keys.cardano.signing.vaultStaticSecret.enabled) }} +{{- if .Values.keys.cardano.enabled }} {{- printf "%s-cardano-signing" (include "hydra-node.fullname" .) | trunc 63 | trimSuffix "-" }} {{- else }} {{- "" }} @@ -138,12 +126,8 @@ Resolve Secret name for Cardano signing key. Resolve Secret key for Cardano signing key. */}} {{- define "hydra-node.cardanoSigningSecretKey" -}} -{{- if .Values.keys.cardano.signing.existingSecret.key }} -{{- .Values.keys.cardano.signing.existingSecret.key }} -{{- else }} {{- default "cardano.sk" .Values.keys.cardano.signing.filename }} {{- end }} -{{- end }} {{/* Resolve ConfigMap name for Cardano verification key. @@ -157,3 +141,10 @@ Resolve ConfigMap name for Cardano verification key. {{- "" }} {{- end }} {{- end }} + +{{/* +Resolve the ConfigMap name for Metis metrics scripts. +*/}} +{{- define "hydra-node.metricsConfigMapName" -}} +{{- printf "%s-metrics" (include "hydra-node.fullname" .) | trunc 63 | trimSuffix "-" }} +{{- end }} diff --git a/extensions/hydra-node/templates/configmap-metrics.yaml b/extensions/hydra-node/templates/configmap-metrics.yaml new file mode 100644 index 0000000..0092171 --- /dev/null +++ b/extensions/hydra-node/templates/configmap-metrics.yaml @@ -0,0 +1,150 @@ +{{- $configMapName := include "hydra-node.metricsConfigMapName" . -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ $configMapName }} + labels: + {{- include "hydra-node.labels" . | nindent 4 }} +data: + metrics.sh: |- + #!/usr/bin/env sh + set -u + + errors_json="[]" + + append_error() { + msg="$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g')" + if [ "$errors_json" = "[]" ]; then + errors_json="[\"$msg\"]" + else + errors_json="${errors_json%]},\"$msg\"]" + fi + } + + command_exists() { + command -v "$1" >/dev/null 2>&1 + } + + http_get() { + url="$1" + curl -s --fail "$url" 2>/dev/null || wget -qO- "$url" 2>/dev/null || true + } + + metric_value() { + metric_name="$1" + printf '%s\n' "$prometheus" | awk -v key="$metric_name" '$1 == key { print $2; exit }' + } + + json_get() { + json="$1" + filter="$2" + if command_exists jq && [ -n "$json" ]; then + printf '%s' "$json" | jq -r "$filter // empty" 2>/dev/null || true + fi + } + + json_count() { + json="$1" + filter="$2" + if command_exists jq && [ -n "$json" ]; then + printf '%s' "$json" | jq -r "$filter | length" 2>/dev/null || true + fi + } + + json_lovelace_sum() { + json="$1" + if command_exists jq && [ -n "$json" ]; then + printf '%s' "$json" | jq -r '[.[]?.value?.lovelace // 0] | add // 0' 2>/dev/null || true + fi + } + + json_string_or_null() { + value="$1" + if [ -z "$value" ]; then + printf 'null' + else + printf '"%s"' "$(printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g')" + fi + } + + json_number_or_null() { + value="$1" + if printf '%s' "$value" | grep -Eq '^-?[0-9]+([.][0-9]+)?$'; then + printf '%s' "$value" + else + printf 'null' + fi + } + + api_base="http://127.0.0.1:{{ .Values.service.apiPort }}" + monitoring_base="http://127.0.0.1:{{ .Values.service.monitoringPort }}" + mode="{{ ternary "offline" "online" .Values.node.offlineMode }}" + + prometheus="$(http_get "$monitoring_base/metrics")" + [ -n "$prometheus" ] || append_error "failed to read Hydra Prometheus metrics" + + head="$(http_get "$api_base/head")" + [ -n "$head" ] || append_error "failed to read Hydra /head endpoint" + + snapshot="$(http_get "$api_base/snapshot")" + [ -n "$snapshot" ] || append_error "failed to read Hydra /snapshot endpoint" + + snapshot_utxo="$(http_get "$api_base/snapshot/utxo")" + [ -n "$snapshot_utxo" ] || append_error "failed to read Hydra /snapshot/utxo endpoint" + + last_seen="$(http_get "$api_base/snapshot/last-seen")" + [ -n "$last_seen" ] || append_error "failed to read Hydra /snapshot/last-seen endpoint" + + commits="$(http_get "$api_base/commits")" + [ -n "$commits" ] || append_error "failed to read Hydra /commits endpoint" + + if ! command_exists jq; then + append_error "jq is not available; API-derived metrics are limited" + fi + + head_status="$(json_get "$head" '.tag // .headStatus')" + head_id="$(json_get "$head" '.headId // .hydraHeadId')" + hydra_node_version="$(json_get "$head" '.hydraNodeVersion')" + current_slot="$(json_get "$head" '.currentSlot')" + chain_synced_status="$(json_get "$head" '.chainSyncedStatus.tag // .chainSyncedStatus')" + snapshot_number="$(json_get "$snapshot" '.snapshot.number // .number')" + snapshot_version="$(json_get "$snapshot" '.snapshot.version // .version')" + last_seen_snapshot_tag="$(json_get "$last_seen" '.tag')" + pending_deposits="$(json_count "$commits" '.')" + confirmed_utxo_count="$(json_count "$snapshot_utxo" '.')" + confirmed_lovelace="$(json_lovelace_sum "$snapshot_utxo")" + + peers_connected="$(metric_value hydra_head_peers_connected)" + requested_tx="$(metric_value hydra_head_requested_tx)" + confirmed_tx="$(metric_value hydra_head_confirmed_tx)" + inputs="$(metric_value hydra_head_inputs)" + tx_confirmation_count="$(metric_value hydra_head_tx_confirmation_time_ms_count)" + tx_confirmation_sum="$(metric_value hydra_head_tx_confirmation_time_ms_sum)" + tx_confirmation_avg="" + if [ -n "$tx_confirmation_count" ] && [ -n "$tx_confirmation_sum" ]; then + tx_confirmation_avg="$(awk -v sum="$tx_confirmation_sum" -v count="$tx_confirmation_count" 'BEGIN { if (count > 0) print sum / count }')" + fi + + printf '{' + printf '"type":"hydra-node"' + printf ',"mode":%s' "$(json_string_or_null "$mode")" + printf ',"headStatus":%s' "$(json_string_or_null "$head_status")" + printf ',"headId":%s' "$(json_string_or_null "$head_id")" + printf ',"hydraNodeVersion":%s' "$(json_string_or_null "$hydra_node_version")" + printf ',"currentSlot":%s' "$(json_number_or_null "$current_slot")" + printf ',"chainSyncedStatus":%s' "$(json_string_or_null "$chain_synced_status")" + printf ',"peersConnected":%s' "$(json_number_or_null "$peers_connected")" + printf ',"pendingDeposits":%s' "$(json_number_or_null "$pending_deposits")" + printf ',"snapshotNumber":%s' "$(json_number_or_null "$snapshot_number")" + printf ',"snapshotVersion":%s' "$(json_number_or_null "$snapshot_version")" + printf ',"confirmedUtxoCount":%s' "$(json_number_or_null "$confirmed_utxo_count")" + printf ',"confirmedLovelace":%s' "$(json_number_or_null "$confirmed_lovelace")" + printf ',"lastSeenSnapshotTag":%s' "$(json_string_or_null "$last_seen_snapshot_tag")" + printf ',"requestedTx":%s' "$(json_number_or_null "$requested_tx")" + printf ',"confirmedTx":%s' "$(json_number_or_null "$confirmed_tx")" + printf ',"inputs":%s' "$(json_number_or_null "$inputs")" + printf ',"txConfirmationTimeMsCount":%s' "$(json_number_or_null "$tx_confirmation_count")" + printf ',"txConfirmationTimeMsSum":%s' "$(json_number_or_null "$tx_confirmation_sum")" + printf ',"txConfirmationTimeMsAvg":%s' "$(json_number_or_null "$tx_confirmation_avg")" + printf ',"errors":%s' "$errors_json" + printf '}\n' diff --git a/extensions/hydra-node/templates/podmonitor.yaml b/extensions/hydra-node/templates/podmonitor.yaml new file mode 100644 index 0000000..55fbaf3 --- /dev/null +++ b/extensions/hydra-node/templates/podmonitor.yaml @@ -0,0 +1,14 @@ +apiVersion: monitoring.coreos.com/v1 +kind: PodMonitor +metadata: + name: {{ include "hydra-node.fullname" . }} + labels: + app.kubernetes.io/component: o11y + {{- include "hydra-node.labels" . | nindent 4 }} +spec: + selector: + matchLabels: +{{- include "hydra-node.selectorLabels" . | nindent 6 }} + podMetricsEndpoints: + - port: monitoring + path: /metrics diff --git a/extensions/hydra-node/templates/secret-cardano-signing.yaml b/extensions/hydra-node/templates/secret-cardano-signing.yaml deleted file mode 100644 index 4b19c36..0000000 --- a/extensions/hydra-node/templates/secret-cardano-signing.yaml +++ /dev/null @@ -1,12 +0,0 @@ -{{- if and .Values.keys.cardano.enabled (not .Values.keys.cardano.signing.existingSecret.name) (not .Values.keys.cardano.signing.vaultStaticSecret.enabled) .Values.keys.cardano.signing.value }} -{{- $secretName := include "hydra-node.cardanoSigningSecretName" . -}} -apiVersion: v1 -kind: Secret -metadata: - name: {{ $secretName }} - labels: - {{- include "hydra-node.labels" . | nindent 4 }} -type: Opaque -data: - {{ include "hydra-node.cardanoSigningSecretKey" . }}: {{ .Values.keys.cardano.signing.value | b64enc }} -{{- end }} diff --git a/extensions/hydra-node/templates/secret-hydra-signing.yaml b/extensions/hydra-node/templates/secret-hydra-signing.yaml deleted file mode 100644 index b7bb3c2..0000000 --- a/extensions/hydra-node/templates/secret-hydra-signing.yaml +++ /dev/null @@ -1,12 +0,0 @@ -{{- $secretName := include "hydra-node.hydraSigningSecretName" . }} -{{- if and $secretName (not .Values.keys.hydraSigning.existingSecret.name) (not .Values.keys.hydraSigning.vaultStaticSecret.enabled) .Values.keys.hydraSigning.value }} -apiVersion: v1 -kind: Secret -metadata: - name: {{ $secretName }} - labels: - {{- include "hydra-node.labels" . | nindent 4 }} -type: Opaque -data: - {{ include "hydra-node.hydraSigningSecretKey" . }}: {{ .Values.keys.hydraSigning.value | b64enc }} -{{- end }} diff --git a/extensions/hydra-node/templates/serviceaccount-vault-auth.yaml b/extensions/hydra-node/templates/serviceaccount-vault-auth.yaml index c19ef4c..9f48cd7 100644 --- a/extensions/hydra-node/templates/serviceaccount-vault-auth.yaml +++ b/extensions/hydra-node/templates/serviceaccount-vault-auth.yaml @@ -1,4 +1,4 @@ -{{- if and .Values.keys.vaultAuth.serviceAccount.create (or .Values.keys.hydraSigning.vaultStaticSecret.enabled (and .Values.keys.cardano.enabled .Values.keys.cardano.signing.vaultStaticSecret.enabled)) }} +{{- if .Values.keys.vaultAuth.serviceAccount.create }} apiVersion: v1 kind: ServiceAccount metadata: diff --git a/extensions/hydra-node/templates/statefulset.yaml b/extensions/hydra-node/templates/statefulset.yaml index aae4367..ebb0415 100644 --- a/extensions/hydra-node/templates/statefulset.yaml +++ b/extensions/hydra-node/templates/statefulset.yaml @@ -1,10 +1,14 @@ {{- $fullName := include "hydra-node.fullname" . -}} {{- $selectorLabels := include "hydra-node.selectorLabels" . -}} {{- $serviceAccountName := include "hydra-node.serviceAccountName" . -}} +{{- $metricsConfigMapName := include "hydra-node.metricsConfigMapName" . -}} {{- $protocolConfigName := include "hydra-node.protocolParametersConfigMapName" . -}} {{- $initialUtxoConfigName := include "hydra-node.initialUtxoConfigMapName" . -}} -{{- $hydraSigningSecretName := required "You must provide a Hydra signing key via keys.hydraSigning.value or keys.hydraSigning.existingSecret.name" (include "hydra-node.hydraSigningSecretName" .) -}} +{{- $hydraSigningSecretName := include "hydra-node.hydraSigningSecretName" . -}} {{- $hydraSigningSecretKey := include "hydra-node.hydraSigningSecretKey" . -}} +{{- if not .Values.keys.hydraSigning.vaultStaticSecret.path }} + {{- fail "keys.hydraSigning.vaultStaticSecret.path is required; Hydra signing keys must be sourced from Vault" }} +{{- end }} {{- $hydraVerificationConfigName := include "hydra-node.hydraVerificationConfigMapName" . -}} {{- $verificationCount := 0 -}} {{- range $item := .Values.keys.hydraVerification.items }} @@ -25,6 +29,9 @@ {{- if $cardanoEnabled }} {{- $cardanoSigningSecretName = required "Cardano signing key must be provided when keys.cardano.enabled=true" (include "hydra-node.cardanoSigningSecretName" .) }} {{- $cardanoSigningSecretKey = include "hydra-node.cardanoSigningSecretKey" . }} + {{- if not .Values.keys.cardano.signing.vaultStaticSecret.path }} + {{- fail "keys.cardano.signing.vaultStaticSecret.path is required when keys.cardano.enabled=true; Cardano signing keys must be sourced from Vault" }} + {{- end }} {{- $cardanoVerificationConfigName = include "hydra-node.cardanoVerificationConfigMapName" . }} {{- if not $cardanoVerificationConfigName }} {{- fail "Cardano verification key must be provided via keys.cardano.verification when keys.cardano.enabled=true" }} @@ -90,7 +97,8 @@ {{- if not $initialUtxoConfigName }} {{- fail "Offline mode requires ledger.initialUtxo to be configured or provided via existing ConfigMap" }} {{- end }} - {{- $args = append $args "offline" }} + {{- $args = append $args "--offline-head-seed" }} + {{- $args = append $args (required "node.offlineHeadSeed must be set when running offline" .Values.node.offlineHeadSeed) }} {{- $args = append $args "--initial-utxo" }} {{- $args = append $args (printf "%s/%s" $ledgerMount .Values.ledger.initialUtxo.filename) }} {{- $args = append $args "--ledger-protocol-parameters" }} @@ -98,6 +106,10 @@ {{- else }} {{- $args = append $args "--node-id" }} {{- $args = append $args .Values.node.nodeId }} + {{- with .Values.node.network }} + {{- $args = append $args "--network" }} + {{- $args = append $args . }} + {{- end }} {{- $args = append $args "--hydra-scripts-tx-id" }} {{- $args = append $args (required "node.hydraScriptsTxId must be set when running online" .Values.node.hydraScriptsTxId) }} {{- $args = append $args "--cardano-signing-key" }} @@ -106,6 +118,22 @@ {{- $args = append $args (printf "%s/%s" $keysMount (default "cardano.vk" .Values.keys.cardano.verification.filename)) }} {{- $args = append $args "--node-socket" }} {{- $args = append $args $cardanoNodeSocketPath }} + {{- with .Values.node.startChainFrom }} + {{- $args = append $args "--start-chain-from" }} + {{- $args = append $args . }} + {{- end }} + {{- end }} + {{- with .Values.node.contestationPeriod }} + {{- $args = append $args "--contestation-period" }} + {{- $args = append $args . }} + {{- end }} + {{- with .Values.node.depositPeriod }} + {{- $args = append $args "--deposit-period" }} + {{- $args = append $args . }} + {{- end }} + {{- with .Values.node.unsyncedPeriod }} + {{- $args = append $args "--unsynced-period" }} + {{- $args = append $args (printf "%v" .) }} {{- end }} {{- $args = append $args "--hydra-signing-key" }} {{- $args = append $args $hydraSigningPath }} @@ -115,10 +143,18 @@ {{- $args = append $args (printf "%s/%s" $keysMount $item.filename) }} {{- end }} {{- end }} - {{- $args = append $args "--host" }} - {{- $args = append $args (printf "%v" .Values.node.host) }} - {{- $args = append $args "--port" }} - {{- $args = append $args (printf "%v" .Values.service.p2pPort) }} + {{- $listenHost := default .Values.node.host .Values.node.listenHost }} + {{- $args = append $args "--listen" }} + {{- $args = append $args (printf "%v:%v" $listenHost .Values.service.p2pPort) }} + {{- range $peer := .Values.node.peers }} + {{- if kindIs "string" $peer }} + {{- $args = append $args "--peer" }} + {{- $args = append $args $peer }} + {{- else if and $peer.host $peer.port }} + {{- $args = append $args "--peer" }} + {{- $args = append $args (printf "%v:%v" $peer.host $peer.port) }} + {{- end }} + {{- end }} {{- $args = append $args "--api-host" }} {{- $args = append $args (printf "%v" .Values.node.apiHost) }} {{- $args = append $args "--api-port" }} @@ -168,6 +204,7 @@ spec: {{- $_ := set $annotations $key $val }} {{- end }} {{- end }} + {{- $_ := set $annotations "checksum/metrics" (include (print $.Template.BasePath "/configmap-metrics.yaml") . | sha256sum) }} {{- if gt (len $annotations) 0 }} annotations: {{- toYaml $annotations | nindent 8 }} @@ -238,6 +275,10 @@ spec: - name: ledger-config mountPath: {{ .Values.ledger.mountPath | default "/etc/hydra" }} {{- end }} + - name: metrics-scripts + mountPath: /opt/metis/bin/metrics.sh + subPath: metrics.sh + readOnly: true {{- with .Values.node.extraVolumeMounts }} {{- toYaml . | nindent 12 }} {{- end }} @@ -340,6 +381,10 @@ spec: path: {{ .Values.ledger.initialUtxo.filename }} {{- end }} {{- end }} + - name: metrics-scripts + configMap: + name: {{ $metricsConfigMapName }} + defaultMode: 0555 {{- if and (not .Values.persistence.enabled) (not .Values.persistence.existingClaim) }} - name: data emptyDir: {} diff --git a/extensions/hydra-node/templates/vaultstaticsecrets.yaml b/extensions/hydra-node/templates/vaultstaticsecrets.yaml index bfd9bf1..2a7ed4e 100644 --- a/extensions/hydra-node/templates/vaultstaticsecrets.yaml +++ b/extensions/hydra-node/templates/vaultstaticsecrets.yaml @@ -1,7 +1,6 @@ {{- $fullName := include "hydra-node.fullname" . -}} {{- $hydraSigningSecretName := include "hydra-node.hydraSigningSecretName" . -}} {{- $cardanoSigningSecretName := include "hydra-node.cardanoSigningSecretName" . -}} -{{- if .Values.keys.hydraSigning.vaultStaticSecret.enabled }} apiVersion: secrets.hashicorp.com/v1beta1 kind: VaultStaticSecret metadata: @@ -21,11 +20,10 @@ spec: name: {{ $hydraSigningSecretName | quote }} create: true overwrite: true -{{- end }} -{{- if and .Values.keys.hydraSigning.vaultStaticSecret.enabled .Values.keys.cardano.enabled .Values.keys.cardano.signing.vaultStaticSecret.enabled }} +{{- if .Values.keys.cardano.enabled }} --- {{- end }} -{{- if and .Values.keys.cardano.enabled .Values.keys.cardano.signing.vaultStaticSecret.enabled }} +{{- if .Values.keys.cardano.enabled }} apiVersion: secrets.hashicorp.com/v1beta1 kind: VaultStaticSecret metadata: diff --git a/extensions/hydra-node/values.yaml b/extensions/hydra-node/values.yaml index d38d1e8..dd15cf8 100644 --- a/extensions/hydra-node/values.yaml +++ b/extensions/hydra-node/values.yaml @@ -3,8 +3,8 @@ fullnameOverride: "" displayName: "" image: - repository: ghcr.io/input-output-hk/hydra-node - tag: "0.17.0" + repository: ghcr.io/cardano-scaling/hydra-node + tag: "2.1.0" pullPolicy: IfNotPresent imagePullSecrets: [] @@ -69,11 +69,19 @@ node: initContainers: [] terminationGracePeriodSeconds: 60 offlineMode: true + offlineHeadSeed: "0001" quiet: true nodeId: "hydra-node-1" + network: "" + contestationPeriod: "" + depositPeriod: "" + unsyncedPeriod: null + startChainFrom: "" hydraScriptsTxId: "" apiHost: 0.0.0.0 + listenHost: 0.0.0.0 host: 0.0.0.0 + peers: [] persistenceSubPath: "" cardanoSocketProxy: enabled: false @@ -130,15 +138,10 @@ keys: name: vault-auth hydraSigning: filename: hydra.sk - existingSecret: - name: "" - key: "" vaultStaticSecret: - enabled: false mount: kv path: "" refreshAfter: 1m - value: "" hydraVerification: existingConfigMap: name: "" @@ -148,15 +151,10 @@ keys: socketPath: /ipc/node.socket signing: filename: cardano.sk - existingSecret: - name: "" - key: "" vaultStaticSecret: - enabled: false mount: kv path: "" refreshAfter: 1m - value: "" verification: filename: cardano.vk existingConfigMap: diff --git a/mcp-server/.dockerignore b/mcp-server/.dockerignore new file mode 100644 index 0000000..5b0abf1 --- /dev/null +++ b/mcp-server/.dockerignore @@ -0,0 +1,4 @@ +target/ +.git/ +.gitignore +Dockerfile diff --git a/mcp-server/Cargo.lock b/mcp-server/Cargo.lock new file mode 100644 index 0000000..d3ed251 --- /dev/null +++ b/mcp-server/Cargo.lock @@ -0,0 +1,2963 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "log", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonpath-rust" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633a7320c4bb672863a3782e89b9094ad70285e097ff6832cddd0ec615beadfa" +dependencies = [ + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror", +] + +[[package]] +name = "k8s-openapi" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51b326f5219dd55872a72c1b6ddd1b830b8334996c667449c29391d657d78d5e" +dependencies = [ + "base64", + "jiff", + "serde", + "serde_json", +] + +[[package]] +name = "kube" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acc5a6a69da2975ed9925d56b5dcfc9cc739b66f37add06785b7c9f6d1e88741" +dependencies = [ + "k8s-openapi", + "kube-client", + "kube-core", +] + +[[package]] +name = "kube-client" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fcaf2d1f1a91e1805d4cd82e8333c022767ae8ffd65909bbef6802733a7dd40" +dependencies = [ + "base64", + "bytes", + "either", + "form_urlencoded", + "futures", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jiff", + "jsonpath-rust", + "k8s-openapi", + "kube-core", + "pem", + "rustls", + "secrecy", + "serde", + "serde_json", + "serde_yaml", + "tame-oauth", + "thiserror", + "tokio", + "tokio-tungstenite", + "tokio-util", + "tower", + "tower-http", + "tracing", +] + +[[package]] +name = "kube-core" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f126d2db7a8b532ec1d839ece2a71e2485dc3bbca6cc3c3f929becaa810e719e" +dependencies = [ + "derive_more", + "form_urlencoded", + "http", + "jiff", + "k8s-openapi", + "serde", + "serde-value", + "serde_json", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "pastey" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a797f0e07bdf071d15742978fc3128ec6c22891c31a3a931513263904c982a" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmcp" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e12ca9067b5ebfbd5b3fcdc4acfceb81aa7d5ab2a879dff7cb75d22434276aad" +dependencies = [ + "async-trait", + "base64", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "pastey", + "pin-project-lite", + "rand 0.10.1", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "sse-stream", + "thiserror", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7caa6743cc0888e433105fe1bc551a7f607940b126a37bc97b478e86064627eb" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn", +] + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sse-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supernode-mcp" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "base64", + "chrono", + "ed25519-dalek", + "flate2", + "getrandom 0.2.17", + "k8s-openapi", + "kube", + "reqwest", + "rmcp", + "rusqlite", + "serde", + "serde_json", + "sha2", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tame-oauth" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c206bbecfbc0aea8bf35f57bf34e8be060d2cf7efe3937f8d0bdfdd4205ed771" +dependencies = [ + "data-encoding", + "http", + "ring", + "serde", + "serde_json", + "twox-hash", + "url", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "async-compression", + "base64", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "twox-hash" +version = "1.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" +dependencies = [ + "cfg-if", + "static_assertions", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/mcp-server/Cargo.toml b/mcp-server/Cargo.toml new file mode 100644 index 0000000..65790e4 --- /dev/null +++ b/mcp-server/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "supernode-mcp" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1" +async-trait = "0.1" +axum = "0.8" +base64 = "0.22" +chrono = { version = "0.4", features = ["serde"] } +ed25519-dalek = "2" +flate2 = "1" +getrandom = "0.2" +k8s-openapi = { version = "0.27.1", features = ["latest"] } +kube = { version = "3.1.0", features = ["gzip", "oauth", "oidc", "ws"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +rmcp = { version = "1.6", features = ["server", "transport-streamable-http-server"] } +rusqlite = { version = "0.37", features = ["bundled"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +thiserror = "2" +tokio = { version = "1", features = ["macros", "process", "rt-multi-thread", "signal", "io-util", "time"] } +tokio-util = "0.7" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/mcp-server/Dockerfile b/mcp-server/Dockerfile new file mode 100644 index 0000000..7d81bb9 --- /dev/null +++ b/mcp-server/Dockerfile @@ -0,0 +1,22 @@ +FROM rust:1.91-bookworm AS builder + +WORKDIR /app +COPY Cargo.toml Cargo.lock ./ +COPY src ./src + +RUN cargo build --release + +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash \ + && apt-get purge -y --auto-remove curl \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/target/release/supernode-mcp /usr/local/bin/supernode-mcp + +USER 65534:65534 +EXPOSE 8443 + +ENTRYPOINT ["/usr/local/bin/supernode-mcp"] diff --git a/mcp-server/src/audit/event.rs b/mcp-server/src/audit/event.rs new file mode 100644 index 0000000..87c4828 --- /dev/null +++ b/mcp-server/src/audit/event.rs @@ -0,0 +1,63 @@ +use chrono::DateTime; +use chrono::Utc; +use serde::Serialize; + +use crate::auth::AuthContext; +use crate::auth::AuthMode; +use crate::policy::PolicyDecision; +use crate::policy::PolicyOutcome; +use crate::policy::Scope; + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AuditTarget { + None, + VaultRuntime { + path: String, + written_keys: Vec, + }, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuditEvent { + pub timestamp: DateTime, + pub auth_mode: AuthMode, + pub enforced: bool, + pub subject: String, + pub client_id: Option, + pub scopes: Vec, + pub required_scopes: Vec, + pub tool: String, + pub approval_id: Option, + pub target: AuditTarget, + pub decision: PolicyDecision, + pub reason: Option, + pub secret_values_included: bool, +} + +impl AuditEvent { + pub fn from_policy_outcome( + auth: &AuthContext, + tool: impl Into, + approval_id: Option, + target: AuditTarget, + outcome: &PolicyOutcome, + ) -> Self { + Self { + timestamp: Utc::now(), + auth_mode: auth.auth_mode, + enforced: auth.enforced, + subject: auth.subject.clone(), + client_id: auth.client_id.clone(), + scopes: auth.scopes.iter().cloned().collect(), + required_scopes: outcome.required_scopes.iter().cloned().collect(), + tool: tool.into(), + approval_id, + target, + decision: outcome.decision, + reason: outcome.reason.clone(), + secret_values_included: false, + } + } +} diff --git a/mcp-server/src/audit/mod.rs b/mcp-server/src/audit/mod.rs new file mode 100644 index 0000000..f34d70d --- /dev/null +++ b/mcp-server/src/audit/mod.rs @@ -0,0 +1,7 @@ +pub mod event; +pub mod sink; + +pub use event::AuditEvent; +pub use event::AuditTarget; +pub use sink::AuditSink; +pub use sink::TracingAuditSink; diff --git a/mcp-server/src/audit/sink.rs b/mcp-server/src/audit/sink.rs new file mode 100644 index 0000000..9536ae0 --- /dev/null +++ b/mcp-server/src/audit/sink.rs @@ -0,0 +1,16 @@ +use tracing::info; + +use crate::audit::AuditEvent; + +pub trait AuditSink: Send + Sync { + fn record(&self, event: &AuditEvent); +} + +#[derive(Debug, Default)] +pub struct TracingAuditSink; + +impl AuditSink for TracingAuditSink { + fn record(&self, event: &AuditEvent) { + info!(target: "supernode_mcp::audit", ?event, "audit event"); + } +} diff --git a/mcp-server/src/auth/mod.rs b/mcp-server/src/auth/mod.rs new file mode 100644 index 0000000..8203b9e --- /dev/null +++ b/mcp-server/src/auth/mod.rs @@ -0,0 +1,55 @@ +use std::collections::BTreeSet; +use std::str::FromStr; + +use serde::Serialize; + +use crate::errors::ConfigError; +use crate::policy::Role; +use crate::policy::Scope; + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthMode { + Trusted, + OAuth, +} + +impl FromStr for AuthMode { + type Err = ConfigError; + + fn from_str(value: &str) -> Result { + match value { + "trusted" => Ok(Self::Trusted), + "oauth" => Ok(Self::OAuth), + other => Err(ConfigError::InvalidAuthMode(other.to_string())), + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthContext { + pub auth_mode: AuthMode, + pub subject: String, + pub client_id: Option, + pub roles: BTreeSet, + pub scopes: BTreeSet, + pub issuer: String, + pub audience: Vec, + pub enforced: bool, +} + +impl AuthContext { + pub fn trusted() -> Self { + Self { + auth_mode: AuthMode::Trusted, + subject: "trusted-local-operator".to_string(), + client_id: Some("trusted-mcp-client".to_string()), + roles: BTreeSet::from([Role::Admin]), + scopes: Scope::all(), + issuer: "trusted".to_string(), + audience: vec!["metis-supernode-mcp".to_string()], + enforced: false, + } + } +} diff --git a/mcp-server/src/catalog/cardano_node_relay.rs b/mcp-server/src/catalog/cardano_node_relay.rs new file mode 100644 index 0000000..bc91068 --- /dev/null +++ b/mcp-server/src/catalog/cardano_node_relay.rs @@ -0,0 +1,459 @@ +use serde_json::json; + +use super::{ + ExtensionConfiguration, ExtensionDefinition, ExtensionMetrics, ExtensionOutputDefinition, +}; +use crate::catalog::schema::{nullable_boolean, nullable_number, nullable_string}; + +pub(super) fn definition() -> ExtensionDefinition { + ExtensionDefinition::new( + "cardano-node-relay", + "Cardano Node Relay", + "A Cardano relay node workload for participating in Cardano network topology without block-producing keys.", + vec!["0.1.0"], + "0.1.0", + configuration_schema(), + vec![], + vec![], + metrics_schema(), + outputs(), + "oci://oci.supernode.store/extensions/cardano-node".to_string(), + ) +} + +fn outputs() -> Vec { + vec![ + ExtensionOutputDefinition::new( + "n2n", + "Cardano node-to-node networking endpoint for relay peer connectivity.", + "n2n", + "TCP", + ), + ExtensionOutputDefinition::new( + "n2c", + "Cardano node-to-client endpoint for local clients through the chart proxy.", + "n2c", + "TCP", + ), + ] +} + +fn configuration_schema() -> ExtensionConfiguration { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Cardano Node Relay Configuration", + "type": "object", + "required": ["network", "namespace", "storageClass"], + "properties": { + "network": { + "type": "string", + "description": "Cardano network to join.", + "enum": ["mainnet", "preprod", "preview"], + "default": "preview" + }, + "namespace": { + "type": "string", + "description": "Kubernetes namespace where the relay workload will be installed.", + "minLength": 1 + }, + "storageClass": { + "type": "string", + "description": "StorageClass used for the relay chain-data PVC.", + "minLength": 1 + }, + "topology": { + "type": "object", + "description": "Optional topology override for relay peer selection. Omit to use the image-provided topology.", + "properties": { + "mode": { + "type": "string", + "enum": ["image-default", "relay-service", "custom"], + "default": "image-default" + }, + "relayTargets": { + "type": "array", + "description": "Internal relay services to use when mode is relay-service.", + "items": { + "type": "object", + "required": ["releaseName"], + "properties": { + "releaseName": { "type": "string", "minLength": 1 }, + "namespace": { "type": "string", "minLength": 1 }, + "chart": { "type": "string", "minLength": 1 }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535, "default": 3000 }, + "valency": { "type": "integer", "minimum": 1, "default": 1 } + }, + "additionalProperties": false + }, + "default": [] + }, + "localRoots": { + "type": "array", + "description": "Custom Cardano topology localRoots entries when mode is custom.", + "items": { "type": "object" }, + "default": [] + }, + "publicRoots": { + "type": "array", + "description": "Custom Cardano topology publicRoots entries when mode is custom.", + "items": { "type": "object" }, + "default": [] + }, + "useLedgerAfterSlot": { + "type": "integer", + "minimum": 0, + "default": 0 + } + }, + "additionalProperties": false, + "default": { "mode": "image-default" } + }, + "exposeLoadBalancer": { + "type": "boolean", + "description": "Expose the relay service as a Kubernetes LoadBalancer instead of ClusterIP.", + "default": false + }, + "imageTag": { + "type": "string", + "description": "Cardano node image tag.", + "default": "11.0.1" + }, + "resources": { + "type": "object", + "description": "Kubernetes resource requests and limits for the relay container. Defaults are placeholders and should be tuned per network.", + "properties": { + "requests": { "$ref": "#/$defs/resourceList" }, + "limits": { "$ref": "#/$defs/resourceList" } + }, + "additionalProperties": false + }, + "pvcSize": { + "type": "string", + "description": "Requested size for the relay chain-data PVC. Defaults are placeholders and should be tuned per network.", + "pattern": "^[0-9]+(Mi|Gi|Ti)$" + } + }, + "allOf": [ + { + "if": { "properties": { "network": { "const": "mainnet" } }, "required": ["network"] }, + "then": { + "properties": { + "pvcSize": { "default": "250Gi" }, + "resources": { + "default": { + "requests": { "cpu": "2", "memory": "8Gi" }, + "limits": { "cpu": "4", "memory": "16Gi" } + } + } + } + } + }, + { + "if": { "properties": { "network": { "const": "preprod" } }, "required": ["network"] }, + "then": { + "properties": { + "pvcSize": { "default": "50Gi" }, + "resources": { + "default": { + "requests": { "cpu": "1", "memory": "4Gi" }, + "limits": { "cpu": "2", "memory": "4Gi" } + } + } + } + } + }, + { + "if": { "properties": { "network": { "const": "preview" } }, "required": ["network"] }, + "then": { + "properties": { + "pvcSize": { "default": "50Gi" }, + "resources": { + "default": { + "requests": { "cpu": "500m", "memory": "4Gi" }, + "limits": { "cpu": "2", "memory": "4Gi" } + } + } + } + } + } + ], + "$defs": { + "resourceList": { + "type": "object", + "properties": { + "cpu": { "type": "string", "minLength": 1 }, + "memory": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }) +} + +fn metrics_schema() -> ExtensionMetrics { + let mut properties = serde_json::Map::new(); + properties.insert("type".to_string(), json!({ "const": "cardano-node" })); + properties.insert("role".to_string(), json!({ "const": "relay" })); + properties.insert( + "blockHeight".to_string(), + nullable_number("Latest block number observed by the node."), + ); + properties.insert( + "epoch".to_string(), + nullable_number("Current epoch observed by the node."), + ); + properties.insert( + "slotNum".to_string(), + nullable_number("Absolute slot number observed by the node across the chain timeline."), + ); + properties.insert( + "slotInEpoch".to_string(), + nullable_number("Current slot within the active epoch observed by the node."), + ); + properties.insert( + "epochProgressPercent".to_string(), + nullable_number("Percentage of the current epoch completed from slot-in-epoch and Shelley genesis epoch length."), + ); + properties.insert( + "epochTimeRemainingSeconds".to_string(), + nullable_number( + "Approximate time remaining in the current epoch derived from Shelley genesis timing.", + ), + ); + properties.insert( + "tipRefSlot".to_string(), + nullable_number( + "Reference chain tip computed from the Shelley genesis system start and slot length.", + ), + ); + properties.insert( + "tipDiffSlots".to_string(), + nullable_number("Difference between the computed reference tip and the node tip."), + ); + properties.insert( + "syncPercent".to_string(), + nullable_number("Estimated sync percentage against the computed reference tip."), + ); + properties.insert( + "density".to_string(), + nullable_number("Recent chain density reported by the node, expressed as a percentage."), + ); + properties.insert( + "forks".to_string(), + nullable_number("Number of chain forks the node has observed since startup."), + ); + properties.insert( + "txProcessed".to_string(), + nullable_number("Total transactions processed by the node since startup."), + ); + properties.insert( + "pendingTx".to_string(), + nullable_number("Transactions currently in the mempool."), + ); + properties.insert( + "pendingTxBytes".to_string(), + nullable_number("Buffered mempool transaction size when available."), + ); + properties.insert( + "nodeVersion".to_string(), + nullable_string("Cardano node build version reported by the metrics endpoint."), + ); + properties.insert( + "nodeRevision".to_string(), + nullable_string("Cardano node build revision reported by the metrics endpoint."), + ); + properties.insert( + "forgingEnabled".to_string(), + nullable_boolean("Whether this node currently has forging enabled."), + ); + properties.insert( + "peersIncoming".to_string(), + nullable_number("Active inbound node connections."), + ); + properties.insert( + "peersOutgoing".to_string(), + nullable_number("Active outbound node connections."), + ); + properties.insert( + "connectionUniDir".to_string(), + nullable_number("Current unidirectional connection count."), + ); + properties.insert( + "connectionBiDir".to_string(), + nullable_number("Current bidirectional connection count."), + ); + properties.insert( + "connectionDuplex".to_string(), + nullable_number("Current full duplex connection count."), + ); + properties.insert( + "inboundGovernorWarm".to_string(), + nullable_number("Inbound governor warm connection count reported by the node."), + ); + properties.insert( + "inboundGovernorHot".to_string(), + nullable_number("Inbound governor hot connection count reported by the node."), + ); + properties.insert( + "peerSelectionCold".to_string(), + nullable_number("Peer selection cold state count for outbound connections."), + ); + properties.insert( + "peerSelectionWarm".to_string(), + nullable_number("Peer selection warm state count for outbound connections."), + ); + properties.insert( + "peerSelectionHot".to_string(), + nullable_number("Peer selection hot state count for outbound connections."), + ); + properties.insert( + "lastBlockDelaySeconds".to_string(), + nullable_number("Latest observed block propagation delay."), + ); + properties.insert( + "blocksServed".to_string(), + nullable_number("Blocks served to peers by this node since startup."), + ); + properties.insert( + "blocksLate".to_string(), + nullable_number("Blocks observed later than five seconds by the block fetch client."), + ); + properties.insert( + "blocksWithin1s".to_string(), + nullable_number("Percentage of observed blocks arriving within 1 second."), + ); + properties.insert( + "blocksWithin3s".to_string(), + nullable_number("Percentage of observed blocks arriving within 3 seconds."), + ); + properties.insert( + "blocksWithin5s".to_string(), + nullable_number("Percentage of observed blocks arriving within 5 seconds."), + ); + properties.insert( + "memLiveBytes".to_string(), + nullable_number("Live RTS memory currently retained by the node process."), + ); + properties.insert( + "memHeapBytes".to_string(), + nullable_number("Heap memory currently reserved by the node RTS."), + ); + properties.insert( + "gcMinorCount".to_string(), + nullable_number("Number of minor garbage collections since startup."), + ); + properties.insert( + "gcMajorCount".to_string(), + nullable_number("Number of major garbage collections since startup."), + ); + properties.insert( + "epochLength".to_string(), + nullable_number("Number of slots in the current Cardano epoch from Shelley genesis."), + ); + properties.insert( + "slotLength".to_string(), + nullable_number("Slot duration in seconds from Shelley genesis."), + ); + properties.insert( + "systemStartUnix".to_string(), + nullable_number("Shelley system start timestamp as Unix seconds."), + ); + properties.insert( + "kesPeriod".to_string(), + nullable_number("Current KES period reported by the node, when available."), + ); + properties.insert( + "kesRemaining".to_string(), + nullable_number("Remaining KES periods before key expiry, when available."), + ); + properties.insert( + "kesExpirationSeconds".to_string(), + nullable_number("Approximate seconds until KES key expiry, when available."), + ); + properties.insert( + "kesExpirationTime".to_string(), + nullable_string("Estimated KES key expiry time as an ISO-8601 timestamp, when available."), + ); + properties.insert( + "opCertOnDisk".to_string(), + nullable_number("Operational certificate counter found on disk, when available."), + ); + properties.insert( + "opCertOnChain".to_string(), + nullable_number("Operational certificate counter observed on chain, when available."), + ); + properties.insert( + "leaderCount".to_string(), + nullable_number( + "Slots where the node was leader since startup, for block-producing nodes.", + ), + ); + properties.insert( + "adoptedCount".to_string(), + nullable_number( + "Forged blocks adopted by the chain since startup, for block-producing nodes.", + ), + ); + properties.insert( + "forgedCount".to_string(), + nullable_number("Blocks forged by the node since startup, for block-producing nodes."), + ); + properties.insert( + "aboutToLeadCount".to_string(), + nullable_number( + "Times the node was about to lead a slot since startup, for block-producing nodes.", + ), + ); + properties.insert( + "invalidCount".to_string(), + nullable_number("Derived count of forged blocks that were not adopted, clamped at zero."), + ); + properties.insert( + "missedSlots".to_string(), + nullable_number("Slots missed by the node since startup, when available."), + ); + properties.insert( + "scheduledLeaderCount".to_string(), + nullable_number( + "Leadership slots scheduled for the current epoch, for block-producing nodes.", + ), + ); + properties.insert( + "scheduledIdealCount".to_string(), + nullable_number("Expected leadership slots for the current epoch based on active stake."), + ); + properties.insert( + "scheduledLuckPercent".to_string(), + nullable_number("Scheduled leader slots as a percentage of ideal expected slots."), + ); + properties.insert( + "nextLeaderSlot".to_string(), + nullable_number("Next scheduled leadership slot number, for block-producing nodes."), + ); + properties.insert( + "nextLeaderTime".to_string(), + nullable_string("Next scheduled leadership slot time as an ISO-8601 timestamp."), + ); + properties.insert( + "nextLeaderTimeRemainingSeconds".to_string(), + nullable_number("Approximate seconds until the next scheduled leadership slot."), + ); + properties.insert( + "errors".to_string(), + json!({ + "type": "array", + "description": "Warnings or collection errors emitted by the metrics script.", + "items": { "type": "string" } + }), + ); + + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Cardano Node Relay Metrics", + "type": "object", + "required": ["type", "role", "errors"], + "properties": properties, + "additionalProperties": false + }) +} diff --git a/mcp-server/src/catalog/dolos.rs b/mcp-server/src/catalog/dolos.rs new file mode 100644 index 0000000..db03d4a --- /dev/null +++ b/mcp-server/src/catalog/dolos.rs @@ -0,0 +1,129 @@ +use serde_json::json; + +use super::{ + ExtensionConfiguration, ExtensionDefinition, ExtensionMetrics, ExtensionOutputDefinition, +}; +use crate::catalog::schema::nullable_number; + +pub(super) fn definition() -> ExtensionDefinition { + ExtensionDefinition::new( + "dolos", + "Dolos", + "A Dolos chain data service workload for serving Cardano chain data APIs from the supernode cluster.", + vec!["0.1.0"], + "0.1.0", + configuration_schema(), + vec![], + vec![], + metrics_schema(), + outputs(), + "oci://oci.supernode.store/extensions/dolos".to_string(), + ) +} + +fn outputs() -> Vec { + vec![ + ExtensionOutputDefinition::new("trp", "Dolos TRP HTTP endpoint.", "trp", "HTTP"), + ExtensionOutputDefinition::new( + "blockfrost", + "Blockfrost-compatible minibf HTTP endpoint.", + "minibf", + "HTTP", + ), + ExtensionOutputDefinition::new( + "kupo", + "Kupo-compatible minikupo HTTP endpoint.", + "minikupo", + "HTTP", + ), + ExtensionOutputDefinition::new("utxorpc", "UTxO RPC gRPC endpoint.", "grpc", "gRPC"), + ] +} + +fn configuration_schema() -> ExtensionConfiguration { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dolos Configuration", + "type": "object", + "required": ["network", "namespace", "storageClass"], + "properties": { + "network": { + "type": "string", + "description": "Cardano network Dolos should index.", + "enum": ["cardano-mainnet", "cardano-preprod", "cardano-preview"], + "default": "cardano-preview" + }, + "namespace": { + "type": "string", + "description": "Kubernetes namespace where the Dolos workload will be installed.", + "minLength": 1 + }, + "storageClass": { + "type": "string", + "description": "StorageClass used for the Dolos data PVC.", + "minLength": 1 + }, + "upstreamAddress": { + "type": "string", + "description": "Trusted Cardano relay address for Dolos sync. If omitted, MCP will try to discover a same-network relay already installed in the cluster.", + "minLength": 1 + }, + "exposeLoadBalancer": { + "type": "boolean", + "description": "Expose the Dolos service as a Kubernetes LoadBalancer instead of ClusterIP.", + "default": false + }, + "imageTag": { + "type": "string", + "description": "Dolos image tag.", + "default": "v1.1.1" + }, + "resources": { + "type": "object", + "description": "Kubernetes resource requests and limits for the Dolos container.", + "properties": { + "requests": { "$ref": "#/$defs/resourceList" }, + "limits": { "$ref": "#/$defs/resourceList" } + }, + "additionalProperties": false + }, + "pvcSize": { + "type": "string", + "description": "Requested size for the Dolos data PVC.", + "pattern": "^[0-9]+(Mi|Gi|Ti)$" + } + }, + "$defs": { + "resourceList": { + "type": "object", + "properties": { + "cpu": { "type": "string", "minLength": 1 }, + "memory": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }) +} + +fn metrics_schema() -> ExtensionMetrics { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dolos Metrics", + "type": "object", + "required": ["type", "errors"], + "properties": { + "type": { "const": "dolos" }, + "blockHeight": nullable_number("Latest block height served by Dolos."), + "epoch": nullable_number("Current epoch served by Dolos."), + "slotNum": nullable_number("Latest block slot served by Dolos."), + "errors": { + "type": "array", + "description": "Warnings or collection errors emitted by the metrics script.", + "items": { "type": "string" } + } + }, + "additionalProperties": false + }) +} diff --git a/mcp-server/src/catalog/extension.rs b/mcp-server/src/catalog/extension.rs new file mode 100644 index 0000000..3180d3f --- /dev/null +++ b/mcp-server/src/catalog/extension.rs @@ -0,0 +1,111 @@ +use serde::Serialize; +use serde_json::Value; + +pub type ExtensionId = String; +pub type ExtensionConfiguration = Value; +pub type ExtensionMetrics = Value; + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionSecretDefinition { + pub name: String, + pub description: String, + pub required: bool, + pub required_when: Option, + pub scope: String, + pub material: String, + pub write_only: bool, + pub accepted_sources: Vec, +} + +impl ExtensionSecretDefinition { + #[allow(clippy::too_many_arguments)] + pub fn new( + name: &str, + description: &str, + required: bool, + required_when: Option<&str>, + scope: &str, + material: &str, + write_only: bool, + accepted_sources: Vec<&str>, + ) -> Self { + Self { + name: name.to_string(), + description: description.to_string(), + required, + required_when: required_when.map(str::to_string), + scope: scope.to_string(), + material: material.to_string(), + write_only, + accepted_sources: accepted_sources.into_iter().map(str::to_string).collect(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionOutputDefinition { + pub name: String, + pub description: String, + pub port_name: String, + pub protocol: String, +} + +impl ExtensionOutputDefinition { + pub fn new(name: &str, description: &str, port_name: &str, protocol: &str) -> Self { + Self { + name: name.to_string(), + description: description.to_string(), + port_name: port_name.to_string(), + protocol: protocol.to_string(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionDefinition { + pub id: ExtensionId, + pub name: String, + pub description: String, + pub versions: Vec, + pub default_version: String, + pub configuration: ExtensionConfiguration, + pub secrets: Vec, + pub dependencies: Vec, + pub metrics: ExtensionMetrics, + pub outputs: Vec, + pub chart: String, +} + +impl ExtensionDefinition { + #[allow(clippy::too_many_arguments)] + pub fn new( + id: &str, + name: &str, + description: &str, + versions: Vec<&str>, + default_version: &str, + configuration: ExtensionConfiguration, + secrets: Vec, + dependencies: Vec<&str>, + metrics: ExtensionMetrics, + outputs: Vec, + chart: String, + ) -> Self { + Self { + id: id.to_string(), + name: name.to_string(), + description: description.to_string(), + versions: versions.into_iter().map(str::to_string).collect(), + default_version: default_version.to_string(), + configuration, + secrets, + dependencies: dependencies.into_iter().map(str::to_string).collect(), + metrics, + outputs, + chart, + } + } +} diff --git a/mcp-server/src/catalog/hydra_node.rs b/mcp-server/src/catalog/hydra_node.rs new file mode 100644 index 0000000..8267e1d --- /dev/null +++ b/mcp-server/src/catalog/hydra_node.rs @@ -0,0 +1,301 @@ +use serde_json::json; + +use super::{ + ExtensionConfiguration, ExtensionDefinition, ExtensionMetrics, ExtensionOutputDefinition, + ExtensionSecretDefinition, +}; +use crate::catalog::schema::{nullable_number, nullable_string}; + +pub(super) fn definition() -> ExtensionDefinition { + ExtensionDefinition::new( + "hydra-node", + "Hydra Node", + "A Hydra Head protocol node for operating Cardano L2 state channels with low-latency off-chain transactions and L1 settlement.", + vec!["0.2.0"], + "0.2.0", + configuration_schema(), + secrets(), + vec![], + metrics_schema(), + outputs(), + "oci://oci.supernode.store/extensions/hydra-node".to_string(), + ) +} + +fn secrets() -> Vec { + vec![ + ExtensionSecretDefinition::new( + "hydraSigningKey", + "Hydra Ed25519 signing key used by this node to sign snapshots. Values must be supplied through runtime Vault sync and are never echoed by MCP.", + true, + None, + "runtime", + "hydra-signing-key", + true, + vec!["vaultStaticSecret"], + ), + ExtensionSecretDefinition::new( + "cardanoSigningKey", + "Cardano signing key used by online Hydra nodes to pay L1 fuel and drive head lifecycle transactions.", + false, + Some("mode == online"), + "runtime", + "cardano-signing-key", + true, + vec!["vaultStaticSecret"], + ), + ExtensionSecretDefinition::new( + "blockfrostProjectId", + "Optional Blockfrost project identifier if a future chart profile uses Blockfrost instead of a Cardano node socket.", + false, + Some("cardanoBackend.mode == blockfrost"), + "runtime", + "blockfrost-project-id", + true, + vec!["vaultStaticSecret"], + ), + ExtensionSecretDefinition::new( + "tlsKey", + "Optional Hydra API TLS private key when TLS is enabled for the unauthenticated Hydra API endpoint.", + false, + Some("api.tls == true"), + "runtime", + "tls-private-key", + true, + vec!["vaultStaticSecret"], + ), + ] +} + +fn outputs() -> Vec { + vec![ + ExtensionOutputDefinition::new("api", "Hydra HTTP API endpoint.", "api", "HTTP"), + ExtensionOutputDefinition::new( + "ws", + "Hydra WebSocket client-input and server-output endpoint.", + "api", + "WebSocket", + ), + ExtensionOutputDefinition::new( + "p2p", + "Hydra node-to-node peer networking endpoint.", + "p2p", + "TCP", + ), + ExtensionOutputDefinition::new( + "monitoring", + "Hydra Prometheus metrics endpoint.", + "monitoring", + "HTTP", + ), + ] +} + +fn configuration_schema() -> ExtensionConfiguration { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Hydra Node Configuration", + "type": "object", + "required": ["namespace", "storageClass", "mode", "hydraSigningKey", "hydraVerificationKeys"], + "properties": { + "namespace": { + "type": "string", + "description": "Kubernetes namespace where the Hydra node workload will be installed.", + "minLength": 1 + }, + "storageClass": { + "type": "string", + "description": "StorageClass used for the Hydra persistence PVC.", + "minLength": 1 + }, + "mode": { + "type": "string", + "description": "Run without L1 connectivity for local experiments, or online against Cardano.", + "enum": ["offline", "online"], + "default": "offline" + }, + "network": { + "type": "string", + "description": "Cardano network for online mode.", + "enum": ["mainnet", "preprod", "preview"] + }, + "nodeId": { + "type": "string", + "description": "Unique Hydra node identifier. Mirror nodes must use unique node IDs.", + "default": "hydra-node-1", + "minLength": 1 + }, + "imageTag": { + "type": "string", + "description": "Hydra node image tag.", + "default": "2.1.0" + }, + "pvcSize": { + "type": "string", + "description": "Requested size for Hydra persistence.", + "default": "5Gi", + "pattern": "^[0-9]+(Mi|Gi|Ti)$" + }, + "exposeLoadBalancer": { + "type": "boolean", + "description": "Expose the Hydra API, P2P, and monitoring service as a LoadBalancer instead of ClusterIP.", + "default": false + }, + "contestationPeriod": { + "type": "string", + "description": "Hydra contestation period, for example 43200s. Mainnet should use at least 12 hours.", + "default": "43200s" + }, + "depositPeriod": { + "type": "string", + "description": "Optional deposit period used for incremental commits, for example 7200s." + }, + "unsyncedPeriod": { + "type": "integer", + "description": "Optional seconds after which the node considers itself out of sync with L1.", + "minimum": 1 + }, + "peers": { + "type": "array", + "description": "Static Hydra peer endpoints. All participants must agree on topology.", + "items": { + "type": "object", + "required": ["host", "port"], + "properties": { + "host": { "type": "string", "minLength": 1 }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 } + }, + "additionalProperties": false + }, + "default": [] + }, + "offline": { + "type": "object", + "description": "Offline Hydra head parameters used for local or CI experiments without Cardano L1.", + "properties": { + "headSeed": { + "type": "string", + "description": "Hexadecimal offline head seed shared by participants.", + "pattern": "^[0-9a-fA-F]+$", + "default": "0001" + }, + "initialUtxo": { "type": "object", "description": "Initial Hydra UTxO JSON." }, + "protocolParameters": { "type": "object", "description": "Hydra ledger protocol parameters JSON." }, + "ledgerGenesis": { "type": "object", "description": "Optional Shelley genesis JSON for offline time semantics." } + }, + "additionalProperties": false, + "default": { "headSeed": "0001" } + }, + "cardanoBackend": { + "type": "object", + "description": "Online Cardano backend configuration.", + "properties": { + "mode": { + "type": "string", + "enum": ["autoRelay", "socketProxy", "mountedSocket", "blockfrost"] + }, + "upstreamAddress": { "type": "string", "minLength": 1 }, + "socketPath": { "type": "string", "default": "/ipc/node.socket" }, + "blockfrostProjectId": { "$ref": "#/$defs/secretRef" }, + "startChainFrom": { "type": "string" }, + "hydraScriptsTxId": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "hydraSigningKey": { "$ref": "#/$defs/secretRef" }, + "hydraVerificationKeys": { + "type": "array", + "description": "Hydra verification key files for all parties, including this node.", + "minItems": 1, + "items": { + "type": "object", + "required": ["filename"], + "properties": { + "filename": { "type": "string", "minLength": 1 }, + "value": { "type": "string", "description": "Public verification key payload. This is not secret material." } + }, + "additionalProperties": false + } + }, + "cardanoSigningKey": { "$ref": "#/$defs/secretRef" }, + "cardanoVerificationKey": { + "type": "object", + "description": "Cardano verification key for this online Hydra participant.", + "properties": { + "filename": { "type": "string", "default": "cardano.vk" }, + "value": { "type": "string", "description": "Public verification key payload. This is not secret material." }, + "existingConfigMap": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "resources": { + "type": "object", + "description": "Kubernetes resource requests and limits for the Hydra node container.", + "properties": { + "requests": { "$ref": "#/$defs/resourceList" }, + "limits": { "$ref": "#/$defs/resourceList" } + }, + "additionalProperties": false + } + }, + "$defs": { + "secretRef": { + "type": "object", + "description": "Reference to pre-staged runtime secret material. Secret values are not accepted in catalog-driven lifecycle inputs.", + "required": ["source"], + "properties": { + "source": { "type": "string", "enum": ["vaultStaticSecret"] }, + "vaultPath": { "type": "string", "pattern": "^runtime/", "description": "Runtime Vault path without kv/data prefix." }, + "key": { "type": "string", "description": "Secret key expected in the Vault record and synced Kubernetes Secret.", "minLength": 1 } + }, + "additionalProperties": false + }, + "resourceList": { + "type": "object", + "properties": { + "cpu": { "type": "string", "minLength": 1 }, + "memory": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }) +} + +fn metrics_schema() -> ExtensionMetrics { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Hydra Node Metrics", + "type": "object", + "required": ["type", "errors"], + "properties": { + "type": { "const": "hydra-node" }, + "mode": nullable_string("Hydra node mode as configured by the chart: offline or online."), + "headStatus": nullable_string("Latest head state tag reported by the Hydra HTTP API."), + "headId": nullable_string("Current Hydra head identifier when a head is known."), + "hydraNodeVersion": nullable_string("Hydra node version reported by the HTTP API."), + "currentSlot": nullable_number("Current chain slot reported by the Hydra API when available."), + "chainSyncedStatus": nullable_string("Hydra chain sync status when connected to L1."), + "peersConnected": nullable_number("Connected Hydra peers from the Prometheus metrics endpoint."), + "pendingDeposits": nullable_number("Number of pending deposit transaction IDs returned by /commits."), + "snapshotNumber": nullable_number("Latest confirmed snapshot number when available."), + "snapshotVersion": nullable_number("Latest confirmed snapshot version when available."), + "confirmedUtxoCount": nullable_number("Number of entries in the latest confirmed snapshot UTxO."), + "confirmedLovelace": nullable_number("Sum of lovelace in the latest confirmed snapshot UTxO."), + "lastSeenSnapshotTag": nullable_string("Tag returned by /snapshot/last-seen for diagnosing in-flight snapshot consensus."), + "requestedTx": nullable_number("Total requested L2 transactions from hydra_head_requested_tx."), + "confirmedTx": nullable_number("Total confirmed L2 transactions from hydra_head_confirmed_tx."), + "inputs": nullable_number("Total processed head inputs from hydra_head_inputs."), + "txConfirmationTimeMsCount": nullable_number("Confirmation-time histogram sample count."), + "txConfirmationTimeMsSum": nullable_number("Confirmation-time histogram sample sum in milliseconds."), + "txConfirmationTimeMsAvg": nullable_number("Derived average confirmation time in milliseconds."), + "errors": { + "type": "array", + "description": "Warnings or collection errors emitted by the metrics script.", + "items": { "type": "string" } + } + }, + "additionalProperties": false + }) +} diff --git a/mcp-server/src/catalog/mod.rs b/mcp-server/src/catalog/mod.rs new file mode 100644 index 0000000..019945f --- /dev/null +++ b/mcp-server/src/catalog/mod.rs @@ -0,0 +1,250 @@ +mod cardano_node_relay; +mod dolos; +pub mod extension; +mod hydra_node; +mod schema; + +use std::collections::BTreeMap; + +use serde::Serialize; + +pub use extension::ExtensionConfiguration; +pub use extension::ExtensionDefinition; +pub use extension::ExtensionId; +pub use extension::ExtensionMetrics; +pub use extension::ExtensionOutputDefinition; +pub use extension::ExtensionSecretDefinition; + +#[derive(Debug, Clone, Serialize)] +pub struct ExtensionCatalog { + extensions: BTreeMap, +} + +impl ExtensionCatalog { + pub fn embedded() -> Self { + Self::from_extensions([ + cardano_node_relay::definition(), + dolos::definition(), + hydra_node::definition(), + ]) + } + + pub fn from_extensions(extensions: impl IntoIterator) -> Self { + let extensions = extensions + .into_iter() + .map(|extension| (extension.id.clone(), extension)) + .collect(); + Self { extensions } + } + + pub fn list(&self) -> impl Iterator { + self.extensions.values() + } + + pub fn get(&self, extension_id: &str) -> Option<&ExtensionDefinition> { + self.extensions.get(extension_id) + } + + pub fn len(&self) -> usize { + self.extensions.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{Value, json}; + + #[test] + fn embedded_catalog_contains_cardano_node_relay_dolos_and_hydra() { + let catalog = ExtensionCatalog::embedded(); + + assert_eq!(catalog.len(), 3); + assert!(catalog.get("cardano-node-relay").is_some()); + assert!(catalog.get("cardano-node").is_none()); + assert!(catalog.get("dolos").is_some()); + assert!(catalog.get("hydra-node").is_some()); + } + + #[test] + fn relay_extension_exposes_domain_contract() { + let catalog = ExtensionCatalog::embedded(); + let extension = catalog.get("cardano-node-relay").unwrap(); + + assert_eq!(extension.name, "Cardano Node Relay"); + assert_eq!(extension.default_version, "0.1.0"); + assert!(extension.versions.contains(&"0.1.0".to_string())); + assert_eq!(extension.configuration.get("type"), Some(&json!("object"))); + assert_eq!(extension.metrics.get("type"), Some(&json!("object"))); + assert_eq!(extension.outputs.len(), 2); + assert!(extension.secrets.is_empty()); + assert!(extension.dependencies.is_empty()); + } + + #[test] + fn relay_configuration_does_not_expose_power_user_config_override() { + let catalog = ExtensionCatalog::embedded(); + let properties = catalog + .get("cardano-node-relay") + .unwrap() + .configuration + .get("properties") + .and_then(Value::as_object) + .unwrap(); + + assert!(properties.contains_key("topology")); + assert!(properties.contains_key("exposeLoadBalancer")); + assert!(properties.contains_key("imageTag")); + assert!(properties.contains_key("resources")); + assert!(properties.contains_key("pvcSize")); + assert!(!properties.contains_key("config")); + } + + #[test] + fn relay_metrics_schema_describes_script_output_fields() { + let catalog = ExtensionCatalog::embedded(); + let metrics = &catalog.get("cardano-node-relay").unwrap().metrics; + let properties = metrics + .get("properties") + .and_then(Value::as_object) + .unwrap(); + let required = metrics.get("required").and_then(Value::as_array).unwrap(); + + assert!(required.contains(&json!("role"))); + assert!(properties.contains_key("role")); + assert!(properties.contains_key("epochLength")); + assert!(properties.contains_key("kesExpirationTime")); + assert!(properties.contains_key("scheduledLeaderCount")); + assert!(properties.contains_key("nextLeaderTimeRemainingSeconds")); + } + + #[test] + fn dolos_extension_exposes_domain_contract() { + let catalog = ExtensionCatalog::embedded(); + let extension = catalog.get("dolos").unwrap(); + + assert_eq!(extension.name, "Dolos"); + assert_eq!(extension.default_version, "0.1.0"); + assert!(extension.versions.contains(&"0.1.0".to_string())); + assert_eq!(extension.configuration.get("type"), Some(&json!("object"))); + assert_eq!(extension.metrics.get("type"), Some(&json!("object"))); + assert_eq!(extension.outputs.len(), 4); + assert!(extension.secrets.is_empty()); + assert!(extension.dependencies.is_empty()); + } + + #[test] + fn dolos_configuration_only_exposes_safe_cardano_fields() { + let catalog = ExtensionCatalog::embedded(); + let properties = catalog + .get("dolos") + .unwrap() + .configuration + .get("properties") + .and_then(Value::as_object) + .unwrap(); + + assert!(properties.contains_key("network")); + assert!(properties.contains_key("storageClass")); + assert!(properties.contains_key("upstreamAddress")); + assert!(properties.contains_key("imageTag")); + assert!(properties.contains_key("resources")); + assert!(properties.contains_key("pvcSize")); + assert!(!properties.contains_key("bootstrapEnabled")); + assert!(!properties.contains_key("config")); + assert!(!properties.contains_key("rawValues")); + } + + #[test] + fn dolos_metrics_schema_describes_basic_minibf_fields() { + let catalog = ExtensionCatalog::embedded(); + let metrics = &catalog.get("dolos").unwrap().metrics; + let properties = metrics + .get("properties") + .and_then(Value::as_object) + .unwrap(); + let required = metrics.get("required").and_then(Value::as_array).unwrap(); + + assert!(required.contains(&json!("type"))); + assert!(required.contains(&json!("errors"))); + assert!(properties.contains_key("blockHeight")); + assert!(properties.contains_key("epoch")); + assert!(properties.contains_key("slotNum")); + } + + #[test] + fn hydra_extension_exposes_domain_contract() { + let catalog = ExtensionCatalog::embedded(); + let extension = catalog.get("hydra-node").unwrap(); + + assert_eq!(extension.name, "Hydra Node"); + assert_eq!(extension.default_version, "0.2.0"); + assert!(extension.versions.contains(&"0.2.0".to_string())); + assert_eq!(extension.configuration.get("type"), Some(&json!("object"))); + assert_eq!(extension.metrics.get("type"), Some(&json!("object"))); + assert_eq!(extension.outputs.len(), 4); + assert_eq!(extension.secrets.len(), 4); + assert!(extension.dependencies.is_empty()); + } + + #[test] + fn hydra_extension_describes_runtime_secret_metadata() { + let catalog = ExtensionCatalog::embedded(); + let extension = catalog.get("hydra-node").unwrap(); + + let hydra_signing = extension + .secrets + .iter() + .find(|secret| secret.name == "hydraSigningKey") + .unwrap(); + + assert!(hydra_signing.required); + assert_eq!(hydra_signing.scope, "runtime"); + assert!(hydra_signing.write_only); + assert!( + hydra_signing + .accepted_sources + .contains(&"vaultStaticSecret".to_string()) + ); + assert_eq!(hydra_signing.accepted_sources.len(), 1); + } + + #[test] + fn hydra_metrics_schema_describes_api_and_prometheus_fields() { + let catalog = ExtensionCatalog::embedded(); + let metrics = &catalog.get("hydra-node").unwrap().metrics; + let properties = metrics + .get("properties") + .and_then(Value::as_object) + .unwrap(); + + assert!(properties.contains_key("headStatus")); + assert!(properties.contains_key("snapshotNumber")); + assert!(properties.contains_key("peersConnected")); + assert!(properties.contains_key("txConfirmationTimeMsAvg")); + } + + #[test] + fn extension_outputs_describe_exposed_endpoints_for_llms() { + let catalog = ExtensionCatalog::embedded(); + let relay = catalog.get("cardano-node-relay").unwrap(); + let dolos = catalog.get("dolos").unwrap(); + let hydra = catalog.get("hydra-node").unwrap(); + + assert_eq!(relay.outputs[0].name, "n2n"); + assert_eq!(relay.outputs[1].name, "n2c"); + assert!(relay.outputs[0].description.contains("node-to-node")); + + assert_eq!(dolos.outputs[0].name, "trp"); + assert_eq!(dolos.outputs[1].name, "blockfrost"); + assert_eq!(dolos.outputs[2].name, "kupo"); + assert_eq!(dolos.outputs[3].name, "utxorpc"); + assert_eq!(dolos.outputs[3].protocol, "gRPC"); + + assert_eq!(hydra.outputs[0].name, "api"); + assert_eq!(hydra.outputs[1].name, "ws"); + assert_eq!(hydra.outputs[1].protocol, "WebSocket"); + assert_eq!(hydra.outputs[2].name, "p2p"); + assert_eq!(hydra.outputs[3].name, "monitoring"); + } +} diff --git a/mcp-server/src/catalog/schema.rs b/mcp-server/src/catalog/schema.rs new file mode 100644 index 0000000..9931e07 --- /dev/null +++ b/mcp-server/src/catalog/schema.rs @@ -0,0 +1,14 @@ +use serde_json::Value; +use serde_json::json; + +pub(super) fn nullable_number(description: &str) -> Value { + json!({ "type": ["number", "null"], "description": description }) +} + +pub(super) fn nullable_string(description: &str) -> Value { + json!({ "type": ["string", "null"], "description": description }) +} + +pub(super) fn nullable_boolean(description: &str) -> Value { + json!({ "type": ["boolean", "null"], "description": description }) +} diff --git a/mcp-server/src/config.rs b/mcp-server/src/config.rs new file mode 100644 index 0000000..49fa466 --- /dev/null +++ b/mcp-server/src/config.rs @@ -0,0 +1,98 @@ +use std::env; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::str::FromStr; + +use crate::auth::AuthMode; +use crate::errors::ConfigError; + +#[derive(Debug, Clone)] +pub struct Config { + pub bind_addr: SocketAddr, + pub auth_mode: AuthMode, + pub log_level: String, + pub session_store: SessionStoreConfig, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum SessionStoreConfig { + Memory, + Sqlite { + path: PathBuf, + ttl_seconds: Option, + }, +} + +impl FromStr for SessionStoreConfig { + type Err = ConfigError; + + fn from_str(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "" | "memory" => Ok(Self::Memory), + "sqlite" => Ok(Self::Sqlite { + path: session_sqlite_path(), + ttl_seconds: session_ttl_seconds()?, + }), + other => Err(ConfigError::InvalidSessionStore(other.to_string())), + } + } +} + +impl Config { + pub fn from_env() -> Result { + let bind_addr = env::var("MCP_BIND_ADDR") + .unwrap_or_else(|_| "0.0.0.0:8443".to_string()) + .parse() + .map_err(ConfigError::InvalidBindAddr)?; + + let auth_mode = env::var("MCP_AUTH_MODE") + .unwrap_or_else(|_| "trusted".to_string()) + .parse()?; + + let log_level = env::var("MCP_LOG_LEVEL").unwrap_or_else(|_| "info".to_string()); + let session_store = env::var("MCP_SESSION_STORE") + .unwrap_or_else(|_| "memory".to_string()) + .parse()?; + + Ok(Self { + bind_addr, + auth_mode, + log_level, + session_store, + }) + } +} + +fn session_sqlite_path() -> PathBuf { + env::var("MCP_SESSION_SQLITE_PATH") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/var/lib/supernode-mcp/sessions.sqlite3")) +} + +fn session_ttl_seconds() -> Result, ConfigError> { + env::var("MCP_SESSION_TTL_SECONDS") + .ok() + .filter(|value| !value.trim().is_empty()) + .map(|value| value.parse().map_err(ConfigError::InvalidSessionTtl)) + .transpose() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_memory_session_store() { + assert_eq!( + "memory".parse::().unwrap(), + SessionStoreConfig::Memory + ); + } + + #[test] + fn rejects_unknown_session_store() { + let error = "redis".parse::().unwrap_err(); + + assert!(matches!(error, ConfigError::InvalidSessionStore(_))); + } +} diff --git a/mcp-server/src/errors.rs b/mcp-server/src/errors.rs new file mode 100644 index 0000000..57f6dad --- /dev/null +++ b/mcp-server/src/errors.rs @@ -0,0 +1,15 @@ +use std::net::AddrParseError; +use std::num::ParseIntError; + +#[derive(Debug, thiserror::Error)] +#[allow(clippy::enum_variant_names)] +pub enum ConfigError { + #[error("invalid MCP_BIND_ADDR: {0}")] + InvalidBindAddr(AddrParseError), + #[error("invalid MCP_AUTH_MODE '{0}', expected 'trusted' or 'oauth'")] + InvalidAuthMode(String), + #[error("invalid MCP_SESSION_STORE '{0}', expected 'memory' or 'sqlite'")] + InvalidSessionStore(String), + #[error("invalid MCP_SESSION_TTL_SECONDS: {0}")] + InvalidSessionTtl(ParseIntError), +} diff --git a/mcp-server/src/helm.rs b/mcp-server/src/helm.rs new file mode 100644 index 0000000..a96a2a8 --- /dev/null +++ b/mcp-server/src/helm.rs @@ -0,0 +1,360 @@ +use std::path::Path; +use std::path::PathBuf; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use serde::Serialize; +use serde_json::Value; +use tokio::process::Command; + +const DEFAULT_HELM_BIN: &str = "helm"; + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HelmChartRef { + pub chart: String, + pub version: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct HelmInstallPlan { + pub release_name: String, + pub namespace: String, + pub chart: HelmChartRef, + pub values: Value, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct HelmUpgradePlan { + pub release_name: String, + pub namespace: String, + pub chart: HelmChartRef, + pub values: Value, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct HelmUninstallPlan { + pub release_name: String, + pub namespace: String, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HelmInstallResult { + pub command: Vec, + pub stdout: String, + pub stderr: String, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HelmUpgradeResult { + pub command: Vec, + pub stdout: String, + pub stderr: String, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HelmUninstallResult { + pub command: Vec, + pub stdout: String, + pub stderr: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum HelmInstallError { + #[error("failed to create Helm values directory: {0}")] + CreateValuesDir(std::io::Error), + #[error("failed to serialize Helm values: {0}")] + SerializeValues(serde_json::Error), + #[error("failed to write Helm values file: {0}")] + WriteValues(std::io::Error), + #[error("failed to execute Helm: {0}")] + Execute(std::io::Error), + #[error("Helm install failed with status {status}")] + Failed { + status: i32, + stdout: String, + stderr: String, + }, +} + +#[derive(Debug, thiserror::Error)] +pub enum HelmUpgradeError { + #[error("failed to create Helm values directory: {0}")] + CreateValuesDir(std::io::Error), + #[error("failed to serialize Helm values: {0}")] + SerializeValues(serde_json::Error), + #[error("failed to write Helm values file: {0}")] + WriteValues(std::io::Error), + #[error("failed to execute Helm: {0}")] + Execute(std::io::Error), + #[error("Helm upgrade failed with status {status}")] + Failed { + status: i32, + stdout: String, + stderr: String, + }, +} + +#[derive(Debug, thiserror::Error)] +pub enum HelmUninstallError { + #[error("failed to execute Helm: {0}")] + Execute(std::io::Error), + #[error("Helm uninstall failed with status {status}")] + Failed { + status: i32, + stdout: String, + stderr: String, + }, +} + +pub async fn install(plan: &HelmInstallPlan) -> Result { + let values_path = write_values_file(&plan.values).map_err(HelmInstallError::from)?; + let helm_bin = std::env::var("MCP_HELM_BIN").unwrap_or_else(|_| DEFAULT_HELM_BIN.to_string()); + let args = install_args(plan, &values_path); + + let output = Command::new(&helm_bin).args(&args).output().await; + let _ = std::fs::remove_file(&values_path); + let output = output.map_err(HelmInstallError::Execute)?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if !output.status.success() { + return Err(HelmInstallError::Failed { + status: output.status.code().unwrap_or(-1), + stdout, + stderr, + }); + } + + let mut command = vec![helm_bin]; + command.extend(args); + + Ok(HelmInstallResult { + command, + stdout, + stderr, + }) +} + +pub async fn upgrade(plan: &HelmUpgradePlan) -> Result { + let values_path = write_values_file(&plan.values).map_err(HelmUpgradeError::from)?; + let helm_bin = std::env::var("MCP_HELM_BIN").unwrap_or_else(|_| DEFAULT_HELM_BIN.to_string()); + let args = upgrade_args(plan, &values_path); + + let output = Command::new(&helm_bin).args(&args).output().await; + let _ = std::fs::remove_file(&values_path); + let output = output.map_err(HelmUpgradeError::Execute)?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if !output.status.success() { + return Err(HelmUpgradeError::Failed { + status: output.status.code().unwrap_or(-1), + stdout, + stderr, + }); + } + + let mut command = vec![helm_bin]; + command.extend(args); + + Ok(HelmUpgradeResult { + command, + stdout, + stderr, + }) +} + +pub async fn uninstall( + plan: &HelmUninstallPlan, +) -> Result { + let helm_bin = std::env::var("MCP_HELM_BIN").unwrap_or_else(|_| DEFAULT_HELM_BIN.to_string()); + let args = uninstall_args(plan); + let output = Command::new(&helm_bin) + .args(&args) + .output() + .await + .map_err(HelmUninstallError::Execute)?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if !output.status.success() { + return Err(HelmUninstallError::Failed { + status: output.status.code().unwrap_or(-1), + stdout, + stderr, + }); + } + + let mut command = vec![helm_bin]; + command.extend(args); + + Ok(HelmUninstallResult { + command, + stdout, + stderr, + }) +} + +fn write_values_file(values: &Value) -> Result { + let dir = std::env::var("MCP_HELM_VALUES_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| std::env::temp_dir().join("supernode-mcp-helm")); + std::fs::create_dir_all(&dir).map_err(HelmValuesFileError::CreateValuesDir)?; + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let path = dir.join(format!("values-{unique}.json")); + let payload = + serde_json::to_vec_pretty(values).map_err(HelmValuesFileError::SerializeValues)?; + std::fs::write(&path, payload).map_err(HelmValuesFileError::WriteValues)?; + Ok(path) +} + +#[derive(Debug, thiserror::Error)] +enum HelmValuesFileError { + #[error("failed to create Helm values directory: {0}")] + CreateValuesDir(std::io::Error), + #[error("failed to serialize Helm values: {0}")] + SerializeValues(serde_json::Error), + #[error("failed to write Helm values file: {0}")] + WriteValues(std::io::Error), +} + +impl From for HelmInstallError { + fn from(error: HelmValuesFileError) -> Self { + match error { + HelmValuesFileError::CreateValuesDir(error) => Self::CreateValuesDir(error), + HelmValuesFileError::SerializeValues(error) => Self::SerializeValues(error), + HelmValuesFileError::WriteValues(error) => Self::WriteValues(error), + } + } +} + +impl From for HelmUpgradeError { + fn from(error: HelmValuesFileError) -> Self { + match error { + HelmValuesFileError::CreateValuesDir(error) => Self::CreateValuesDir(error), + HelmValuesFileError::SerializeValues(error) => Self::SerializeValues(error), + HelmValuesFileError::WriteValues(error) => Self::WriteValues(error), + } + } +} + +fn install_args(plan: &HelmInstallPlan, values_path: &Path) -> Vec { + vec![ + "upgrade".to_string(), + "--install".to_string(), + plan.release_name.clone(), + plan.chart.chart.clone(), + "--namespace".to_string(), + plan.namespace.clone(), + "--create-namespace".to_string(), + "--version".to_string(), + plan.chart.version.clone(), + "--values".to_string(), + values_path.display().to_string(), + ] +} + +fn upgrade_args(plan: &HelmUpgradePlan, values_path: &Path) -> Vec { + vec![ + "upgrade".to_string(), + plan.release_name.clone(), + plan.chart.chart.clone(), + "--namespace".to_string(), + plan.namespace.clone(), + "--version".to_string(), + plan.chart.version.clone(), + "--values".to_string(), + values_path.display().to_string(), + ] +} + +fn uninstall_args(plan: &HelmUninstallPlan) -> Vec { + vec![ + "uninstall".to_string(), + plan.release_name.clone(), + "--namespace".to_string(), + plan.namespace.clone(), + ] +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn install_args_are_bounded_to_upgrade_install() { + let plan = HelmInstallPlan { + release_name: "cardano-preview".to_string(), + namespace: "cardano-preview".to_string(), + chart: HelmChartRef { + chart: "oci://oci.supernode.store/extensions/cardano-node".to_string(), + version: "0.1.0-rc1".to_string(), + }, + values: json!({ "node": { "network": "preview" } }), + }; + let args = install_args(&plan, &PathBuf::from("/tmp/values.json")); + + assert_eq!(args[0], "upgrade"); + assert_eq!(args[1], "--install"); + assert!(args.contains(&"--create-namespace".to_string())); + assert!(args.contains(&"/tmp/values.json".to_string())); + assert!(!args.contains(&"--wait".to_string())); + assert!(!args.contains(&"--timeout".to_string())); + assert!(!args.contains(&"--atomic".to_string())); + assert!(!args.iter().any(|arg| arg.contains("rawValues"))); + } + + #[test] + fn uninstall_args_are_bounded_to_release_and_namespace() { + let plan = HelmUninstallPlan { + release_name: "hydra-offline".to_string(), + namespace: "hydra".to_string(), + }; + + let args = uninstall_args(&plan); + + assert_eq!(args[0], "uninstall"); + assert_eq!(args[1], "hydra-offline"); + assert!(args.contains(&"--namespace".to_string())); + assert!(args.contains(&"hydra".to_string())); + assert!(!args.contains(&"--wait".to_string())); + assert!(!args.contains(&"--timeout".to_string())); + } + + #[test] + fn upgrade_args_do_not_install_missing_releases() { + let plan = HelmUpgradePlan { + release_name: "hydra-offline".to_string(), + namespace: "hydra".to_string(), + chart: HelmChartRef { + chart: "oci://oci.supernode.store/extensions/hydra-node".to_string(), + version: "0.2.0".to_string(), + }, + values: json!({ "node": { "offlineMode": true } }), + }; + + let args = upgrade_args(&plan, &PathBuf::from("/tmp/values.json")); + + assert_eq!(args[0], "upgrade"); + assert_eq!(args[1], "hydra-offline"); + assert!(args.contains(&"/tmp/values.json".to_string())); + assert!(!args.contains(&"--wait".to_string())); + assert!(!args.contains(&"--timeout".to_string())); + assert!(!args.contains(&"--atomic".to_string())); + assert!(!args.contains(&"--install".to_string())); + assert!(!args.contains(&"--create-namespace".to_string())); + } +} diff --git a/mcp-server/src/k8s/client.rs b/mcp-server/src/k8s/client.rs new file mode 100644 index 0000000..e32e5a2 --- /dev/null +++ b/mcp-server/src/k8s/client.rs @@ -0,0 +1,503 @@ +use k8s_openapi::NamespaceResourceScope; +use k8s_openapi::api::apps::v1::Deployment; +use k8s_openapi::api::apps::v1::StatefulSet; +use k8s_openapi::api::core::v1::ConfigMap; +use k8s_openapi::api::core::v1::Endpoints; +use k8s_openapi::api::core::v1::Event; +use k8s_openapi::api::core::v1::Namespace; +use k8s_openapi::api::core::v1::PersistentVolumeClaim; +use k8s_openapi::api::core::v1::Pod; +use k8s_openapi::api::core::v1::Service; +use k8s_openapi::api::storage::v1::StorageClass; +use kube::Api; +use kube::Client; +use kube::Resource; +use kube::api::AttachParams; +use kube::api::DeleteParams; +use kube::api::ListParams; +use kube::api::LogParams; +use kube::api::ObjectList; +use kube::api::Patch; +use kube::api::PatchParams; +use serde::de::DeserializeOwned; +use serde_json::json; +use tokio::io::AsyncRead; +use tokio::io::AsyncReadExt; +use tokio::time::Duration; + +const DEFAULT_LOG_TAIL_LINES: i64 = 200; +const MAX_LOG_TAIL_LINES: i64 = 1_000; +const DEFAULT_EXEC_TIMEOUT_SECONDS: u64 = 30; +const MAX_EXEC_STDOUT_BYTES: usize = 512 * 1024; +const MAX_EXEC_STDERR_BYTES: usize = 64 * 1024; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct PodExecOutput { + pub stdout: String, + pub stderr: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum PodExecError { + #[error("kubernetes error: {0}")] + Kubernetes(#[from] kube::Error), + #[error("pod exec did not expose {0}")] + MissingStream(&'static str), + #[error("failed to read pod exec {stream}: {source}")] + Read { + stream: &'static str, + source: std::io::Error, + }, + #[error("pod exec {stream} exceeded {max_bytes} bytes")] + OutputTooLarge { + stream: &'static str, + max_bytes: usize, + }, + #[error("pod exec timed out after {seconds} seconds")] + Timeout { seconds: u64 }, + #[error("pod exec failed: {0}")] + RemoteCommand(String), + #[error("pod exec command failed: {message}")] + CommandFailed { message: String }, +} + +#[derive(Debug, Clone, Default, Eq, PartialEq)] +pub struct ResourceListParams { + pub label_selector: Option, + pub field_selector: Option, + pub limit: Option, +} + +impl ResourceListParams { + pub fn to_kube(&self) -> ListParams { + let mut params = ListParams::default(); + + if let Some(label_selector) = &self.label_selector { + params = params.labels(label_selector); + } + + if let Some(field_selector) = &self.field_selector { + params = params.fields(field_selector); + } + + if let Some(limit) = self.limit { + params = params.limit(limit); + } + + params + } +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct PodLogParams { + pub container: Option, + pub previous: bool, + pub tail_lines: Option, + pub since_seconds: Option, + pub timestamps: bool, +} + +impl Default for PodLogParams { + fn default() -> Self { + Self { + container: None, + previous: false, + tail_lines: Some(DEFAULT_LOG_TAIL_LINES), + since_seconds: None, + timestamps: false, + } + } +} + +impl PodLogParams { + pub fn to_kube(&self) -> LogParams { + LogParams { + container: self.container.clone(), + previous: self.previous, + since_seconds: self.since_seconds, + tail_lines: Some(self.effective_tail_lines()), + timestamps: self.timestamps, + ..Default::default() + } + } + + fn effective_tail_lines(&self) -> i64 { + self.tail_lines + .unwrap_or(DEFAULT_LOG_TAIL_LINES) + .clamp(1, MAX_LOG_TAIL_LINES) + } +} + +#[derive(Clone)] +pub struct KubernetesClient { + client: Client, +} + +impl KubernetesClient { + pub async fn try_default() -> Result { + Ok(Self { + client: Client::try_default().await?, + }) + } + + pub fn from_client(client: Client) -> Self { + Self { client } + } + + pub fn inner(&self) -> &Client { + &self.client + } + + pub async fn list_namespaces( + &self, + params: &ResourceListParams, + ) -> Result, kube::Error> { + Api::::all(self.client.clone()) + .list(¶ms.to_kube()) + .await + } + + pub async fn get_namespace(&self, name: &str) -> Result { + Api::::all(self.client.clone()).get(name).await + } + + pub async fn list_pods( + &self, + namespace: Option<&str>, + params: &ResourceListParams, + ) -> Result, kube::Error> { + self.api::(namespace).list(¶ms.to_kube()).await + } + + pub async fn get_pod(&self, namespace: &str, name: &str) -> Result { + self.api::(Some(namespace)).get(name).await + } + + pub async fn pod_logs( + &self, + namespace: &str, + name: &str, + params: &PodLogParams, + ) -> Result { + self.api::(Some(namespace)) + .logs(name, ¶ms.to_kube()) + .await + } + + pub async fn pod_exec_capture( + &self, + namespace: &str, + name: &str, + container: &str, + command: &[&str], + ) -> Result { + let pods = self.api::(Some(namespace)); + let params = AttachParams { + container: Some(container.to_string()), + stdin: false, + stdout: true, + stderr: true, + tty: false, + max_stdout_buf_size: Some(8 * 1024), + max_stderr_buf_size: Some(8 * 1024), + ..Default::default() + }; + let command = command + .iter() + .map(|argument| (*argument).to_string()) + .collect::>(); + let mut process = pods.exec(name, command, ¶ms).await?; + let stdout = process + .stdout() + .ok_or(PodExecError::MissingStream("stdout"))?; + let stderr = process + .stderr() + .ok_or(PodExecError::MissingStream("stderr"))?; + let status = process + .take_status() + .ok_or(PodExecError::MissingStream("status"))?; + + let capture = async { + tokio::try_join!( + read_limited(stdout, "stdout", MAX_EXEC_STDOUT_BYTES), + read_limited(stderr, "stderr", MAX_EXEC_STDERR_BYTES), + async { Ok::<_, PodExecError>(status.await) }, + ) + }; + + let (stdout, stderr, status) = + match tokio::time::timeout(Duration::from_secs(DEFAULT_EXEC_TIMEOUT_SECONDS), capture) + .await + { + Ok(Ok(result)) => result, + Ok(Err(error)) => { + process.abort(); + return Err(error); + } + Err(_) => { + process.abort(); + return Err(PodExecError::Timeout { + seconds: DEFAULT_EXEC_TIMEOUT_SECONDS, + }); + } + }; + + process + .join() + .await + .map_err(|error| PodExecError::RemoteCommand(error.to_string()))?; + + if let Some(status) = status + && status.status.as_deref() == Some("Failure") + { + return Err(PodExecError::CommandFailed { + message: status_message(&status), + }); + } + + Ok(PodExecOutput { stdout, stderr }) + } + + pub async fn list_services( + &self, + namespace: Option<&str>, + params: &ResourceListParams, + ) -> Result, kube::Error> { + self.api::(namespace).list(¶ms.to_kube()).await + } + + pub async fn get_service(&self, namespace: &str, name: &str) -> Result { + self.api::(Some(namespace)).get(name).await + } + + pub async fn list_endpoints( + &self, + namespace: Option<&str>, + params: &ResourceListParams, + ) -> Result, kube::Error> { + self.api::(namespace) + .list(¶ms.to_kube()) + .await + } + + pub async fn get_endpoints( + &self, + namespace: &str, + name: &str, + ) -> Result { + self.api::(Some(namespace)).get(name).await + } + + pub async fn list_persistent_volume_claims( + &self, + namespace: Option<&str>, + params: &ResourceListParams, + ) -> Result, kube::Error> { + self.api::(namespace) + .list(¶ms.to_kube()) + .await + } + + pub async fn get_persistent_volume_claim( + &self, + namespace: &str, + name: &str, + ) -> Result { + self.api::(Some(namespace)) + .get(name) + .await + } + + pub async fn delete_persistent_volume_claim( + &self, + namespace: &str, + name: &str, + ) -> Result<(), kube::Error> { + self.api::(Some(namespace)) + .delete(name, &DeleteParams::default()) + .await + .map(|_| ()) + } + + pub async fn list_config_maps( + &self, + namespace: Option<&str>, + params: &ResourceListParams, + ) -> Result, kube::Error> { + self.api::(namespace) + .list(¶ms.to_kube()) + .await + } + + pub async fn get_config_map( + &self, + namespace: &str, + name: &str, + ) -> Result { + self.api::(Some(namespace)).get(name).await + } + + pub async fn list_events( + &self, + namespace: Option<&str>, + params: &ResourceListParams, + ) -> Result, kube::Error> { + self.api::(namespace).list(¶ms.to_kube()).await + } + + pub async fn list_deployments( + &self, + namespace: Option<&str>, + params: &ResourceListParams, + ) -> Result, kube::Error> { + self.api::(namespace) + .list(¶ms.to_kube()) + .await + } + + pub async fn get_deployment( + &self, + namespace: &str, + name: &str, + ) -> Result { + self.api::(Some(namespace)).get(name).await + } + + pub async fn list_stateful_sets( + &self, + namespace: Option<&str>, + params: &ResourceListParams, + ) -> Result, kube::Error> { + self.api::(namespace) + .list(¶ms.to_kube()) + .await + } + + pub async fn get_stateful_set( + &self, + namespace: &str, + name: &str, + ) -> Result { + self.api::(Some(namespace)).get(name).await + } + + pub async fn scale_stateful_set( + &self, + namespace: &str, + name: &str, + replicas: i32, + ) -> Result { + self.api::(Some(namespace)) + .patch( + name, + &PatchParams::default(), + &Patch::Merge(json!({ "spec": { "replicas": replicas } })), + ) + .await + } + + pub async fn list_storage_classes( + &self, + params: &ResourceListParams, + ) -> Result, kube::Error> { + Api::::all(self.client.clone()) + .list(¶ms.to_kube()) + .await + } + + pub async fn get_storage_class(&self, name: &str) -> Result { + Api::::all(self.client.clone()) + .get(name) + .await + } + + fn api(&self, namespace: Option<&str>) -> Api + where + K: Clone + DeserializeOwned + Resource, + { + match namespace { + Some(namespace) => Api::namespaced(self.client.clone(), namespace), + None => Api::all(self.client.clone()), + } + } +} + +async fn read_limited( + mut reader: impl AsyncRead + Unpin, + stream: &'static str, + max_bytes: usize, +) -> Result { + let mut output = Vec::new(); + let mut buffer = [0_u8; 4096]; + + loop { + let read = reader + .read(&mut buffer) + .await + .map_err(|source| PodExecError::Read { stream, source })?; + if read == 0 { + break; + } + if output.len() + read > max_bytes { + return Err(PodExecError::OutputTooLarge { stream, max_bytes }); + } + output.extend_from_slice(&buffer[..read]); + } + + Ok(String::from_utf8_lossy(&output).to_string()) +} + +fn status_message(status: &k8s_openapi::apimachinery::pkg::apis::meta::v1::Status) -> String { + status + .message + .clone() + .or_else(|| status.reason.clone()) + .or_else(|| status.code.map(|code| format!("status code {code}"))) + .unwrap_or_else(|| "remote command failed".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn list_params_preserve_selectors_and_limit() { + let params = ResourceListParams { + label_selector: Some("app=cardano-node".to_string()), + field_selector: Some("metadata.namespace=cardano".to_string()), + limit: Some(50), + } + .to_kube(); + + assert_eq!(params.label_selector.as_deref(), Some("app=cardano-node")); + assert_eq!( + params.field_selector.as_deref(), + Some("metadata.namespace=cardano") + ); + assert_eq!(params.limit, Some(50)); + } + + #[test] + fn log_params_default_to_bounded_tail() { + let params = PodLogParams::default().to_kube(); + + assert_eq!(params.tail_lines, Some(DEFAULT_LOG_TAIL_LINES)); + assert!(!params.previous); + assert!(!params.timestamps); + } + + #[test] + fn log_params_clamp_tail_lines() { + let too_high = PodLogParams { + tail_lines: Some(10_000), + ..Default::default() + }; + let too_low = PodLogParams { + tail_lines: Some(0), + ..Default::default() + }; + + assert_eq!(too_high.to_kube().tail_lines, Some(MAX_LOG_TAIL_LINES)); + assert_eq!(too_low.to_kube().tail_lines, Some(1)); + } +} diff --git a/mcp-server/src/k8s/helm_releases.rs b/mcp-server/src/k8s/helm_releases.rs new file mode 100644 index 0000000..5461386 --- /dev/null +++ b/mcp-server/src/k8s/helm_releases.rs @@ -0,0 +1,394 @@ +use std::collections::BTreeMap; +use std::io::Read; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use flate2::read::GzDecoder; +use k8s_openapi::ByteString; +use k8s_openapi::api::core::v1::Secret; +use kube::Api; +use kube::api::ListParams; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; + +use crate::k8s::KubernetesClient; + +const HELM_SECRET_TYPE: &str = "helm.sh/release.v1"; +const HELM_RELEASE_DATA_KEY: &str = "release"; +const HELM_OWNER_LABEL: &str = "owner=helm"; +const CONTROL_PLANE_RELEASE_NAME: &str = "control-plane"; + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HelmReleaseSummary { + pub name: String, + pub namespace: String, + pub revision: i32, + pub status: Option, + pub chart: HelmChartSummary, + pub app_version: Option, + pub description: Option, + pub updated: Option, + pub secret_name: Option, + #[serde(skip_serializing)] + pub config: Option, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HelmChartSummary { + pub name: Option, + pub version: Option, +} + +#[derive(Clone)] +pub struct HelmReleaseDiscovery { + client: KubernetesClient, +} + +impl HelmReleaseDiscovery { + pub fn new(client: KubernetesClient) -> Self { + Self { client } + } + + pub async fn list_latest( + &self, + namespace: Option<&str>, + include_control_plane: bool, + ) -> Result, HelmReleaseError> { + let secrets = self.list_helm_secrets(namespace).await?; + Ok(latest_releases( + secrets + .items + .iter() + .map(decode_helm_release_secret) + .collect::, _>>()?, + include_control_plane, + )) + } + + pub async fn get_latest( + &self, + namespace: &str, + name: &str, + ) -> Result, HelmReleaseError> { + Ok(self + .list_latest(Some(namespace), true) + .await? + .into_iter() + .find(|release| release.name == name)) + } + + async fn list_helm_secrets( + &self, + namespace: Option<&str>, + ) -> Result, kube::Error> { + let params = ListParams::default().labels(HELM_OWNER_LABEL); + match namespace { + Some(namespace) => { + Api::::namespaced(self.client.inner().clone(), namespace) + .list(¶ms) + .await + } + None => { + Api::::all(self.client.inner().clone()) + .list(¶ms) + .await + } + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum HelmReleaseError { + #[error("kubernetes error: {0}")] + Kubernetes(#[from] kube::Error), + #[error("failed to decode Helm release secret: {0}")] + Decode(#[from] HelmReleaseDecodeError), +} + +#[derive(Debug, thiserror::Error)] +pub enum HelmReleaseDecodeError { + #[error("secret is not a Helm release secret")] + NotHelmReleaseSecret, + #[error("missing Helm release payload")] + MissingPayload, + #[error("release payload is not valid UTF-8 or base64/gzip encoded JSON")] + InvalidPayload, + #[error("release payload JSON is invalid: {0}")] + InvalidJson(serde_json::Error), +} + +#[derive(Debug, Deserialize)] +struct HelmReleaseData { + name: String, + namespace: String, + version: i32, + info: Option, + chart: Option, + config: Option, +} + +#[derive(Debug, Deserialize)] +struct HelmReleaseInfo { + status: Option, + description: Option, + #[serde(rename = "last_deployed", alias = "lastDeployed")] + last_deployed: Option, +} + +#[derive(Debug, Deserialize)] +struct HelmChart { + metadata: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct HelmChartMetadata { + name: Option, + version: Option, + app_version: Option, +} + +fn decode_helm_release_secret( + secret: &Secret, +) -> Result { + if secret.type_.as_deref() != Some(HELM_SECRET_TYPE) { + return Err(HelmReleaseDecodeError::NotHelmReleaseSecret); + } + + let payload = secret + .data + .as_ref() + .and_then(|data| data.get(HELM_RELEASE_DATA_KEY)) + .ok_or(HelmReleaseDecodeError::MissingPayload)?; + let release = decode_release_payload(payload)?; + let chart_metadata = release.chart.and_then(|chart| chart.metadata); + let info = release.info; + + Ok(HelmReleaseSummary { + name: release.name, + namespace: release.namespace, + revision: revision_from_secret(secret).unwrap_or(release.version), + status: info.as_ref().and_then(|info| info.status.clone()), + chart: HelmChartSummary { + name: chart_metadata + .as_ref() + .and_then(|metadata| metadata.name.clone()), + version: chart_metadata + .as_ref() + .and_then(|metadata| metadata.version.clone()), + }, + app_version: chart_metadata.and_then(|metadata| metadata.app_version), + description: info.as_ref().and_then(|info| info.description.clone()), + updated: info.and_then(|info| info.last_deployed), + secret_name: secret.metadata.name.clone(), + config: release.config, + }) +} + +fn decode_release_payload(payload: &ByteString) -> Result { + let mut candidates = vec![payload.0.clone()]; + + if let Ok(text) = std::str::from_utf8(&payload.0) + && let Ok(decoded) = STANDARD.decode(text.trim()) + { + candidates.push(decoded); + } + + for candidate in candidates { + if let Ok(release) = serde_json::from_slice::(&candidate) { + return Ok(release); + } + + if let Ok(inflated) = gunzip(&candidate) { + return serde_json::from_slice::(&inflated) + .map_err(HelmReleaseDecodeError::InvalidJson); + } + } + + Err(HelmReleaseDecodeError::InvalidPayload) +} + +fn gunzip(payload: &[u8]) -> Result, std::io::Error> { + let mut decoder = GzDecoder::new(payload); + let mut decoded = Vec::new(); + decoder.read_to_end(&mut decoded)?; + Ok(decoded) +} + +fn revision_from_secret(secret: &Secret) -> Option { + secret + .metadata + .labels + .as_ref()? + .get("version")? + .parse() + .ok() +} + +fn latest_releases( + releases: Vec, + include_control_plane: bool, +) -> Vec { + let mut latest = BTreeMap::<(String, String), HelmReleaseSummary>::new(); + + for release in releases { + if !include_control_plane && is_control_plane_release(&release) { + continue; + } + + let key = (release.namespace.clone(), release.name.clone()); + let replace = latest + .get(&key) + .is_none_or(|existing| release.revision > existing.revision); + + if replace { + latest.insert(key, release); + } + } + + latest.into_values().collect() +} + +fn is_control_plane_release(release: &HelmReleaseSummary) -> bool { + release.name == CONTROL_PLANE_RELEASE_NAME + || release.chart.name.as_deref() == Some(CONTROL_PLANE_RELEASE_NAME) + || release.namespace == CONTROL_PLANE_RELEASE_NAME +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use flate2::Compression; + use flate2::write::GzEncoder; + use k8s_openapi::api::core::v1::Secret; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + use serde_json::json; + + use super::*; + + #[test] + fn decodes_base64_gzip_helm_release_payload() { + let secret = helm_secret("sh.helm.release.v1.cardano.v2", "cardano", "cardano", 2); + + let release = decode_helm_release_secret(&secret).unwrap(); + + assert_eq!(release.name, "cardano"); + assert_eq!(release.namespace, "cardano"); + assert_eq!(release.revision, 2); + assert_eq!(release.status.as_deref(), Some("deployed")); + assert_eq!(release.chart.name.as_deref(), Some("cardano-node")); + assert_eq!(release.chart.version.as_deref(), Some("0.1.0-rc1")); + assert_eq!(release.app_version.as_deref(), Some("10.7.1")); + assert_eq!(release.config, Some(json!({ "mustNotBeReturned": true }))); + } + + #[test] + fn latest_releases_keep_highest_revision_and_exclude_control_plane() { + let releases = vec![ + decode_helm_release_secret(&helm_secret( + "sh.helm.release.v1.cardano.v1", + "cardano", + "cardano", + 1, + )) + .unwrap(), + decode_helm_release_secret(&helm_secret( + "sh.helm.release.v1.cardano.v3", + "cardano", + "cardano", + 3, + )) + .unwrap(), + decode_helm_release_secret(&helm_secret( + "sh.helm.release.v1.control-plane.v1", + "control-plane", + "control-plane", + 1, + )) + .unwrap(), + ]; + + let latest = latest_releases(releases, false); + + assert_eq!(latest.len(), 1); + assert_eq!(latest[0].name, "cardano"); + assert_eq!(latest[0].revision, 3); + } + + #[test] + fn malformed_release_payload_is_rejected() { + let secret = Secret { + type_: Some(HELM_SECRET_TYPE.to_string()), + data: Some(BTreeMap::from([( + HELM_RELEASE_DATA_KEY.to_string(), + ByteString(b"not-valid".to_vec()), + )])), + metadata: ObjectMeta { + name: Some("sh.helm.release.v1.bad.v1".to_string()), + ..Default::default() + }, + ..Default::default() + }; + + let error = decode_helm_release_secret(&secret).unwrap_err(); + + assert!(matches!(error, HelmReleaseDecodeError::InvalidPayload)); + } + + fn helm_secret( + secret_name: &str, + namespace: &str, + release_name: &str, + revision: i32, + ) -> Secret { + Secret { + type_: Some(HELM_SECRET_TYPE.to_string()), + data: Some(BTreeMap::from([( + HELM_RELEASE_DATA_KEY.to_string(), + encoded_release(namespace, release_name, revision), + )])), + metadata: ObjectMeta { + name: Some(secret_name.to_string()), + namespace: Some(namespace.to_string()), + labels: Some(BTreeMap::from([ + ("owner".to_string(), "helm".to_string()), + ("name".to_string(), release_name.to_string()), + ("version".to_string(), revision.to_string()), + ])), + ..Default::default() + }, + ..Default::default() + } + } + + fn encoded_release(namespace: &str, release_name: &str, revision: i32) -> ByteString { + let release = json!({ + "name": release_name, + "namespace": namespace, + "version": revision, + "info": { + "status": "deployed", + "description": "Install complete", + "last_deployed": "2026-05-07T12:00:00Z" + }, + "chart": { + "metadata": { + "name": if release_name == "control-plane" { "control-plane" } else { "cardano-node" }, + "version": "0.1.0-rc1", + "appVersion": "10.7.1" + } + }, + "config": { "mustNotBeReturned": true }, + "manifest": "apiVersion: v1\nkind: Secret\n" + }); + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(release.to_string().as_bytes()).unwrap(); + let gzipped = encoder.finish().unwrap(); + + ByteString(STANDARD.encode(gzipped).into_bytes()) + } +} diff --git a/mcp-server/src/k8s/mod.rs b/mcp-server/src/k8s/mod.rs new file mode 100644 index 0000000..44c27fd --- /dev/null +++ b/mcp-server/src/k8s/mod.rs @@ -0,0 +1,11 @@ +pub mod client; +pub mod helm_releases; + +pub use client::KubernetesClient; +pub use client::PodExecError; +pub use client::PodLogParams; +pub use client::ResourceListParams; +pub use helm_releases::HelmChartSummary; +pub use helm_releases::HelmReleaseDiscovery; +pub use helm_releases::HelmReleaseError; +pub use helm_releases::HelmReleaseSummary; diff --git a/mcp-server/src/main.rs b/mcp-server/src/main.rs new file mode 100644 index 0000000..70934c4 --- /dev/null +++ b/mcp-server/src/main.rs @@ -0,0 +1,31 @@ +mod audit; +mod auth; +mod catalog; +mod config; +mod errors; +mod helm; +pub mod k8s; +mod mcp; +mod policy; +mod prompts; +mod resources; +mod server; +mod session; +mod tools; +pub mod vault; + +use crate::config::Config; +use crate::server::run; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let config = Config::from_env()?; + + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::try_new( + config.log_level.clone(), + )?) + .init(); + + run(config).await +} diff --git a/mcp-server/src/mcp.rs b/mcp-server/src/mcp.rs new file mode 100644 index 0000000..a45aa1e --- /dev/null +++ b/mcp-server/src/mcp.rs @@ -0,0 +1,388 @@ +use std::sync::Arc; + +use rmcp::RoleServer; +use rmcp::ServerHandler; +use rmcp::model::CallToolRequestParams; +use rmcp::model::CallToolResult; +use rmcp::model::ErrorData as McpError; +use rmcp::model::GetPromptRequestParams; +use rmcp::model::GetPromptResult; +use rmcp::model::Implementation; +use rmcp::model::InitializeRequestParams; +use rmcp::model::InitializeResult; +use rmcp::model::ListPromptsResult; +use rmcp::model::ListResourcesResult; +use rmcp::model::ListToolsResult; +use rmcp::model::PaginatedRequestParams; +use rmcp::model::ProtocolVersion; +use rmcp::model::ReadResourceRequestParams; +use rmcp::model::ReadResourceResult; +use rmcp::model::ServerCapabilities; +use rmcp::model::ServerInfo; +use rmcp::service::NotificationContext; +use rmcp::service::RequestContext; + +use crate::audit::AuditEvent; +use crate::audit::AuditSink; +use crate::audit::AuditTarget; +use crate::auth::AuthContext; +use crate::catalog::ExtensionCatalog; +use crate::policy::ApprovalClass; +use crate::policy::Policy; +use crate::policy::PolicyDecision; +use crate::policy::Scope; +use crate::prompts::PromptCatalog; +use crate::resources::ResourceRouter; +use crate::resources::router::ResourceReadError; +use crate::tools::ToolRouter; +use crate::tools::dynamic::DynamicToolState; + +#[derive(Clone)] +pub struct SupernodeMcpServer { + auth: AuthContext, + policy: Policy, + audit: Arc, + catalog: Arc, + resources: ResourceRouter, + prompts: PromptCatalog, + tools: ToolRouter, + dynamic_tools: DynamicToolState, +} + +impl SupernodeMcpServer { + pub fn new( + auth: AuthContext, + policy: Policy, + audit: Arc, + catalog: Arc, + ) -> Self { + let resources = ResourceRouter::new(catalog.clone()); + + Self { + auth, + policy, + audit, + catalog, + resources, + prompts: PromptCatalog, + tools: ToolRouter::new(), + dynamic_tools: DynamicToolState::default(), + } + } + + fn audit_discovery(&self, action: &str) { + let scope_outcome = self.policy.require_scope(&self.auth, Scope::Discover); + let audit_event = AuditEvent::from_policy_outcome( + &self.auth, + action, + None, + AuditTarget::None, + &scope_outcome, + ); + self.audit.record(&audit_event); + } + + fn server_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .enable_resources() + .enable_prompts() + .build(), + ) + .with_server_info( + Implementation::new("metis-supernode-mcp", env!("CARGO_PKG_VERSION")) + .with_title("Metis Supernode MCP"), + ) + .with_protocol_version(ProtocolVersion::V_2025_11_25) + .with_instructions("Operate an existing Metis Supernode cluster using typed tools only. Trusted MVP mode uses advisory policy and audit, not OAuth enforcement.".to_string()) + } +} + +impl ServerHandler for SupernodeMcpServer { + async fn initialize( + &self, + _request: InitializeRequestParams, + _context: RequestContext, + ) -> Result { + self.audit_discovery("initialize"); + + let approval_outcome = + self.policy + .require_approval(&self.auth, None, ApprovalClass::Discovery); + let audit_event = AuditEvent::from_policy_outcome( + &self.auth, + "initialize.approval", + None, + AuditTarget::None, + &approval_outcome, + ); + self.audit.record(&audit_event); + + tracing::debug!( + supported_approval_classes = ApprovalClass::all().len(), + extension_count = self.catalog.len(), + listed_extension_count = self.catalog.list().count(), + "initialized MCP session" + ); + + Ok(self.server_info()) + } + + async fn list_resources( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + self.audit_discovery("resources/list"); + + Ok(self.resources.list()) + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + self.audit_discovery("resources/read"); + + self.resources + .read(&request.uri, &self.auth) + .map_err(|error| match error { + ResourceReadError::NotFound => McpError::resource_not_found( + format!("resource not found: {}", request.uri), + None, + ), + ResourceReadError::Serialize(error) => McpError::internal_error( + "failed to serialize resource".to_string(), + Some(serde_json::json!({ "error": error.to_string() })), + ), + }) + } + + async fn list_prompts( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + self.audit_discovery("prompts/list"); + + Ok(self.prompts.list()) + } + + async fn get_prompt( + &self, + request: GetPromptRequestParams, + _context: RequestContext, + ) -> Result { + self.audit_discovery("prompts/get"); + + self.prompts.get(&request.name).ok_or_else(|| { + McpError::invalid_params(format!("prompt not found: {}", request.name), None) + }) + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + self.audit_discovery("tools/list"); + self.dynamic_tools.refresh().await; + let dynamic_definitions = self.dynamic_tools.definitions().await; + + Ok(self.tools.list_with_dynamic(&dynamic_definitions)) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + let tool_name = request.name.to_string(); + self.dynamic_tools.refresh().await; + let dynamic_definitions = self.dynamic_tools.definitions().await; + let definition = self + .tools + .get_with_dynamic(&tool_name, &dynamic_definitions) + .ok_or_else(|| { + McpError::invalid_params(format!("tool not found: {tool_name}"), None) + })?; + let approval_id = request + .arguments + .as_ref() + .and_then(|arguments| arguments.get("approvalId")) + .and_then(|value| value.as_str()) + .map(str::to_string); + + let audit_target = audit_target_for_tool(&tool_name, request.arguments.as_ref()); + let scope_outcome = self + .policy + .require_scope(&self.auth, definition.required_scope); + let audit_event = AuditEvent::from_policy_outcome( + &self.auth, + tool_name.clone(), + approval_id.clone(), + audit_target.clone(), + &scope_outcome, + ); + self.audit.record(&audit_event); + if scope_outcome.decision == PolicyDecision::Denied { + return Ok(CallToolResult::structured_error(serde_json::json!({ + "error": "policy_denied", + "message": "missing required scope", + "policy": scope_outcome, + }))); + } + + let approval_outcome = self.policy.require_approval( + &self.auth, + approval_id.as_deref(), + definition.approval_class, + ); + let audit_event = AuditEvent::from_policy_outcome( + &self.auth, + format!("{tool_name}.approval"), + approval_id, + audit_target, + &approval_outcome, + ); + self.audit.record(&audit_event); + if approval_outcome.decision == PolicyDecision::Denied { + return Ok(CallToolResult::structured_error(serde_json::json!({ + "error": "approval_denied", + "message": "required approval is missing", + "policy": approval_outcome, + }))); + } + + let result = self + .tools + .call(definition, request.arguments.as_ref(), &self.catalog) + .await; + + if result.is_error != Some(true) + && !definition.read_only + && self.dynamic_tools.refresh().await + && let Err(error) = context.peer.notify_tool_list_changed().await + { + tracing::warn!(%error, "failed to send tools/list_changed notification"); + } + + Ok(result) + } + + async fn on_initialized(&self, context: NotificationContext) { + let dynamic_tools = self.dynamic_tools.clone(); + let peer = context.peer.clone(); + + tokio::spawn(async move { + dynamic_tools.refresh().await; + let mut last_signature = dynamic_tools.signature().await; + let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); + + loop { + interval.tick().await; + dynamic_tools.refresh().await; + let current_signature = dynamic_tools.signature().await; + if current_signature == last_signature { + continue; + } + last_signature = current_signature; + + if let Err(error) = peer.notify_tool_list_changed().await { + tracing::warn!(%error, "failed to send tools/list_changed notification"); + break; + } + } + }); + } + + fn get_info(&self) -> ServerInfo { + self.server_info() + } +} + +fn audit_target_for_tool( + tool_name: &str, + arguments: Option<&rmcp::model::JsonObject>, +) -> AuditTarget { + match tool_name { + "vault.runtime.metadata.get" | "vault.runtime.write" | "vault.runtime.patch" => { + let path = arguments + .and_then(|arguments| arguments.get("path")) + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string(); + let written_keys = arguments + .and_then(|arguments| arguments.get("values").or_else(|| arguments.get("data"))) + .and_then(|value| value.as_object()) + .map(|object| { + let mut keys = object.keys().cloned().collect::>(); + keys.sort(); + keys + }) + .unwrap_or_default(); + + AuditTarget::VaultRuntime { path, written_keys } + } + _ => AuditTarget::None, + } +} + +#[cfg(test)] +mod tests { + use crate::audit::TracingAuditSink; + + use super::*; + + #[test] + fn server_info_uses_target_protocol_version() { + let server = SupernodeMcpServer::new( + AuthContext::trusted(), + Policy, + Arc::new(TracingAuditSink), + Arc::new(ExtensionCatalog::embedded()), + ); + + let info = server.server_info(); + + assert_eq!(info.protocol_version, ProtocolVersion::V_2025_11_25); + assert_eq!(info.server_info.name, "metis-supernode-mcp"); + assert_eq!( + info.capabilities + .tools + .and_then(|capability| capability.list_changed), + Some(true) + ); + } + + #[test] + fn vault_audit_target_records_path_and_keys_only() { + let mut arguments = rmcp::model::JsonObject::new(); + arguments.insert( + "path".to_string(), + serde_json::Value::String("runtime/cardano-node/mainnet".to_string()), + ); + arguments.insert( + "values".to_string(), + serde_json::json!({ "kes.skey": "secret-value", "op.cert": "also-secret" }), + ); + + let target = audit_target_for_tool("vault.runtime.write", Some(&arguments)); + + assert_eq!( + target, + AuditTarget::VaultRuntime { + path: "runtime/cardano-node/mainnet".to_string(), + written_keys: vec!["kes.skey".to_string(), "op.cert".to_string()], + } + ); + assert!( + !serde_json::to_string(&target) + .unwrap() + .contains("secret-value") + ); + } +} diff --git a/mcp-server/src/policy/approvals.rs b/mcp-server/src/policy/approvals.rs new file mode 100644 index 0000000..dafd865 --- /dev/null +++ b/mcp-server/src/policy/approvals.rs @@ -0,0 +1,46 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalClass { + Discovery, + ReadOnlyDebug, + SensitiveRuntimeRead, + RuntimeSecretWrite, + OperatorSecretGuidance, + OperatorSecretBreakGlass, + Mutation, + Destructive, + LedgerPrepare, + LedgerSubmit, +} + +impl ApprovalClass { + pub fn all() -> &'static [Self] { + &[ + Self::Discovery, + Self::ReadOnlyDebug, + Self::SensitiveRuntimeRead, + Self::RuntimeSecretWrite, + Self::OperatorSecretGuidance, + Self::OperatorSecretBreakGlass, + Self::Mutation, + Self::Destructive, + Self::LedgerPrepare, + Self::LedgerSubmit, + ] + } + + pub fn requires_approval(self) -> bool { + match self { + Self::Discovery | Self::ReadOnlyDebug | Self::OperatorSecretGuidance => false, + Self::SensitiveRuntimeRead + | Self::RuntimeSecretWrite + | Self::OperatorSecretBreakGlass + | Self::Mutation + | Self::Destructive + | Self::LedgerPrepare + | Self::LedgerSubmit => true, + } + } +} diff --git a/mcp-server/src/policy/mod.rs b/mcp-server/src/policy/mod.rs new file mode 100644 index 0000000..cc1ac53 --- /dev/null +++ b/mcp-server/src/policy/mod.rs @@ -0,0 +1,146 @@ +pub mod approvals; +pub mod roles; +pub mod scopes; + +use std::collections::BTreeSet; + +use serde::Serialize; + +use crate::auth::AuthContext; + +pub use approvals::ApprovalClass; +pub use roles::Role; +pub use scopes::Scope; + +#[derive(Debug, Clone, Default)] +pub struct Policy; + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PolicyDecision { + Allowed, + AdvisoryAllowed, + Denied, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PolicyOutcome { + pub decision: PolicyDecision, + pub required_scopes: BTreeSet, + pub approval_required: bool, + pub approval_present: bool, + pub reason: Option, +} + +impl Policy { + pub fn require_scope(&self, auth: &AuthContext, scope: Scope) -> PolicyOutcome { + let required_scopes = BTreeSet::from([scope]); + + if auth.scopes.contains(&scope) { + return PolicyOutcome { + decision: PolicyDecision::Allowed, + required_scopes, + approval_required: false, + approval_present: false, + reason: None, + }; + } + + let decision = if auth.enforced { + PolicyDecision::Denied + } else { + PolicyDecision::AdvisoryAllowed + }; + + PolicyOutcome { + decision, + required_scopes, + approval_required: false, + approval_present: false, + reason: Some(format!("missing required scope {scope:?}")), + } + } + + pub fn require_approval( + &self, + auth: &AuthContext, + approval_id: Option<&str>, + approval_class: ApprovalClass, + ) -> PolicyOutcome { + let approval_required = approval_class.requires_approval(); + let approval_present = approval_id.is_some_and(|value| !value.trim().is_empty()); + let missing_required_approval = approval_required && !approval_present; + + if !missing_required_approval { + return PolicyOutcome { + decision: PolicyDecision::Allowed, + required_scopes: BTreeSet::new(), + approval_required, + approval_present, + reason: None, + }; + } + + let decision = if auth.enforced { + PolicyDecision::Denied + } else { + PolicyDecision::AdvisoryAllowed + }; + + PolicyOutcome { + decision, + required_scopes: BTreeSet::new(), + approval_required, + approval_present, + reason: Some(format!("missing approval for {approval_class:?}")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::AuthContext; + + #[test] + fn trusted_context_has_all_scopes() { + let auth = AuthContext::trusted(); + + assert_eq!(auth.scopes, Scope::all()); + assert!(!auth.enforced); + } + + #[test] + fn trusted_policy_allows_missing_scope_as_advisory() { + let mut auth = AuthContext::trusted(); + auth.scopes.remove(&Scope::Discover); + + let outcome = Policy.require_scope(&auth, Scope::Discover); + + assert_eq!(outcome.decision, PolicyDecision::AdvisoryAllowed); + assert!(outcome.reason.is_some()); + } + + #[test] + fn enforced_policy_denies_missing_scope() { + let mut auth = AuthContext::trusted(); + auth.enforced = true; + auth.scopes.remove(&Scope::Discover); + + let outcome = Policy.require_scope(&auth, Scope::Discover); + + assert_eq!(outcome.decision, PolicyDecision::Denied); + } + + #[test] + fn trusted_policy_allows_missing_approval_as_advisory() { + let auth = AuthContext::trusted(); + + let outcome = Policy.require_approval(&auth, None, ApprovalClass::Mutation); + + assert_eq!(outcome.decision, PolicyDecision::AdvisoryAllowed); + assert!(outcome.approval_required); + assert!(!outcome.approval_present); + } +} diff --git a/mcp-server/src/policy/roles.rs b/mcp-server/src/policy/roles.rs new file mode 100644 index 0000000..cd1ce2b --- /dev/null +++ b/mcp-server/src/policy/roles.rs @@ -0,0 +1,7 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Role { + Admin, +} diff --git a/mcp-server/src/policy/scopes.rs b/mcp-server/src/policy/scopes.rs new file mode 100644 index 0000000..9e5f577 --- /dev/null +++ b/mcp-server/src/policy/scopes.rs @@ -0,0 +1,41 @@ +use std::collections::BTreeSet; + +use serde::Serialize; + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Scope { + Discover, + Debug, + WorkloadsInstall, + WorkloadsUpgrade, + WorkloadsDelete, + VaultRuntimeMetadata, + VaultRuntimeRead, + VaultRuntimeWrite, + VaultOperatorMetadata, + VaultOperatorWrite, + VaultOperatorRead, + Admin, +} + +impl Scope { + pub fn all() -> BTreeSet { + [ + Self::Discover, + Self::Debug, + Self::WorkloadsInstall, + Self::WorkloadsUpgrade, + Self::WorkloadsDelete, + Self::VaultRuntimeMetadata, + Self::VaultRuntimeRead, + Self::VaultRuntimeWrite, + Self::VaultOperatorMetadata, + Self::VaultOperatorWrite, + Self::VaultOperatorRead, + Self::Admin, + ] + .into_iter() + .collect() + } +} diff --git a/mcp-server/src/prompts/catalog.rs b/mcp-server/src/prompts/catalog.rs new file mode 100644 index 0000000..8b9c04b --- /dev/null +++ b/mcp-server/src/prompts/catalog.rs @@ -0,0 +1,114 @@ +use rmcp::model::GetPromptResult; +use rmcp::model::ListPromptsResult; +use rmcp::model::Prompt; +use rmcp::model::PromptMessage; +use rmcp::model::PromptMessageRole; + +#[derive(Debug, Clone, Copy)] +struct PromptSpec { + name: &'static str, + title: &'static str, + description: &'static str, + text: &'static str, +} + +#[derive(Debug, Clone)] +pub struct PromptCatalog; + +impl PromptCatalog { + pub fn list(&self) -> ListPromptsResult { + ListPromptsResult::with_all_items( + PROMPTS + .iter() + .map(|prompt| { + Prompt::new(prompt.name, Some(prompt.description), None) + .with_title(prompt.title) + }) + .collect(), + ) + } + + pub fn get(&self, name: &str) -> Option { + let prompt = PROMPTS.iter().find(|prompt| prompt.name == name)?; + Some( + GetPromptResult::new(vec![PromptMessage::new_text( + PromptMessageRole::User, + prompt.text, + )]) + .with_description(prompt.description), + ) + } +} + +const PROMPTS: &[PromptSpec] = &[ + PromptSpec { + name: "bootstrap-and-discovery", + title: "Bootstrap And Discovery", + description: "Discover an existing Supernode cluster before making changes.", + text: "Inspect the Supernode through read-only resources and discovery tools first. Use supernode://status, supernode://control-plane/status, and supernode://extensions/catalog to understand the environment. Do not bootstrap infrastructure from MCP, do not shell out, and do not request raw Kubernetes, Vault, or Helm proxy access.", + }, + PromptSpec { + name: "cardano-relay-setup", + title: "Cardano Relay Setup", + description: "Plan a Cardano relay install through the catalog workflow.", + text: "Use the catalog-driven lifecycle workflow for a Cardano relay. Read supernode://extensions/catalog/cardano-node-relay, validate required extension configuration values, and use workloads.install when tool execution is available. Do not use extension-specific install tools or raw Helm values.", + }, + PromptSpec { + name: "dashboard-access", + title: "Dashboard Access", + description: "Inspect dashboard/control-plane access without broad privileges.", + text: "Inspect control-plane status through read-only MCP resources and typed discovery tools. Keep access scoped to the MCP server permissions; do not reuse the dashboard superadmin cluster role, do not expose bearer tokens, and prefer kubectl port-forward access for the trusted MVP.", + }, +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lists_expected_static_prompts() { + let catalog = PromptCatalog; + + let prompts = catalog.list().prompts; + + assert_eq!(prompts.len(), 3); + assert!( + prompts + .iter() + .any(|prompt| prompt.name == "bootstrap-and-discovery") + ); + assert!( + prompts + .iter() + .any(|prompt| prompt.name == "cardano-relay-setup") + ); + assert!( + prompts + .iter() + .any(|prompt| prompt.name == "dashboard-access") + ); + } + + #[test] + fn prompts_reference_catalog_driven_workflows() { + let catalog = PromptCatalog; + + let prompt = catalog.get("cardano-relay-setup").unwrap(); + let message = &prompt.messages[0]; + + assert_eq!(message.role, PromptMessageRole::User); + let rmcp::model::PromptMessageContent::Text { text } = &message.content else { + panic!("expected text prompt"); + }; + assert!(text.contains("workloads.install")); + assert!(text.contains("cardano-node-relay")); + assert!(!text.contains("cardano.relay.install")); + } + + #[test] + fn unknown_prompt_returns_none() { + let catalog = PromptCatalog; + + assert!(catalog.get("not-real").is_none()); + } +} diff --git a/mcp-server/src/prompts/mod.rs b/mcp-server/src/prompts/mod.rs new file mode 100644 index 0000000..ec224e8 --- /dev/null +++ b/mcp-server/src/prompts/mod.rs @@ -0,0 +1,3 @@ +pub mod catalog; + +pub use catalog::PromptCatalog; diff --git a/mcp-server/src/resources/mod.rs b/mcp-server/src/resources/mod.rs new file mode 100644 index 0000000..255f06e --- /dev/null +++ b/mcp-server/src/resources/mod.rs @@ -0,0 +1,4 @@ +pub mod router; +pub mod uri; + +pub use router::ResourceRouter; diff --git a/mcp-server/src/resources/router.rs b/mcp-server/src/resources/router.rs new file mode 100644 index 0000000..e1114fb --- /dev/null +++ b/mcp-server/src/resources/router.rs @@ -0,0 +1,205 @@ +use std::sync::Arc; + +use rmcp::model::Annotated; +use rmcp::model::ListResourcesResult; +use rmcp::model::RawResource; +use rmcp::model::ReadResourceResult; +use rmcp::model::Resource; +use rmcp::model::ResourceContents; +use serde::Serialize; +use serde_json::json; + +use crate::auth::AuthContext; +use crate::catalog::ExtensionCatalog; + +use super::uri::CONTROL_PLANE_STATUS_URI; +use super::uri::EXTENSION_CATALOG_URI; +use super::uri::STATUS_URI; +use super::uri::SupernodeResourceUri; +use super::uri::extension_catalog_entry_uri; + +const JSON_MIME_TYPE: &str = "application/json"; + +#[derive(Debug, Clone)] +pub struct ResourceRouter { + catalog: Arc, +} + +impl ResourceRouter { + pub fn new(catalog: Arc) -> Self { + Self { catalog } + } + + pub fn list(&self) -> ListResourcesResult { + let mut resources = vec![ + resource( + STATUS_URI, + "supernode-status", + "Supernode Status", + "Static MCP server status and runtime mode.", + ), + resource( + CONTROL_PLANE_STATUS_URI, + "control-plane-status", + "Control Plane Status", + "Static control-plane status placeholder until Kubernetes discovery is available.", + ), + resource( + EXTENSION_CATALOG_URI, + "extensions-catalog", + "Extension Catalog", + "Embedded catalog of extensions supported by this MCP server.", + ), + ]; + + resources.extend(self.catalog.list().map(|extension| { + resource( + extension_catalog_entry_uri(&extension.id), + format!("extension-catalog-{}", extension.id), + extension.name.clone(), + format!("Embedded catalog entry for {}.", extension.name), + ) + })); + + ListResourcesResult::with_all_items(resources) + } + + pub fn read( + &self, + uri: &str, + auth: &AuthContext, + ) -> Result { + let parsed = SupernodeResourceUri::parse(uri).ok_or(ResourceReadError::NotFound)?; + let value = match parsed { + SupernodeResourceUri::Status => json!({ + "status": "ok", + "authMode": auth.auth_mode, + "policyEnforced": auth.enforced, + "catalogExtensionCount": self.catalog.len(), + }), + SupernodeResourceUri::ControlPlaneStatus => json!({ + "status": "unknown", + "reason": "kubernetes-discovery-not-implemented", + }), + SupernodeResourceUri::ExtensionCatalog => json!({ + "extensions": self.catalog.list().collect::>(), + }), + SupernodeResourceUri::ExtensionCatalogEntry { extension_id } => serde_json::to_value( + self.catalog + .get(extension_id) + .ok_or(ResourceReadError::NotFound)?, + )?, + }; + + Ok(text_resource(uri, &value)?) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ResourceReadError { + #[error("resource not found")] + NotFound, + #[error("failed to serialize resource")] + Serialize(#[from] serde_json::Error), +} + +fn resource( + uri: impl Into, + name: impl Into, + title: impl Into, + description: impl Into, +) -> Resource { + Annotated::new( + RawResource::new(uri, name) + .with_title(title) + .with_description(description) + .with_mime_type(JSON_MIME_TYPE), + None, + ) +} + +fn text_resource( + uri: &str, + value: &impl Serialize, +) -> Result { + let text = serde_json::to_string_pretty(value)?; + Ok(ReadResourceResult::new(vec![ + ResourceContents::text(text, uri).with_mime_type(JSON_MIME_TYPE), + ])) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lists_static_and_extension_catalog_resources() { + let router = ResourceRouter::new(Arc::new(ExtensionCatalog::embedded())); + + let resources = router.list().resources; + + assert!(resources.iter().any(|resource| resource.uri == STATUS_URI)); + assert!( + resources + .iter() + .any(|resource| resource.uri == EXTENSION_CATALOG_URI) + ); + assert!( + resources.iter().any(|resource| { + resource.uri == extension_catalog_entry_uri("cardano-node-relay") + }) + ); + } + + #[test] + fn reads_catalog_resource_as_json() { + let router = ResourceRouter::new(Arc::new(ExtensionCatalog::embedded())); + + let result = router + .read(EXTENSION_CATALOG_URI, &AuthContext::trusted()) + .unwrap(); + + assert_eq!(result.contents.len(), 1); + let ResourceContents::TextResourceContents { + text, mime_type, .. + } = &result.contents[0] + else { + panic!("expected text resource"); + }; + assert_eq!(mime_type.as_deref(), Some(JSON_MIME_TYPE)); + assert!(text.contains("cardano-node-relay")); + assert!(!text.contains("secret-value")); + } + + #[test] + fn reads_one_catalog_entry() { + let router = ResourceRouter::new(Arc::new(ExtensionCatalog::embedded())); + + let result = router + .read( + &extension_catalog_entry_uri("cardano-node-relay"), + &AuthContext::trusted(), + ) + .unwrap(); + + let ResourceContents::TextResourceContents { text, .. } = &result.contents[0] else { + panic!("expected text resource"); + }; + assert!(text.contains("Cardano Node Relay")); + assert!(text.contains("configuration")); + } + + #[test] + fn unknown_resource_returns_not_found() { + let router = ResourceRouter::new(Arc::new(ExtensionCatalog::embedded())); + + let error = router + .read( + "supernode://extensions/catalog/not-real", + &AuthContext::trusted(), + ) + .unwrap_err(); + + assert!(matches!(error, ResourceReadError::NotFound)); + } +} diff --git a/mcp-server/src/resources/uri.rs b/mcp-server/src/resources/uri.rs new file mode 100644 index 0000000..1d36e55 --- /dev/null +++ b/mcp-server/src/resources/uri.rs @@ -0,0 +1,73 @@ +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum SupernodeResourceUri<'a> { + Status, + ControlPlaneStatus, + ExtensionCatalog, + ExtensionCatalogEntry { extension_id: &'a str }, +} + +pub const STATUS_URI: &str = "supernode://status"; +pub const CONTROL_PLANE_STATUS_URI: &str = "supernode://control-plane/status"; +pub const EXTENSION_CATALOG_URI: &str = "supernode://extensions/catalog"; + +const EXTENSION_CATALOG_ENTRY_PREFIX: &str = "supernode://extensions/catalog/"; + +impl<'a> SupernodeResourceUri<'a> { + pub fn parse(uri: &'a str) -> Option { + match uri { + STATUS_URI => Some(Self::Status), + CONTROL_PLANE_STATUS_URI => Some(Self::ControlPlaneStatus), + EXTENSION_CATALOG_URI => Some(Self::ExtensionCatalog), + _ => uri + .strip_prefix(EXTENSION_CATALOG_ENTRY_PREFIX) + .filter(|extension_id| !extension_id.is_empty() && !extension_id.contains('/')) + .map(|extension_id| Self::ExtensionCatalogEntry { extension_id }), + } + } +} + +pub fn extension_catalog_entry_uri(extension_id: &str) -> String { + format!("{EXTENSION_CATALOG_ENTRY_PREFIX}{extension_id}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_known_resource_uris() { + assert_eq!( + SupernodeResourceUri::parse(STATUS_URI), + Some(SupernodeResourceUri::Status) + ); + assert_eq!( + SupernodeResourceUri::parse(CONTROL_PLANE_STATUS_URI), + Some(SupernodeResourceUri::ControlPlaneStatus) + ); + assert_eq!( + SupernodeResourceUri::parse(EXTENSION_CATALOG_URI), + Some(SupernodeResourceUri::ExtensionCatalog) + ); + assert_eq!( + SupernodeResourceUri::parse("supernode://extensions/catalog/cardano-node-relay"), + Some(SupernodeResourceUri::ExtensionCatalogEntry { + extension_id: "cardano-node-relay" + }) + ); + } + + #[test] + fn rejects_unknown_or_nested_resource_uris() { + assert_eq!(SupernodeResourceUri::parse("supernode://unknown"), None); + assert_eq!( + SupernodeResourceUri::parse("supernode://extensions/catalog/"), + None + ); + assert_eq!( + SupernodeResourceUri::parse( + "supernode://extensions/catalog/cardano-node-relay/profile" + ), + None + ); + } +} diff --git a/mcp-server/src/server.rs b/mcp-server/src/server.rs new file mode 100644 index 0000000..aa80681 --- /dev/null +++ b/mcp-server/src/server.rs @@ -0,0 +1,134 @@ +use std::sync::Arc; + +use axum::Extension; +use axum::Json; +use axum::Router; +use axum::routing::get; +use rmcp::transport::streamable_http_server::StreamableHttpServerConfig; +use rmcp::transport::streamable_http_server::StreamableHttpService; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use rmcp::transport::streamable_http_server::session::store::SessionStore; +use serde::Serialize; +use tokio::net::TcpListener; +use tokio_util::sync::CancellationToken; +use tracing::info; + +use crate::audit::TracingAuditSink; +use crate::auth::AuthContext; +use crate::auth::AuthMode; +use crate::catalog::ExtensionCatalog; +use crate::config::Config; +use crate::config::SessionStoreConfig; +use crate::mcp::SupernodeMcpServer; +use crate::policy::Policy; +use crate::session::SqliteSessionStore; + +#[derive(Debug, Serialize)] +struct HealthResponse { + status: &'static str, +} + +pub async fn run(config: Config) -> anyhow::Result<()> { + let cancellation_token = CancellationToken::new(); + let app = router(config.clone(), cancellation_token.child_token())?; + let listener = TcpListener::bind(config.bind_addr).await?; + + info!( + bind_addr = %config.bind_addr, + auth_mode = ?config.auth_mode, + log_level = %config.log_level, + "starting supernode MCP server" + ); + + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal(cancellation_token)) + .await?; + + Ok(()) +} + +fn router(config: Config, cancellation_token: CancellationToken) -> anyhow::Result { + let auth_mode = config.auth_mode; + let auth_context = match auth_mode { + AuthMode::Trusted => AuthContext::trusted(), + AuthMode::OAuth => anyhow::bail!("MCP_AUTH_MODE=oauth is not implemented yet"), + }; + let session_store = session_store(&config.session_store)?; + + let mcp_state = SupernodeMcpServer::new( + auth_context.clone(), + Policy, + Arc::new(TracingAuditSink), + Arc::new(ExtensionCatalog::embedded()), + ); + let mut mcp_config = StreamableHttpServerConfig::default() + .with_cancellation_token(cancellation_token) + .with_allowed_hosts([ + "localhost", + "127.0.0.1", + "::1", + "supernode-mcp", + "supernode-mcp.control-plane", + "supernode-mcp.control-plane.svc", + "supernode-mcp.control-plane.svc.cluster.local", + ]); + mcp_config.session_store = session_store; + + let mcp_service = StreamableHttpService::new( + move || Ok(mcp_state.clone()), + Arc::new(LocalSessionManager::default()), + mcp_config, + ); + + Ok(Router::new() + .route("/healthz", get(healthz)) + .route("/readyz", get(readyz)) + .nest_service("/mcp", mcp_service) + .layer(Extension(auth_context))) +} + +fn session_store(config: &SessionStoreConfig) -> anyhow::Result>> { + match config { + SessionStoreConfig::Memory => Ok(None), + SessionStoreConfig::Sqlite { path, ttl_seconds } => { + info!(path = %path.display(), ?ttl_seconds, "using SQLite MCP session store"); + let store = SqliteSessionStore::new(path, *ttl_seconds) + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + Ok(Some(Arc::new(store))) + } + } +} + +async fn healthz() -> Json { + Json(HealthResponse { status: "ok" }) +} + +async fn readyz() -> Json { + Json(HealthResponse { status: "ok" }) +} + +async fn shutdown_signal(cancellation_token: CancellationToken) { + let ctrl_c = async { + tokio::signal::ctrl_c() + .await + .expect("failed to install Ctrl-C handler"); + }; + + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } + + cancellation_token.cancel(); +} diff --git a/mcp-server/src/session/mod.rs b/mcp-server/src/session/mod.rs new file mode 100644 index 0000000..015ccce --- /dev/null +++ b/mcp-server/src/session/mod.rs @@ -0,0 +1,3 @@ +pub mod sqlite; + +pub use sqlite::SqliteSessionStore; diff --git a/mcp-server/src/session/sqlite.rs b/mcp-server/src/session/sqlite.rs new file mode 100644 index 0000000..eba2341 --- /dev/null +++ b/mcp-server/src/session/sqlite.rs @@ -0,0 +1,219 @@ +use std::path::PathBuf; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use async_trait::async_trait; +use rmcp::transport::streamable_http_server::session::SessionState; +use rmcp::transport::streamable_http_server::session::SessionStore; +use rmcp::transport::streamable_http_server::session::SessionStoreError; +use rusqlite::Connection; +use rusqlite::OptionalExtension; +use rusqlite::params; + +#[derive(Debug, Clone)] +pub struct SqliteSessionStore { + path: PathBuf, + ttl_seconds: Option, +} + +impl SqliteSessionStore { + pub fn new( + path: impl Into, + ttl_seconds: Option, + ) -> Result { + let store = Self { + path: path.into(), + ttl_seconds, + }; + store.ensure_parent_dir()?; + store.with_connection(|connection| { + connection.pragma_update(None, "journal_mode", "WAL")?; + connection.pragma_update(None, "busy_timeout", 5000)?; + connection.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS mcp_sessions ( + session_id TEXT PRIMARY KEY, + state_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + expires_at INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_mcp_sessions_expires_at + ON mcp_sessions(expires_at); + "#, + )?; + Ok(()) + })?; + Ok(store) + } + + fn ensure_parent_dir(&self) -> Result<(), SessionStoreError> { + if let Some(parent) = self.path.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent).map_err(box_error)?; + } + Ok(()) + } + + fn with_connection( + &self, + action: impl FnOnce(&Connection) -> Result, + ) -> Result { + let connection = Connection::open(&self.path).map_err(box_error)?; + connection + .pragma_update(None, "busy_timeout", 5000) + .map_err(box_error)?; + action(&connection) + } + + fn expires_at(&self, now: i64) -> Option { + self.ttl_seconds.filter(|ttl| *ttl > 0).map(|ttl| now + ttl) + } +} + +#[async_trait] +impl SessionStore for SqliteSessionStore { + async fn load(&self, session_id: &str) -> Result, SessionStoreError> { + let now = unix_timestamp(); + self.with_connection(|connection| { + delete_expired(connection, now)?; + let state_json = connection + .query_row( + "SELECT state_json FROM mcp_sessions WHERE session_id = ?1 AND (expires_at IS NULL OR expires_at > ?2)", + params![session_id, now], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(box_error)?; + + state_json + .map(|value| serde_json::from_str(&value).map_err(box_error)) + .transpose() + }) + } + + async fn store(&self, session_id: &str, state: &SessionState) -> Result<(), SessionStoreError> { + let now = unix_timestamp(); + let expires_at = self.expires_at(now); + let state_json = serde_json::to_string(state).map_err(box_error)?; + + self.with_connection(|connection| { + connection + .execute( + r#" + INSERT INTO mcp_sessions (session_id, state_json, created_at, updated_at, expires_at) + VALUES (?1, ?2, ?3, ?3, ?4) + ON CONFLICT(session_id) DO UPDATE SET + state_json = excluded.state_json, + updated_at = excluded.updated_at, + expires_at = excluded.expires_at + "#, + params![session_id, state_json, now, expires_at], + ) + .map_err(box_error)?; + Ok(()) + }) + } + + async fn delete(&self, session_id: &str) -> Result<(), SessionStoreError> { + self.with_connection(|connection| { + connection + .execute( + "DELETE FROM mcp_sessions WHERE session_id = ?1", + params![session_id], + ) + .map_err(box_error)?; + Ok(()) + }) + } +} + +fn delete_expired(connection: &Connection, now: i64) -> Result<(), SessionStoreError> { + connection + .execute( + "DELETE FROM mcp_sessions WHERE expires_at IS NOT NULL AND expires_at <= ?1", + params![now], + ) + .map_err(box_error)?; + Ok(()) +} + +fn unix_timestamp() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +fn box_error(error: impl std::error::Error + Send + Sync + 'static) -> SessionStoreError { + Box::new(error) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use rmcp::model::ClientCapabilities; + use rmcp::model::Implementation; + use rmcp::model::InitializeRequestParams; + + use super::*; + + #[tokio::test] + async fn stores_loads_and_deletes_session_state() { + let path = temp_db_path("roundtrip"); + let store = SqliteSessionStore::new(&path, Some(60)).unwrap(); + let state = session_state(); + + store.store("session-1", &state).await.unwrap(); + let loaded = store.load("session-1").await.unwrap().unwrap(); + + assert_eq!(loaded.initialize_params.client_info.name, "test-client"); + + store.delete("session-1").await.unwrap(); + assert!(store.load("session-1").await.unwrap().is_none()); + cleanup_db(&path); + } + + #[tokio::test] + async fn session_state_survives_store_recreation() { + let path = temp_db_path("recreate"); + let state = session_state(); + + SqliteSessionStore::new(&path, Some(60)) + .unwrap() + .store("session-1", &state) + .await + .unwrap(); + let loaded = SqliteSessionStore::new(&path, Some(60)) + .unwrap() + .load("session-1") + .await + .unwrap(); + + assert!(loaded.is_some()); + cleanup_db(&path); + } + + fn session_state() -> SessionState { + SessionState::new(InitializeRequestParams::new( + ClientCapabilities::default(), + Implementation::new("test-client", "0"), + )) + } + + fn temp_db_path(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("supernode-mcp-{name}-{unique}.sqlite3")) + } + + fn cleanup_db(path: &Path) { + for suffix in ["", "-wal", "-shm"] { + let _ = std::fs::remove_file(format!("{}{}", path.display(), suffix)); + } + } +} diff --git a/mcp-server/src/tools/common.rs b/mcp-server/src/tools/common.rs new file mode 100644 index 0000000..2ff1eed --- /dev/null +++ b/mcp-server/src/tools/common.rs @@ -0,0 +1,53 @@ +use rmcp::model::CallToolResult; +use serde_json::Value; +use serde_json::json; + +use crate::k8s::PodExecError; +use crate::vault::VaultError; + +pub(crate) fn success(value: Value) -> CallToolResult { + CallToolResult::structured(value) +} + +pub(crate) fn tool_error(code: &str, message: impl Into, details: Value) -> CallToolResult { + CallToolResult::structured_error(json!({ + "error": code, + "message": message.into(), + "details": details, + })) +} + +pub(crate) fn kube_error(tool: &str, error: kube::Error) -> CallToolResult { + tool_error( + "kubernetes_error", + error.to_string(), + json!({ "tool": tool }), + ) +} + +pub(crate) fn pod_exec_error(tool: &str, error: PodExecError) -> CallToolResult { + let code = match &error { + PodExecError::Timeout { .. } => "pod_exec_timeout", + PodExecError::OutputTooLarge { .. } => "pod_exec_output_too_large", + PodExecError::CommandFailed { .. } => "pod_exec_command_failed", + PodExecError::Kubernetes(_) => "kubernetes_error", + PodExecError::MissingStream(_) + | PodExecError::Read { .. } + | PodExecError::RemoteCommand(_) => "pod_exec_error", + }; + + tool_error(code, error.to_string(), json!({ "tool": tool })) +} + +pub(crate) fn vault_error(tool: &str, error: VaultError) -> CallToolResult { + let code = match &error { + VaultError::Path(_) => "vault_path_not_allowed", + VaultError::MissingConfig(_) | VaultError::InvalidConfig(_) => "vault_not_configured", + VaultError::RootTokenRejected => "vault_root_token_rejected", + VaultError::InvalidWriteMode => "invalid_arguments", + VaultError::SecretValue(_) => "invalid_secret_values", + VaultError::Status(_) | VaultError::Http(_) | VaultError::TokenFile(_) => "vault_error", + }; + + tool_error(code, error.to_string(), json!({ "tool": tool })) +} diff --git a/mcp-server/src/tools/dynamic.rs b/mcp-server/src/tools/dynamic.rs new file mode 100644 index 0000000..31df13c --- /dev/null +++ b/mcp-server/src/tools/dynamic.rs @@ -0,0 +1,102 @@ +use std::collections::BTreeSet; +use std::sync::Arc; +use tokio::sync::RwLock; + +use crate::k8s::HelmReleaseDiscovery; +use crate::k8s::KubernetesClient; + +use super::ToolDefinition; +use super::workloads; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum DynamicToolError { + #[error("kubernetes error: {0}")] + Kubernetes(#[from] kube::Error), + #[error("helm release discovery error: {0}")] + HelmRelease(#[from] crate::k8s::HelmReleaseError), +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct DynamicToolState { + definitions: Arc>>>, +} + +impl DynamicToolState { + pub(crate) async fn definitions(&self) -> Arc> { + self.definitions.read().await.clone() + } + + pub(crate) async fn signature(&self) -> BTreeSet<&'static str> { + tool_signature(&self.definitions().await) + } + + pub(crate) async fn refresh(&self) -> bool { + let definitions = match discover_definitions().await { + Ok(definitions) => definitions, + Err(error) => { + tracing::debug!(%error, "failed to refresh dynamic MCP tools"); + Vec::new() + } + }; + + let mut current = self.definitions.write().await; + let changed = tool_signature(current.as_ref()) != tool_signature(&definitions); + if changed { + *current = Arc::new(definitions); + } + changed + } +} + +async fn discover_definitions() -> Result, DynamicToolError> { + let client = KubernetesClient::try_default().await?; + let releases = HelmReleaseDiscovery::new(client) + .list_latest(None, true) + .await?; + let installed_extension_ids = workloads::registry::installed_extension_ids(&releases); + + Ok(workloads::dynamic_definitions(&installed_extension_ids)) +} + +fn tool_signature(definitions: &[ToolDefinition]) -> BTreeSet<&'static str> { + definitions + .iter() + .map(|definition| definition.name) + .collect() +} + +#[cfg(test)] +mod tests { + use crate::k8s::HelmChartSummary; + use crate::k8s::HelmReleaseSummary; + + use super::*; + + #[test] + fn dynamic_workload_tools_follow_installed_extensions() { + let releases = vec![HelmReleaseSummary { + name: "dolos-preview".to_string(), + namespace: "cardano".to_string(), + revision: 1, + status: Some("deployed".to_string()), + chart: HelmChartSummary { + name: Some("dolos".to_string()), + version: Some("0.1.0".to_string()), + }, + app_version: None, + description: None, + updated: None, + secret_name: None, + config: None, + }]; + let installed_extension_ids = workloads::registry::installed_extension_ids(&releases); + + let definitions = workloads::dynamic_definitions(&installed_extension_ids); + + assert!( + definitions + .iter() + .any(|definition| definition.name == "dolos.snapshot.refresh") + ); + } +} diff --git a/mcp-server/src/tools/hydra.rs b/mcp-server/src/tools/hydra.rs new file mode 100644 index 0000000..c4d3c65 --- /dev/null +++ b/mcp-server/src/tools/hydra.rs @@ -0,0 +1,311 @@ +use ed25519_dalek::SigningKey; +use rmcp::model::{CallToolResult, JsonObject}; +use serde::Serialize; +use serde_json::json; +use sha2::{Digest, Sha256}; + +use crate::policy::{ApprovalClass, Scope}; +use crate::vault::{SecretObject, VaultClient, VaultPath, WriteMode}; + +use super::{ + ToolDefinition, + common::{success, tool_error, vault_error}, +}; + +const TOOL_NAME: &str = "hydra.keys.generate"; +const DEFAULT_SIGNING_KEY_NAME: &str = "hydra.sk"; +const DEFAULT_VERIFICATION_KEY_NAME: &str = "hydra.vk"; +const HYDRA_SIGNING_KEY_TYPE: &str = "HydraSigningKey_ed25519"; +const HYDRA_VERIFICATION_KEY_TYPE: &str = "HydraVerificationKey_ed25519"; + +pub fn definitions() -> &'static [ToolDefinition] { + &[ToolDefinition { + name: TOOL_NAME, + title: "Generate Hydra Keys", + description: "Generate Hydra off-chain signing and verification keys and save both text envelopes to runtime Vault. The private signing key is never returned.", + required_scope: Scope::VaultRuntimeWrite, + approval_class: ApprovalClass::RuntimeSecretWrite, + read_only: false, + destructive: false, + input_schema: r#"{"type":"object","required":["path","approvalId"],"properties":{"path":{"type":"string","pattern":"^runtime/","description":"Runtime Vault path without kv/data prefix."},"approvalId":{"type":"string"},"signingKeyName":{"type":"string","minLength":1,"default":"hydra.sk","description":"Vault field name for the Hydra signing key text envelope."},"verificationKeyName":{"type":"string","minLength":1,"default":"hydra.vk","description":"Vault field name for the Hydra verification key text envelope."},"overwrite":{"type":"boolean","default":false,"description":"Allow replacing existing signing or verification key fields at the target path."}},"additionalProperties":false}"#, + }] +} + +pub async fn generate_keys(arguments: Option<&JsonObject>) -> CallToolResult { + let path = match runtime_vault_path(arguments) { + Ok(path) => path, + Err(error) => return error, + }; + let signing_key_name = optional_string(arguments, "signingKeyName") + .unwrap_or_else(|| DEFAULT_SIGNING_KEY_NAME.to_string()); + let verification_key_name = optional_string(arguments, "verificationKeyName") + .unwrap_or_else(|| DEFAULT_VERIFICATION_KEY_NAME.to_string()); + let overwrite = optional_bool(arguments, "overwrite").unwrap_or(false); + + if signing_key_name == verification_key_name { + return tool_error( + "invalid_arguments", + "signingKeyName and verificationKeyName must be different", + json!({ "signingKeyName": signing_key_name, "verificationKeyName": verification_key_name }), + ); + } + + let client = match VaultClient::from_env() { + Ok(client) => client, + Err(error) => return vault_error(TOOL_NAME, error), + }; + + if !overwrite { + match client.runtime_metadata(&path).await { + Ok(metadata) => { + let conflicting_keys = metadata + .key_names + .iter() + .filter(|key| *key == &signing_key_name || *key == &verification_key_name) + .cloned() + .collect::>(); + if !conflicting_keys.is_empty() { + return tool_error( + "vault_secret_key_exists", + "target Vault path already contains one or more Hydra key fields; set overwrite=true to replace them", + json!({ + "path": path.as_str(), + "conflictingKeys": conflicting_keys, + }), + ); + } + } + Err(error) => return vault_error(TOOL_NAME, error), + } + } + + let key_pair = match generate_hydra_key_pair() { + Ok(key_pair) => key_pair, + Err(error) => { + return tool_error( + "hydra_key_generation_failed", + error.to_string(), + json!({ "tool": TOOL_NAME }), + ); + } + }; + let secret = match SecretObject::new(json!({ + signing_key_name.clone(): key_pair.signing_key, + verification_key_name.clone(): key_pair.verification_key, + })) { + Ok(secret) => secret, + Err(error) => { + return tool_error( + "invalid_secret_values", + error.to_string(), + json!({ "tool": TOOL_NAME }), + ); + } + }; + + match client + .write_runtime_secret(&path, &secret, WriteMode::Patch) + .await + { + Ok(receipt) => success(json!({ + "path": receipt.path, + "writtenKeys": receipt.written_keys, + "version": receipt.version, + "verificationKey": { + "filename": verification_key_name, + "value": key_pair.verification_key, + }, + "signingKey": { + "filename": signing_key_name, + "returned": false, + }, + "secretValuesReturned": false, + "signingKeyReturned": false, + })), + Err(error) => vault_error(TOOL_NAME, error), + } +} + +fn generate_hydra_key_pair() -> Result { + let mut entropy = [0u8; 16]; + getrandom::getrandom(&mut entropy)?; + Ok(hydra_key_pair_from_entropy(entropy)) +} + +fn hydra_key_pair_from_entropy(entropy: [u8; 16]) -> HydraKeyPair { + let signing_seed = Sha256::digest(entropy); + let signing_key = SigningKey::from_bytes(&signing_seed.into()); + hydra_key_pair_from_signing_key_bytes(signing_key.to_bytes()) +} + +fn hydra_key_pair_from_signing_key_bytes(signing_key_bytes: [u8; 32]) -> HydraKeyPair { + let signing_key = SigningKey::from_bytes(&signing_key_bytes); + let verification_key_bytes = signing_key.verifying_key().to_bytes(); + + HydraKeyPair { + signing_key: text_envelope(HYDRA_SIGNING_KEY_TYPE, &signing_key_bytes), + verification_key: text_envelope(HYDRA_VERIFICATION_KEY_TYPE, &verification_key_bytes), + } +} + +fn text_envelope(key_type: &str, raw_key: &[u8; 32]) -> String { + let envelope = TextEnvelope { + key_type, + description: "", + cbor_hex: format!("5820{}", hex_lower(raw_key)), + }; + + serde_json::to_string_pretty(&envelope).expect("serializing Hydra text envelope must not fail") +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +fn runtime_vault_path(arguments: Option<&JsonObject>) -> Result { + let path = required_string(arguments, "path")?; + VaultPath::runtime(&path).map_err(|error| { + tool_error( + "vault_path_not_allowed", + error.to_string(), + json!({ "path": path }), + ) + }) +} + +fn required_string(arguments: Option<&JsonObject>, name: &str) -> Result { + optional_string(arguments, name).ok_or_else(|| { + tool_error( + "invalid_arguments", + format!("missing required string argument: {name}"), + json!({ "argument": name }), + ) + }) +} + +fn optional_string(arguments: Option<&JsonObject>, name: &str) -> Option { + arguments? + .get(name)? + .as_str() + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) +} + +fn optional_bool(arguments: Option<&JsonObject>, name: &str) -> Option { + arguments?.get(name)?.as_bool() +} + +#[derive(Debug, Clone, Eq, PartialEq)] +struct HydraKeyPair { + signing_key: String, + verification_key: String, +} + +#[derive(Serialize)] +struct TextEnvelope<'a> { + #[serde(rename = "type")] + key_type: &'a str, + description: &'a str, + #[serde(rename = "cborHex")] + cbor_hex: String, +} + +#[cfg(test)] +mod tests { + use serde_json::Value; + + use super::*; + + const ALICE_SIGNING_KEY_CBOR_HEX: &str = + "5820e4c36e5403e6a02ef4821a34bb71d504916df0ddea476f797a5639110bc1bd52"; + const ALICE_VERIFICATION_KEY_CBOR_HEX: &str = + "5820b37aabd81024c043f53a069c91e51a5b52e4ea399ae17ee1fe3cb9c44db707eb"; + + #[test] + fn generated_key_pair_uses_hydra_text_envelopes() { + let pair = hydra_key_pair_from_entropy(*b"1234567890abcdef"); + let signing = parse_json(&pair.signing_key); + let verification = parse_json(&pair.verification_key); + + assert_eq!(signing.get("type"), Some(&json!(HYDRA_SIGNING_KEY_TYPE))); + assert_eq!( + verification.get("type"), + Some(&json!(HYDRA_VERIFICATION_KEY_TYPE)) + ); + assert_eq!( + signing + .get("cborHex") + .and_then(Value::as_str) + .unwrap() + .len(), + 68 + ); + assert!( + signing + .get("cborHex") + .and_then(Value::as_str) + .unwrap() + .starts_with("5820") + ); + assert_eq!( + verification + .get("cborHex") + .and_then(Value::as_str) + .unwrap() + .len(), + 68 + ); + assert!( + verification + .get("cborHex") + .and_then(Value::as_str) + .unwrap() + .starts_with("5820") + ); + } + + #[test] + fn derives_hydra_demo_verification_key_from_signing_key() { + let signing_key_bytes = raw_key_bytes(ALICE_SIGNING_KEY_CBOR_HEX); + + let pair = hydra_key_pair_from_signing_key_bytes(signing_key_bytes); + let verification = parse_json(&pair.verification_key); + + assert_eq!( + verification.get("cborHex"), + Some(&json!(ALICE_VERIFICATION_KEY_CBOR_HEX)) + ); + } + + #[test] + fn definitions_expose_runtime_secret_write_policy() { + let definition = definitions().first().unwrap(); + + assert_eq!(definition.name, "hydra.keys.generate"); + assert_eq!(definition.required_scope, Scope::VaultRuntimeWrite); + assert_eq!(definition.approval_class, ApprovalClass::RuntimeSecretWrite); + assert!(!definition.read_only); + assert!(!definition.destructive); + assert!(definition.input_schema.contains("overwrite")); + } + + fn parse_json(value: &str) -> Value { + serde_json::from_str(value).unwrap() + } + + fn raw_key_bytes(cbor_hex: &str) -> [u8; 32] { + assert_eq!(&cbor_hex[0..4], "5820"); + let hex = &cbor_hex[4..]; + let mut output = [0u8; 32]; + for (index, chunk) in hex.as_bytes().chunks(2).enumerate() { + output[index] = u8::from_str_radix(std::str::from_utf8(chunk).unwrap(), 16).unwrap(); + } + output + } +} diff --git a/mcp-server/src/tools/k8s_summaries.rs b/mcp-server/src/tools/k8s_summaries.rs new file mode 100644 index 0000000..dbcd066 --- /dev/null +++ b/mcp-server/src/tools/k8s_summaries.rs @@ -0,0 +1,261 @@ +use k8s_openapi::api::apps::v1::Deployment; +use k8s_openapi::api::apps::v1::StatefulSet; +use k8s_openapi::api::core::v1::Container; +use k8s_openapi::api::core::v1::ContainerState; +use k8s_openapi::api::core::v1::ContainerStatus; +use k8s_openapi::api::core::v1::Event; +use k8s_openapi::api::core::v1::ObjectReference; +use k8s_openapi::api::core::v1::Pod; +use k8s_openapi::api::core::v1::Service; +use k8s_openapi::api::storage::v1::StorageClass; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::ObjectList; +use serde_json::Value; +use serde_json::json; + +pub(crate) fn deployment_summaries( + deployments: ObjectList, + include_control_plane: bool, +) -> Vec { + deployments + .items + .iter() + .filter(|deployment| include_control_plane || !is_control_plane(&deployment.metadata)) + .map(deployment_summary) + .collect() +} + +pub(crate) fn deployment_summary(deployment: &Deployment) -> Value { + json!({ + "kind": "Deployment", + "metadata": metadata_summary(&deployment.metadata), + "replicas": deployment.spec.as_ref().and_then(|spec| spec.replicas), + "readyReplicas": deployment.status.as_ref().and_then(|status| status.ready_replicas), + "availableReplicas": deployment.status.as_ref().and_then(|status| status.available_replicas), + }) +} + +pub(crate) fn stateful_set_summaries( + stateful_sets: ObjectList, + include_control_plane: bool, +) -> Vec { + stateful_sets + .items + .iter() + .filter(|stateful_set| include_control_plane || !is_control_plane(&stateful_set.metadata)) + .map(stateful_set_summary) + .collect() +} + +pub(crate) fn stateful_set_summary(stateful_set: &StatefulSet) -> Value { + json!({ + "kind": "StatefulSet", + "metadata": metadata_summary(&stateful_set.metadata), + "replicas": stateful_set.spec.as_ref().and_then(|spec| spec.replicas), + "readyReplicas": stateful_set.status.as_ref().and_then(|status| status.ready_replicas), + "currentReplicas": stateful_set.status.as_ref().and_then(|status| status.current_replicas), + }) +} + +pub(crate) fn pod_summaries(pods: ObjectList, include_control_plane: bool) -> Vec { + pods.items + .iter() + .filter(|pod| include_control_plane || !is_control_plane(&pod.metadata)) + .map(pod_summary) + .collect() +} + +pub(crate) fn pod_summary(pod: &Pod) -> Value { + json!({ + "kind": "Pod", + "metadata": metadata_summary(&pod.metadata), + "phase": pod.status.as_ref().and_then(|status| status.phase.clone()), + "podIp": pod.status.as_ref().and_then(|status| status.pod_ip.clone()), + "nodeName": pod.spec.as_ref().and_then(|spec| spec.node_name.clone()), + "containers": pod.spec.as_ref().map(|spec| { + spec.containers + .iter() + .map(|container| container.name.clone()) + .collect::>() + }).unwrap_or_default(), + }) +} + +pub(crate) fn pod_log_target_summaries(pods: &[Pod]) -> Vec { + pods.iter().map(pod_log_target_summary).collect() +} + +fn pod_log_target_summary(pod: &Pod) -> Value { + let container_statuses = pod + .status + .as_ref() + .and_then(|status| status.container_statuses.as_deref()); + let init_container_statuses = pod + .status + .as_ref() + .and_then(|status| status.init_container_statuses.as_deref()); + + json!({ + "pod": pod.metadata.name, + "phase": pod.status.as_ref().and_then(|status| status.phase.clone()), + "deleting": pod.metadata.deletion_timestamp.is_some(), + "containers": pod.spec.as_ref().map(|spec| { + container_log_targets(&spec.containers, container_statuses) + }).unwrap_or_default(), + "initContainers": pod.spec.as_ref().and_then(|spec| spec.init_containers.as_ref()).map(|containers| { + container_log_targets(containers, init_container_statuses) + }).unwrap_or_default(), + }) +} + +fn container_log_targets( + containers: &[Container], + statuses: Option<&[ContainerStatus]>, +) -> Vec { + containers + .iter() + .map(|container| { + let status = statuses + .and_then(|statuses| statuses.iter().find(|status| status.name == container.name)); + json!({ + "name": container.name, + "ready": status.map(|status| status.ready), + "restartCount": status.map(|status| status.restart_count), + "state": status.and_then(|status| container_state(status.state.as_ref())), + "lastState": status.and_then(|status| container_state(status.last_state.as_ref())), + }) + }) + .collect() +} + +fn container_state(state: Option<&ContainerState>) -> Option<&'static str> { + let state = state?; + if state.running.is_some() { + Some("running") + } else if state.waiting.is_some() { + Some("waiting") + } else if state.terminated.is_some() { + Some("terminated") + } else { + None + } +} + +pub(crate) fn service_summaries( + services: ObjectList, + include_control_plane: bool, +) -> Vec { + services + .items + .iter() + .filter(|service| include_control_plane || !is_control_plane(&service.metadata)) + .map(service_summary) + .collect() +} + +pub(crate) fn service_summary(service: &Service) -> Value { + json!({ + "kind": "Service", + "metadata": metadata_summary(&service.metadata), + "type": service.spec.as_ref().and_then(|spec| spec.type_.clone()), + "clusterIp": service.spec.as_ref().and_then(|spec| spec.cluster_ip.clone()), + "loadBalancerIngress": load_balancer_ingress_summary(service), + "ports": service.spec.as_ref().map(|spec| { + spec.ports.clone().unwrap_or_default().into_iter().map(|port| json!({ + "name": port.name, + "port": port.port, + "protocol": port.protocol, + })).collect::>() + }).unwrap_or_default(), + }) +} + +pub(crate) fn storage_class_summary(storage_class: &StorageClass) -> Value { + json!({ + "metadata": metadata_summary(&storage_class.metadata), + "provisioner": storage_class.provisioner, + "reclaimPolicy": storage_class.reclaim_policy, + "volumeBindingMode": storage_class.volume_binding_mode, + "allowVolumeExpansion": storage_class.allow_volume_expansion, + "isDefault": is_default_storage_class(storage_class), + }) +} + +pub(crate) fn event_summary(event: &Event) -> Value { + json!({ + "metadata": metadata_summary(&event.metadata), + "type": event.type_, + "reason": event.reason, + "message": event.message, + "count": event.count, + "involvedObject": object_reference_summary(&event.involved_object), + "firstTimestamp": event.first_timestamp, + "lastTimestamp": event.last_timestamp, + }) +} + +fn load_balancer_ingress_summary(service: &Service) -> Vec { + service + .status + .as_ref() + .and_then(|status| status.load_balancer.as_ref()) + .and_then(|load_balancer| load_balancer.ingress.as_ref()) + .map(|ingress| { + ingress + .iter() + .map(|entry| { + json!({ + "ip": entry.ip, + "hostname": entry.hostname, + }) + }) + .collect() + }) + .unwrap_or_default() +} + +fn is_default_storage_class(storage_class: &StorageClass) -> bool { + storage_class + .metadata + .annotations + .as_ref() + .is_some_and(|annotations| { + annotations + .get("storageclass.kubernetes.io/is-default-class") + .is_some_and(|value| value == "true") + || annotations + .get("storageclass.beta.kubernetes.io/is-default-class") + .is_some_and(|value| value == "true") + }) +} + +fn metadata_summary(metadata: &ObjectMeta) -> Value { + json!({ + "name": metadata.name, + "namespace": metadata.namespace, + "labels": metadata.labels, + "creationTimestamp": metadata.creation_timestamp, + }) +} + +fn object_reference_summary(reference: &ObjectReference) -> Value { + json!({ + "kind": reference.kind, + "namespace": reference.namespace, + "name": reference.name, + "uid": reference.uid, + }) +} + +fn is_control_plane(metadata: &ObjectMeta) -> bool { + metadata.name.as_deref() == Some("control-plane") + || metadata.namespace.as_deref() == Some("control-plane") + || metadata.labels.as_ref().is_some_and(|labels| { + labels + .get("app.kubernetes.io/name") + .is_some_and(|value| value == "control-plane") + || labels + .get("app.kubernetes.io/instance") + .is_some_and(|value| value == "control-plane") + }) +} diff --git a/mcp-server/src/tools/mod.rs b/mcp-server/src/tools/mod.rs new file mode 100644 index 0000000..375d450 --- /dev/null +++ b/mcp-server/src/tools/mod.rs @@ -0,0 +1,24 @@ +pub(crate) mod common; +pub(crate) mod dynamic; +pub(crate) mod hydra; +pub(crate) mod k8s_summaries; +pub mod router; +pub mod supernode; +pub mod vault; +pub mod workloads; + +pub use router::ToolRouter; + +use crate::policy::{ApprovalClass, Scope}; + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct ToolDefinition { + pub name: &'static str, + pub title: &'static str, + pub description: &'static str, + pub required_scope: Scope, + pub approval_class: ApprovalClass, + pub read_only: bool, + pub destructive: bool, + pub input_schema: &'static str, +} diff --git a/mcp-server/src/tools/router.rs b/mcp-server/src/tools/router.rs new file mode 100644 index 0000000..4991c59 --- /dev/null +++ b/mcp-server/src/tools/router.rs @@ -0,0 +1,1393 @@ +use std::sync::Arc; + +use rmcp::model::{CallToolResult, JsonObject, ListToolsResult, Meta, Tool, ToolAnnotations}; +use serde_json::{Value, json}; + +use crate::{ + catalog::ExtensionCatalog, + helm::{self, HelmChartRef, HelmInstallPlan}, + k8s::{HelmReleaseDiscovery, KubernetesClient, ResourceListParams}, + vault::{SecretObject, VaultClient, VaultError, VaultPath, WriteMode}, +}; + +use super::{ + ToolDefinition, common::kube_error, common::pod_exec_error, common::success, + common::tool_error, common::vault_error, hydra, k8s_summaries, supernode, vault, workloads, + workloads::install, workloads::logs, workloads::metrics, workloads::outputs, +}; + +#[derive(Debug, Clone)] +pub struct ToolRouter { + definitions: Arc>, +} + +impl ToolRouter { + pub fn new() -> Self { + let definitions = supernode::definitions() + .iter() + .chain(workloads::definitions()) + .chain(vault::definitions()) + .chain(hydra::definitions()) + .copied() + .collect(); + + Self { + definitions: Arc::new(definitions), + } + } + + pub fn list_with_dynamic(&self, dynamic_definitions: &[ToolDefinition]) -> ListToolsResult { + ListToolsResult::with_all_items( + self.definitions + .iter() + .chain(dynamic_definitions.iter()) + .map(|definition| tool_from_definition(*definition)) + .collect(), + ) + } + + pub fn get_with_dynamic( + &self, + name: &str, + dynamic_definitions: &[ToolDefinition], + ) -> Option { + self.definitions + .iter() + .chain(dynamic_definitions.iter()) + .find(|definition| definition.name == name) + .copied() + } + + pub fn not_implemented_result(&self, definition: ToolDefinition) -> CallToolResult { + CallToolResult::structured_error(json!({ + "error": "not_implemented", + "tool": definition.name, + "message": "Tool execution is not implemented in this incremental step.", + })) + } + + pub async fn call( + &self, + definition: ToolDefinition, + arguments: Option<&JsonObject>, + catalog: &ExtensionCatalog, + ) -> CallToolResult { + match definition.name { + "supernode.status.get" => supernode_status(catalog).await, + "cluster.storage_classes.list" => storage_classes_list(arguments).await, + "cluster.events.list" => events_list(arguments).await, + "extensions.catalog.list" => catalog_list(catalog), + "extensions.catalog.get" => catalog_get(arguments, catalog), + "vault.runtime.metadata.get" => vault_runtime_metadata_get(arguments).await, + "vault.runtime.write" => vault_runtime_write(arguments, WriteMode::Replace).await, + "vault.runtime.patch" => vault_runtime_write(arguments, WriteMode::Patch).await, + "hydra.keys.generate" => hydra::generate_keys(arguments).await, + "workloads.list" => workloads_list(arguments, catalog).await, + "workloads.get" => workloads_get(arguments, catalog).await, + "workloads.logs.get" => logs::get(arguments).await, + "workloads.metrics.get" => workloads_metrics_get(arguments, catalog).await, + "workloads.install" => workloads_install(arguments, catalog).await, + "workloads.upgrade" => workloads::upgrade::upgrade(arguments, catalog).await, + "workloads.delete" => workloads::delete::delete(arguments, catalog).await, + "dolos.snapshot.refresh" => workloads::dolos::snapshot_refresh(arguments).await, + _ => self.not_implemented_result(definition), + } + } +} + +async fn vault_runtime_metadata_get(arguments: Option<&JsonObject>) -> CallToolResult { + let path = match runtime_vault_path(arguments) { + Ok(path) => path, + Err(error) => return error, + }; + let client = match VaultClient::from_env() { + Ok(client) => client, + Err(error) => return vault_error("vault.runtime.metadata.get", error), + }; + + match client.runtime_metadata(&path).await { + Ok(metadata) => success(json!({ + "path": metadata.path, + "exists": metadata.exists, + "keyNames": metadata.key_names, + "keyNamesAvailable": metadata.key_names_available, + "currentVersion": metadata.current_version, + })), + Err(error) => vault_error("vault.runtime.metadata.get", error), + } +} + +async fn vault_runtime_write( + arguments: Option<&JsonObject>, + default_mode: WriteMode, +) -> CallToolResult { + let path = match runtime_vault_path(arguments) { + Ok(path) => path, + Err(error) => return error, + }; + let secret = match secret_argument(arguments) { + Ok(secret) => secret, + Err(error) => return error, + }; + let mode = match write_mode(arguments, default_mode) { + Ok(mode) => mode, + Err(error) => return vault_error("vault.runtime.write", error), + }; + let client = match VaultClient::from_env() { + Ok(client) => client, + Err(error) => return vault_error("vault.runtime.write", error), + }; + + match client.write_runtime_secret(&path, &secret, mode).await { + Ok(receipt) => success(json!({ + "path": receipt.path, + "writtenKeys": receipt.written_keys, + "version": receipt.version, + "secretValuesReturned": false, + })), + Err(error) => vault_error("vault.runtime.write", error), + } +} + +async fn supernode_status(catalog: &ExtensionCatalog) -> CallToolResult { + let kubernetes = match KubernetesClient::try_default().await { + Ok(client) => match client + .list_namespaces(&ResourceListParams { + limit: Some(1), + ..Default::default() + }) + .await + { + Ok(namespaces) => json!({ + "connected": true, + "namespaceSampleCount": namespaces.items.len(), + }), + Err(error) => json!({ + "connected": false, + "error": error.to_string(), + }), + }, + Err(error) => json!({ + "connected": false, + "error": error.to_string(), + }), + }; + + success(json!({ + "status": "ok", + "catalogExtensionCount": catalog.len(), + "kubernetes": kubernetes, + })) +} + +async fn storage_classes_list(arguments: Option<&JsonObject>) -> CallToolResult { + let params = list_params(arguments, Some(100)); + let client = match KubernetesClient::try_default().await { + Ok(client) => client, + Err(error) => return kube_error("cluster.storage_classes.list", error), + }; + + match client.list_storage_classes(¶ms).await { + Ok(storage_classes) => success(json!({ + "storageClasses": storage_classes.items.iter().map(k8s_summaries::storage_class_summary).collect::>(), + })), + Err(error) => kube_error("cluster.storage_classes.list", error), + } +} + +async fn events_list(arguments: Option<&JsonObject>) -> CallToolResult { + let params = list_params(arguments, Some(100)); + let namespace = optional_string(arguments, "namespace"); + let client = match KubernetesClient::try_default().await { + Ok(client) => client, + Err(error) => return kube_error("cluster.events.list", error), + }; + + match client.list_events(namespace.as_deref(), ¶ms).await { + Ok(events) => success(json!({ + "namespace": namespace, + "events": events.items.iter().map(k8s_summaries::event_summary).collect::>(), + })), + Err(error) => kube_error("cluster.events.list", error), + } +} + +fn catalog_list(catalog: &ExtensionCatalog) -> CallToolResult { + success(json!({ + "extensions": catalog.list().collect::>(), + })) +} + +fn catalog_get(arguments: Option<&JsonObject>, catalog: &ExtensionCatalog) -> CallToolResult { + let extension_id = match required_string(arguments, "extensionId") { + Ok(value) => value, + Err(error) => return error, + }; + + match catalog.get(&extension_id) { + Some(extension) => success(json!({ "extension": extension })), + None => tool_error( + "not_found", + format!("extension not found: {extension_id}"), + json!({ "extensionId": extension_id }), + ), + } +} + +async fn workloads_install( + arguments: Option<&JsonObject>, + catalog: &ExtensionCatalog, +) -> CallToolResult { + let extension_id = match required_string(arguments, "extensionId") { + Ok(value) => value, + Err(error) => return error, + }; + let release_name = match required_string(arguments, "releaseName") { + Ok(value) => value, + Err(error) => return error, + }; + let namespace = match required_string(arguments, "namespace") { + Ok(value) => value, + Err(error) => return error, + }; + let dry_run = optional_bool(arguments, "dryRun").unwrap_or(true); + + let configuration = match required_object(arguments, "configuration") { + Ok(value) => value, + Err(error) => return error, + }; + let extension = match catalog.get(&extension_id) { + Some(extension) => extension, + None => { + return tool_error( + "unknown_extension", + format!("extension not found: {extension_id}"), + json!({ "extensionId": extension_id }), + ); + } + }; + + if let Err(error) = validate_configuration_schema(&configuration, &extension.configuration) { + return error; + } + + if configuration.get("namespace").and_then(Value::as_str) != Some(namespace.as_str()) { + return tool_error( + "invalid_arguments", + "configuration.namespace must match namespace", + json!({ "namespace": namespace, "configurationNamespace": configuration.get("namespace") }), + ); + } + + let resolved_configuration = install::apply_defaults(extension, Value::Object(configuration)); + let resolution = match install::resolve_configuration( + extension, + &namespace, + resolved_configuration, + dry_run, + ) + .await + { + Ok(resolution) => resolution, + Err(error) => return error, + }; + let helm_values = + install::planned_helm_values(extension, &release_name, &resolution.configuration); + let chart = HelmChartRef { + chart: extension.chart.clone(), + version: extension.default_version.clone(), + }; + + if dry_run { + return success(json!({ + "action": "install", + "dryRun": true, + "wouldMutate": false, + "release": { + "name": release_name, + "namespace": namespace, + }, + "extension": { + "id": extension.id, + "name": extension.name, + "version": extension.default_version, + }, + "chart": chart, + "resolvedConfiguration": resolution.configuration, + "helmValues": helm_values, + "availableStorageClasses": resolution.available_storage_classes, + "recommendedStorageClasses": resolution.recommended_storage_classes, + "notes": [ + "dry-run planning only; no Kubernetes or Helm mutation was performed", + "raw Helm values are rejected by the extension configuration schema" + ], + })); + } + + let plan = HelmInstallPlan { + release_name: release_name.clone(), + namespace: namespace.clone(), + chart: chart.clone(), + values: helm_values.clone(), + }; + let helm_result = match helm::install(&plan).await { + Ok(result) => result, + Err(error) => { + let helm_details = match &error { + helm::HelmInstallError::Failed { + status, + stdout, + stderr, + } => json!({ + "tool": "workloads.install", + "extensionId": extension.id, + "releaseName": release_name, + "namespace": namespace, + "status": status, + "stdout": stdout, + "stderr": stderr, + }), + _ => json!({ + "tool": "workloads.install", + "extensionId": extension.id, + "releaseName": release_name, + "namespace": namespace, + }), + }; + return tool_error("helm_install_failed", error.to_string(), helm_details); + } + }; + + success(json!({ + "action": "install", + "dryRun": false, + "wouldMutate": true, + "release": { + "name": release_name, + "namespace": namespace, + }, + "extension": { + "id": extension.id, + "name": extension.name, + "version": extension.default_version, + }, + "chart": chart, + "resolvedConfiguration": resolution.configuration, + "helmValues": helm_values, + "availableStorageClasses": resolution.available_storage_classes, + "recommendedStorageClasses": resolution.recommended_storage_classes, + "helm": helm_result, + "notes": [ + "Helm upgrade --install completed successfully", + "raw Helm values are rejected by the extension configuration schema" + ], + })) +} + +async fn workloads_list( + arguments: Option<&JsonObject>, + catalog: &ExtensionCatalog, +) -> CallToolResult { + let namespace = optional_string(arguments, "namespace"); + let include_control_plane = optional_bool(arguments, "includeControlPlane").unwrap_or(false); + let params = list_params(arguments, Some(200)); + let client = match KubernetesClient::try_default().await { + Ok(client) => client, + Err(error) => return kube_error("workloads.list", error), + }; + let helm_releases = match HelmReleaseDiscovery::new(client.clone()) + .list_latest(namespace.as_deref(), include_control_plane) + .await + { + Ok(releases) => releases, + Err(error) => { + return tool_error( + "helm_release_discovery_error", + error.to_string(), + json!({ "tool": "workloads.list" }), + ); + } + }; + + let deployments = match client.list_deployments(namespace.as_deref(), ¶ms).await { + Ok(items) => k8s_summaries::deployment_summaries(items, include_control_plane), + Err(error) => return kube_error("workloads.list", error), + }; + let stateful_sets = match client + .list_stateful_sets(namespace.as_deref(), ¶ms) + .await + { + Ok(items) => k8s_summaries::stateful_set_summaries(items, include_control_plane), + Err(error) => return kube_error("workloads.list", error), + }; + let pods = match client.list_pods(namespace.as_deref(), ¶ms).await { + Ok(items) => k8s_summaries::pod_summaries(items, include_control_plane), + Err(error) => return kube_error("workloads.list", error), + }; + let services = match client.list_services(namespace.as_deref(), ¶ms).await { + Ok(items) => items, + Err(error) => return kube_error("workloads.list", error), + }; + let workload_outputs = helm_releases + .iter() + .map(|release| { + json!({ + "namespace": release.namespace, + "name": release.name, + "outputs": outputs::outputs_for_release( + &release.namespace, + &release.name, + Some(release), + &services.items, + catalog, + ), + }) + }) + .filter(|entry| { + entry + .pointer("/outputs") + .and_then(Value::as_array) + .is_some_and(|outputs| !outputs.is_empty()) + }) + .collect::>(); + + success(json!({ + "namespace": namespace, + "source": "kubernetes-api+helm-secrets", + "helmReleases": helm_releases, + "deployments": deployments, + "statefulSets": stateful_sets, + "pods": pods, + "services": k8s_summaries::service_summaries(services, include_control_plane), + "workloadOutputs": workload_outputs, + })) +} + +async fn workloads_get( + arguments: Option<&JsonObject>, + catalog: &ExtensionCatalog, +) -> CallToolResult { + let namespace = match required_string(arguments, "namespace") { + Ok(value) => value, + Err(error) => return error, + }; + let name = match required_string(arguments, "name") { + Ok(value) => value, + Err(error) => return error, + }; + let client = match KubernetesClient::try_default().await { + Ok(client) => client, + Err(error) => return kube_error("workloads.get", error), + }; + let helm_release = match HelmReleaseDiscovery::new(client.clone()) + .get_latest(&namespace, &name) + .await + { + Ok(release) => release, + Err(error) => { + return tool_error( + "helm_release_discovery_error", + error.to_string(), + json!({ "tool": "workloads.get", "namespace": namespace, "name": name }), + ); + } + }; + + let deployment = match get_optional(client.get_deployment(&namespace, &name).await) { + Ok(value) => value.map(|deployment| k8s_summaries::deployment_summary(&deployment)), + Err(error) => return kube_error("workloads.get", error), + }; + let stateful_set = match get_optional(client.get_stateful_set(&namespace, &name).await) { + Ok(value) => value.map(|stateful_set| k8s_summaries::stateful_set_summary(&stateful_set)), + Err(error) => return kube_error("workloads.get", error), + }; + let pod = match get_optional(client.get_pod(&namespace, &name).await) { + Ok(value) => value.map(|pod| k8s_summaries::pod_summary(&pod)), + Err(error) => return kube_error("workloads.get", error), + }; + let services = match client + .list_services( + Some(&namespace), + &ResourceListParams { + label_selector: Some(format!("app.kubernetes.io/instance={name}")), + ..Default::default() + }, + ) + .await + { + Ok(items) => items, + Err(error) => return kube_error("workloads.get", error), + }; + let pod_items = match client + .list_pods( + Some(&namespace), + &ResourceListParams { + label_selector: Some(format!("app.kubernetes.io/instance={name}")), + ..Default::default() + }, + ) + .await + { + Ok(items) => items.items, + Err(error) => return kube_error("workloads.get", error), + }; + let pods = pod_items + .iter() + .map(k8s_summaries::pod_summary) + .collect::>(); + + if deployment.is_none() + && stateful_set.is_none() + && pod.is_none() + && helm_release.is_none() + && pods.is_empty() + && services.items.is_empty() + { + return tool_error( + "not_found", + format!("workload not found: {namespace}/{name}"), + json!({ "namespace": namespace, "name": name }), + ); + } + + success(json!({ + "namespace": namespace, + "name": name, + "source": "kubernetes-api+helm-secrets", + "helmRelease": helm_release, + "deployment": deployment, + "statefulSet": stateful_set, + "pod": pod, + "relatedPods": pods, + "logTargets": k8s_summaries::pod_log_target_summaries(&pod_items), + "relatedServices": k8s_summaries::service_summaries(services.clone(), true), + "outputs": outputs::outputs_for_release( + &namespace, + &name, + helm_release.as_ref(), + &services.items, + catalog, + ), + })) +} + +async fn workloads_metrics_get( + arguments: Option<&JsonObject>, + catalog: &ExtensionCatalog, +) -> CallToolResult { + let namespace = match required_string(arguments, "namespace") { + Ok(value) => value, + Err(error) => return error, + }; + let workload = match required_string(arguments, "workload") { + Ok(value) => value, + Err(error) => return error, + }; + let client = match KubernetesClient::try_default().await { + Ok(client) => client, + Err(error) => return kube_error("workloads.metrics.get", error), + }; + let helm_release = match HelmReleaseDiscovery::new(client.clone()) + .get_latest(&namespace, &workload) + .await + { + Ok(Some(release)) => release, + Ok(None) => { + return tool_error( + "not_found", + format!("workload not found: {namespace}/{workload}"), + json!({ "namespace": namespace, "workload": workload }), + ); + } + Err(error) => { + return tool_error( + "helm_release_discovery_error", + error.to_string(), + json!({ "tool": "workloads.metrics.get", "namespace": namespace, "workload": workload }), + ); + } + }; + let target = match metrics::target_for_release(&helm_release, catalog) { + Some(target) => target, + None => { + return tool_error( + "unsupported_metrics_workload", + "metrics are only supported for catalog-managed Cardano node relay, Dolos, and Hydra node workloads", + json!({ + "namespace": namespace, + "workload": workload, + "chart": helm_release.chart, + }), + ); + } + }; + let pod_name = match find_workload_pod(&client, &namespace, &workload).await { + Ok(Some(pod_name)) => pod_name, + Ok(None) => { + return tool_error( + "not_found", + format!("no pod found for workload: {namespace}/{workload}"), + json!({ "namespace": namespace, "workload": workload }), + ); + } + Err(error) => return kube_error("workloads.metrics.get", error), + }; + let output = match client + .pod_exec_capture( + &namespace, + &pod_name, + target.container, + &[metrics::SCRIPT_PATH], + ) + .await + { + Ok(output) => output, + Err(error) => return pod_exec_error("workloads.metrics.get", error), + }; + let metrics = match serde_json::from_str::(output.stdout.trim()) { + Ok(metrics) => metrics, + Err(error) => { + return tool_error( + "invalid_metrics_payload", + format!("metrics script did not return valid JSON: {error}"), + json!({ + "namespace": namespace, + "workload": workload, + "pod": pod_name, + "container": target.container, + }), + ); + } + }; + + success(json!({ + "namespace": namespace, + "workload": workload, + "pod": pod_name, + "container": target.container, + "source": format!("pod-exec:{}", metrics::SCRIPT_PATH), + "extension": { + "id": target.extension.id, + "name": target.extension.name, + "version": target.extension.default_version, + }, + "helmRelease": helm_release, + "metrics": metrics, + "metricsSchema": target.extension.metrics, + "stderr": if output.stderr.trim().is_empty() { Value::Null } else { Value::String(output.stderr) }, + })) +} + +fn tool_from_definition(definition: ToolDefinition) -> Tool { + Tool::new( + definition.name, + definition.description, + input_schema(definition.input_schema), + ) + .with_title(definition.title) + .with_annotations( + ToolAnnotations::with_title(definition.title) + .read_only(definition.read_only) + .destructive(definition.destructive) + .idempotent(definition.read_only) + .open_world(false), + ) + .with_meta(tool_meta(definition)) +} + +fn input_schema(schema: &str) -> JsonObject { + match serde_json::from_str::(schema).expect("tool input schema must be valid JSON") { + Value::Object(object) => object, + _ => panic!("tool input schema must be a JSON object"), + } +} + +fn tool_meta(definition: ToolDefinition) -> Meta { + let mut meta = JsonObject::new(); + meta.insert( + "requiredScope".to_string(), + serde_json::to_value(definition.required_scope) + .expect("serializing required scope must not fail"), + ); + meta.insert( + "approvalClass".to_string(), + serde_json::to_value(definition.approval_class) + .expect("serializing approval class must not fail"), + ); + meta.insert( + "approvalRequired".to_string(), + Value::Bool(definition.approval_class.requires_approval()), + ); + Meta(meta) +} + +async fn find_workload_pod( + client: &KubernetesClient, + namespace: &str, + workload: &str, +) -> Result, kube::Error> { + let mut pods = client + .list_pods( + Some(namespace), + &ResourceListParams { + label_selector: Some(format!("app.kubernetes.io/instance={workload}")), + ..Default::default() + }, + ) + .await? + .items; + + if pods.is_empty() + && let Some(pod) = get_optional(client.get_pod(namespace, workload).await)? + { + pods.push(pod); + } + + pods.sort_by(|left, right| left.metadata.name.cmp(&right.metadata.name)); + + let running_pod = pods + .iter() + .find(|pod| { + pod.status + .as_ref() + .and_then(|status| status.phase.as_deref()) + == Some("Running") + }) + .and_then(|pod| pod.metadata.name.clone()); + + if running_pod.is_some() { + return Ok(running_pod); + } + + Ok(pods + .iter() + .find(|pod| pod.metadata.deletion_timestamp.is_none()) + .and_then(|pod| pod.metadata.name.clone())) +} + +fn get_optional(result: Result) -> Result, kube::Error> { + match result { + Ok(value) => Ok(Some(value)), + Err(kube::Error::Api(error)) if error.code == 404 => Ok(None), + Err(error) => Err(error), + } +} + +fn list_params(arguments: Option<&JsonObject>, default_limit: Option) -> ResourceListParams { + ResourceListParams { + label_selector: optional_string(arguments, "labelSelector"), + field_selector: optional_string(arguments, "fieldSelector"), + limit: optional_u32(arguments, "limit").or(default_limit), + } +} + +fn required_object( + arguments: Option<&JsonObject>, + name: &str, +) -> Result { + match arguments.and_then(|arguments| arguments.get(name)) { + Some(Value::Object(value)) => Ok(value.clone()), + Some(value) => Err(tool_error( + "invalid_arguments", + format!("expected object argument: {name}"), + json!({ "argument": name, "actualType": value_type_name(value) }), + )), + None => Err(tool_error( + "invalid_arguments", + format!("missing required object argument: {name}"), + json!({ "argument": name }), + )), + } +} + +fn required_string(arguments: Option<&JsonObject>, name: &str) -> Result { + optional_string(arguments, name).ok_or_else(|| { + tool_error( + "invalid_arguments", + format!("missing required string argument: {name}"), + json!({ "argument": name }), + ) + }) +} + +fn validate_configuration_schema( + values: &JsonObject, + schema: &Value, +) -> Result<(), CallToolResult> { + let schema = schema.as_object().ok_or_else(|| { + tool_error( + "catalog_schema_error", + "extension configuration schema must be an object schema", + json!({}), + ) + })?; + let properties = schema + .get("properties") + .and_then(Value::as_object) + .ok_or_else(|| { + tool_error( + "catalog_schema_error", + "extension configuration schema must define properties", + json!({}), + ) + })?; + + if schema.get("additionalProperties").and_then(Value::as_bool) == Some(false) { + for key in values.keys() { + if !properties.contains_key(key) { + return Err(tool_error( + "invalid_extension_configuration", + format!("unknown extension configuration value: {key}"), + json!({ "field": key }), + )); + } + } + } + + if let Some(required) = schema.get("required").and_then(Value::as_array) { + for field in required.iter().filter_map(Value::as_str) { + if !values.contains_key(field) { + return Err(tool_error( + "invalid_extension_configuration", + format!("missing required extension configuration value: {field}"), + json!({ "field": field }), + )); + } + } + } + + for (key, value) in values { + if let Some(property_schema) = properties.get(key) { + validate_property_value(key, value, property_schema)?; + } + } + + Ok(()) +} + +fn validate_property_value( + name: &str, + value: &Value, + schema: &Value, +) -> Result<(), CallToolResult> { + if let Some(expected_type) = schema.get("type").and_then(Value::as_str) { + let matches = match expected_type { + "boolean" => value.is_boolean(), + "integer" => value.as_i64().is_some(), + "number" => value.as_f64().is_some(), + "object" => value.is_object(), + "string" => value.is_string(), + _ => true, + }; + + if !matches { + return Err(tool_error( + "invalid_extension_configuration", + format!("invalid type for extension configuration value: {name}"), + json!({ + "field": name, + "expectedType": expected_type, + "actualType": value_type_name(value), + }), + )); + } + } + + if let Some(allowed_values) = schema.get("enum").and_then(Value::as_array) + && !allowed_values.iter().any(|allowed| allowed == value) + { + return Err(tool_error( + "invalid_extension_configuration", + format!("unsupported value for extension configuration field: {name}"), + json!({ + "field": name, + "allowedValues": allowed_values, + "actualValue": value, + }), + )); + } + + Ok(()) +} + +fn value_type_name(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +fn runtime_vault_path(arguments: Option<&JsonObject>) -> Result { + let path = required_string(arguments, "path")?; + VaultPath::runtime(&path).map_err(|error| { + tool_error( + "vault_path_not_allowed", + error.to_string(), + json!({ "path": path }), + ) + }) +} + +fn secret_argument(arguments: Option<&JsonObject>) -> Result { + let value = arguments + .and_then(|arguments| arguments.get("values").or_else(|| arguments.get("data"))) + .cloned() + .ok_or_else(|| { + tool_error( + "invalid_arguments", + "missing required secret object argument: values", + json!({ "argument": "values" }), + ) + })?; + + SecretObject::new(value).map_err(|error| { + tool_error( + "invalid_secret_values", + error.to_string(), + json!({ "argument": "values" }), + ) + }) +} + +fn write_mode( + arguments: Option<&JsonObject>, + default_mode: WriteMode, +) -> Result { + match optional_string(arguments, "mode") { + Some(mode) => WriteMode::parse(Some(&mode)), + None => Ok(default_mode), + } +} + +fn optional_string(arguments: Option<&JsonObject>, name: &str) -> Option { + arguments? + .get(name)? + .as_str() + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) +} + +fn optional_bool(arguments: Option<&JsonObject>, name: &str) -> Option { + arguments?.get(name)?.as_bool() +} + +fn optional_u32(arguments: Option<&JsonObject>, name: &str) -> Option { + arguments? + .get(name)? + .as_u64() + .and_then(|value| u32::try_from(value).ok()) +} + +impl Default for ToolRouter { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use crate::catalog::ExtensionCatalog; + + use super::*; + + #[test] + fn lists_mvp_tools_with_policy_metadata() { + let router = ToolRouter::new(); + + let tools = router.list_with_dynamic(&[]).tools; + + let install = tools + .iter() + .find(|tool| tool.name == "workloads.install") + .unwrap(); + assert_eq!( + install.meta.as_ref().unwrap().0.get("requiredScope"), + Some(&Value::String("workloads-install".to_string())) + ); + assert_eq!( + install.meta.as_ref().unwrap().0.get("approvalClass"), + Some(&Value::String("mutation".to_string())) + ); + } + + #[test] + fn does_not_include_extension_specific_install_tools() { + let router = ToolRouter::new(); + + let tools = router.list_with_dynamic(&[]).tools; + + assert!( + !tools + .iter() + .any(|tool| tool.name == "cardano.relay.install") + ); + assert!( + !tools + .iter() + .any(|tool| tool.name == "cardano.producer.verify") + ); + assert!(!tools.iter().any(|tool| tool.name == "dolos.deploy")); + assert!(tools.iter().any(|tool| tool.name == "workloads.install")); + } + + #[test] + fn can_lookup_tool_definition() { + let router = ToolRouter::new(); + + let definition = router.get_with_dynamic("workloads.delete", &[]).unwrap(); + + assert!(definition.destructive); + } + + #[test] + fn workloads_logs_schema_exposes_pod_and_container_selection() { + let router = ToolRouter::new(); + let definition = router.get_with_dynamic("workloads.logs.get", &[]).unwrap(); + + assert!(definition.input_schema.contains("pod")); + assert!(definition.input_schema.contains("container")); + assert!(definition.input_schema.contains("previous")); + assert!(definition.input_schema.contains("sinceSeconds")); + assert!(definition.input_schema.contains("timestamps")); + } + + #[test] + fn dynamic_tool_definitions_are_listed_and_resolved_when_supplied() { + let router = ToolRouter::new(); + let dynamic = workloads::dolos::definitions(); + + assert!( + router + .get_with_dynamic("dolos.snapshot.refresh", &[]) + .is_none() + ); + assert!( + router + .list_with_dynamic(dynamic) + .tools + .iter() + .any(|tool| tool.name == "dolos.snapshot.refresh") + ); + assert!( + router + .get_with_dynamic("dolos.snapshot.refresh", dynamic) + .is_some() + ); + } + + #[tokio::test] + async fn executes_catalog_get_tool() { + let router = ToolRouter::new(); + let catalog = ExtensionCatalog::embedded(); + let definition = router + .get_with_dynamic("extensions.catalog.get", &[]) + .unwrap(); + let mut arguments = JsonObject::new(); + arguments.insert( + "extensionId".to_string(), + Value::String("cardano-node-relay".to_string()), + ); + + let result = router.call(definition, Some(&arguments), &catalog).await; + + assert_eq!(result.is_error, Some(false)); + assert_eq!( + result + .structured_content + .as_ref() + .and_then(|value| value.pointer("/extension/id")), + Some(&Value::String("cardano-node-relay".to_string())) + ); + } + + #[tokio::test] + async fn missing_required_argument_returns_tool_error() { + let router = ToolRouter::new(); + let catalog = ExtensionCatalog::embedded(); + let definition = router + .get_with_dynamic("extensions.catalog.get", &[]) + .unwrap(); + + let result = router.call(definition, None, &catalog).await; + + assert_eq!(result.is_error, Some(true)); + assert_eq!( + result + .structured_content + .as_ref() + .and_then(|value| value.get("error")), + Some(&Value::String("invalid_arguments".to_string())) + ); + } + + #[tokio::test] + async fn workloads_metrics_get_dispatches_to_argument_validation() { + let router = ToolRouter::new(); + let catalog = ExtensionCatalog::embedded(); + let definition = router + .get_with_dynamic("workloads.metrics.get", &[]) + .unwrap(); + + let result = router.call(definition, None, &catalog).await; + + assert_eq!(result.is_error, Some(true)); + assert_eq!( + result + .structured_content + .as_ref() + .and_then(|value| value.get("error")), + Some(&Value::String("invalid_arguments".to_string())) + ); + } + + #[tokio::test] + async fn workloads_upgrade_dispatches_to_argument_validation() { + let router = ToolRouter::new(); + let catalog = ExtensionCatalog::embedded(); + let definition = router.get_with_dynamic("workloads.upgrade", &[]).unwrap(); + + let result = router.call(definition, None, &catalog).await; + + assert_eq!(result.is_error, Some(true)); + assert_eq!( + result + .structured_content + .as_ref() + .and_then(|value| value.get("error")), + Some(&Value::String("invalid_arguments".to_string())) + ); + } + + #[tokio::test] + async fn workloads_install_dry_run_returns_validated_plan() { + let router = ToolRouter::new(); + let catalog = ExtensionCatalog::embedded(); + let definition = router.get_with_dynamic("workloads.install", &[]).unwrap(); + let mut arguments = JsonObject::new(); + arguments.insert( + "extensionId".to_string(), + Value::String("cardano-node-relay".to_string()), + ); + arguments.insert( + "releaseName".to_string(), + Value::String("relay-preview".to_string()), + ); + arguments.insert( + "namespace".to_string(), + Value::String("cardano".to_string()), + ); + arguments.insert("dryRun".to_string(), Value::Bool(true)); + arguments.insert( + "configuration".to_string(), + json!({ + "network": "preview", + "namespace": "cardano", + "storageClass": "standard", + }), + ); + + let result = router.call(definition, Some(&arguments), &catalog).await; + + assert_eq!(result.is_error, Some(false)); + let content = result.structured_content.as_ref().unwrap(); + assert_eq!(content.pointer("/wouldMutate"), Some(&Value::Bool(false))); + assert_eq!( + content.pointer("/extension/id"), + Some(&Value::String("cardano-node-relay".to_string())) + ); + assert_eq!( + content.pointer("/chart/chart"), + Some(&Value::String( + "oci://oci.supernode.store/extensions/cardano-node".to_string() + )) + ); + assert_eq!( + content.pointer("/helmValues/node/network"), + Some(&Value::String("preview".to_string())) + ); + assert_eq!( + content.pointer("/helmValues/node/blockProducer/enabled"), + Some(&Value::Bool(false)) + ); + assert_eq!( + content.pointer("/helmValues/persistence/size"), + Some(&Value::String("80Gi".to_string())) + ); + } + + #[tokio::test] + async fn workloads_install_rejects_unknown_raw_helm_values() { + let router = ToolRouter::new(); + let catalog = ExtensionCatalog::embedded(); + let definition = router.get_with_dynamic("workloads.install", &[]).unwrap(); + let mut arguments = JsonObject::new(); + arguments.insert( + "extensionId".to_string(), + Value::String("cardano-node-relay".to_string()), + ); + arguments.insert( + "releaseName".to_string(), + Value::String("relay-preview".to_string()), + ); + arguments.insert( + "namespace".to_string(), + Value::String("cardano".to_string()), + ); + arguments.insert( + "configuration".to_string(), + json!({ + "network": "preview", + "namespace": "cardano", + "storageClass": "standard", + "rawValues": { "node": { "replicas": 10 } }, + }), + ); + + let result = router.call(definition, Some(&arguments), &catalog).await; + + assert_eq!(result.is_error, Some(true)); + assert_eq!( + result + .structured_content + .as_ref() + .and_then(|value| value.get("error")), + Some(&Value::String( + "invalid_extension_configuration".to_string() + )) + ); + } + + #[tokio::test] + async fn dolos_install_dry_run_returns_validated_plan() { + let router = ToolRouter::new(); + let catalog = ExtensionCatalog::embedded(); + let definition = router.get_with_dynamic("workloads.install", &[]).unwrap(); + let mut arguments = JsonObject::new(); + arguments.insert( + "extensionId".to_string(), + Value::String("dolos".to_string()), + ); + arguments.insert( + "releaseName".to_string(), + Value::String("dolos-preview".to_string()), + ); + arguments.insert( + "namespace".to_string(), + Value::String("cardano".to_string()), + ); + arguments.insert("dryRun".to_string(), Value::Bool(true)); + arguments.insert( + "configuration".to_string(), + json!({ + "network": "cardano-preview", + "namespace": "cardano", + "storageClass": "standard", + "upstreamAddress": "relay-preview-cardano-node.cardano.svc.cluster.local:3000", + }), + ); + + let result = router.call(definition, Some(&arguments), &catalog).await; + + assert_eq!(result.is_error, Some(false)); + let content = result.structured_content.as_ref().unwrap(); + assert_eq!(content.pointer("/wouldMutate"), Some(&Value::Bool(false))); + assert_eq!(content.pointer("/extension/id"), Some(&json!("dolos"))); + assert_eq!( + content.pointer("/chart/chart"), + Some(&json!("oci://oci.supernode.store/extensions/dolos")) + ); + assert_eq!( + content.pointer("/helmValues/dolos/network"), + Some(&json!("cardano-preview")) + ); + assert_eq!( + content.pointer("/helmValues/config/upstreamAddress"), + Some(&json!( + "relay-preview-cardano-node.cardano.svc.cluster.local:3000" + )) + ); + assert_eq!( + content.pointer("/helmValues/image/tag"), + Some(&json!("v1.1.1")) + ); + assert_eq!( + content.pointer("/helmValues/persistence/size"), + Some(&json!("50Gi")) + ); + } + + #[test] + fn workloads_install_schema_does_not_expose_approval_id() { + let router = ToolRouter::new(); + let definition = router.get_with_dynamic("workloads.install", &[]).unwrap(); + + assert!(definition.input_schema.contains("configuration")); + assert!(!definition.input_schema.contains("approvalId")); + } + + #[test] + fn workloads_install_tool_schema_advertises_configuration_argument() { + let router = ToolRouter::new(); + let tool = router + .list_with_dynamic(&[]) + .tools + .into_iter() + .find(|tool| tool.name == "workloads.install") + .unwrap(); + let schema = Value::Object((*tool.input_schema).clone()); + + assert_eq!( + schema + .get("required") + .and_then(Value::as_array) + .unwrap() + .iter() + .filter_map(Value::as_str) + .collect::>(), + vec!["extensionId", "releaseName", "namespace", "configuration"] + ); + assert_eq!( + schema.pointer("/properties/configuration/type"), + Some(&json!("object")) + ); + assert_eq!( + schema.pointer("/properties/configuration/additionalProperties"), + Some(&json!(true)) + ); + assert!( + schema + .pointer("/properties/configuration/description") + .and_then(Value::as_str) + .is_some_and(|description| description.contains("Required")) + ); + } + + #[tokio::test] + async fn vault_runtime_tool_rejects_non_runtime_path_before_client_setup() { + let router = ToolRouter::new(); + let catalog = ExtensionCatalog::embedded(); + let definition = router.get_with_dynamic("vault.runtime.write", &[]).unwrap(); + let mut arguments = JsonObject::new(); + arguments.insert( + "path".to_string(), + Value::String("operator/root".to_string()), + ); + arguments.insert("values".to_string(), serde_json::json!({ "key": "secret" })); + + let result = router.call(definition, Some(&arguments), &catalog).await; + + assert_eq!(result.is_error, Some(true)); + assert_eq!( + result + .structured_content + .as_ref() + .and_then(|value| value.get("error")), + Some(&Value::String("vault_path_not_allowed".to_string())) + ); + assert!(!serde_json::to_string(&result).unwrap().contains("secret")); + } +} diff --git a/mcp-server/src/tools/supernode.rs b/mcp-server/src/tools/supernode.rs new file mode 100644 index 0000000..5b124d5 --- /dev/null +++ b/mcp-server/src/tools/supernode.rs @@ -0,0 +1,59 @@ +use crate::policy::ApprovalClass; +use crate::policy::Scope; + +use super::ToolDefinition; + +pub fn definitions() -> &'static [ToolDefinition] { + &[ + ToolDefinition { + name: "supernode.status.get", + title: "Get Supernode Status", + description: "Return overall Supernode control-plane and workload status.", + required_scope: Scope::Discover, + approval_class: ApprovalClass::Discovery, + read_only: true, + destructive: false, + input_schema: r#"{"type":"object","properties":{},"additionalProperties":false}"#, + }, + ToolDefinition { + name: "cluster.storage_classes.list", + title: "List Storage Classes", + description: "List available cluster storage classes and defaults.", + required_scope: Scope::Discover, + approval_class: ApprovalClass::Discovery, + read_only: true, + destructive: false, + input_schema: r#"{"type":"object","properties":{},"additionalProperties":false}"#, + }, + ToolDefinition { + name: "cluster.events.list", + title: "List Cluster Events", + description: "Read bounded cluster or namespace events for debugging.", + required_scope: Scope::Debug, + approval_class: ApprovalClass::ReadOnlyDebug, + read_only: true, + destructive: false, + input_schema: r#"{"type":"object","properties":{"namespace":{"type":"string"},"limit":{"type":"integer","minimum":1,"maximum":200}},"additionalProperties":false}"#, + }, + ToolDefinition { + name: "extensions.catalog.list", + title: "List Extension Catalog", + description: "List supported embedded extension catalog entries.", + required_scope: Scope::Discover, + approval_class: ApprovalClass::Discovery, + read_only: true, + destructive: false, + input_schema: r#"{"type":"object","properties":{},"additionalProperties":false}"#, + }, + ToolDefinition { + name: "extensions.catalog.get", + title: "Get Extension Catalog Entry", + description: "Get one extension's configuration and metrics schemas.", + required_scope: Scope::Discover, + approval_class: ApprovalClass::Discovery, + read_only: true, + destructive: false, + input_schema: r#"{"type":"object","required":["extensionId"],"properties":{"extensionId":{"type":"string"}},"additionalProperties":false}"#, + }, + ] +} diff --git a/mcp-server/src/tools/vault.rs b/mcp-server/src/tools/vault.rs new file mode 100644 index 0000000..8f9c1a9 --- /dev/null +++ b/mcp-server/src/tools/vault.rs @@ -0,0 +1,89 @@ +use crate::policy::ApprovalClass; +use crate::policy::Scope; + +use super::ToolDefinition; + +pub fn definitions() -> &'static [ToolDefinition] { + &[ + ToolDefinition { + name: "vault.runtime.metadata.get", + title: "Get Runtime Secret Metadata", + description: "Show runtime path existence and key names only.", + required_scope: Scope::VaultRuntimeMetadata, + approval_class: ApprovalClass::Discovery, + read_only: true, + destructive: false, + input_schema: r#"{"type":"object","required":["path"],"properties":{"path":{"type":"string"}},"additionalProperties":false}"#, + }, + ToolDefinition { + name: "vault.runtime.read", + title: "Read Runtime Secret", + description: "Read runtime secret values after approval; values must be redacted in outputs by default.", + required_scope: Scope::VaultRuntimeRead, + approval_class: ApprovalClass::SensitiveRuntimeRead, + read_only: true, + destructive: false, + input_schema: r#"{"type":"object","required":["path","approvalId"],"properties":{"path":{"type":"string"},"approvalId":{"type":"string"}},"additionalProperties":false}"#, + }, + ToolDefinition { + name: "vault.runtime.write", + title: "Write Runtime Secret", + description: "Write approved runtime secret material under kv/runtime paths.", + required_scope: Scope::VaultRuntimeWrite, + approval_class: ApprovalClass::RuntimeSecretWrite, + read_only: false, + destructive: false, + input_schema: r#"{"type":"object","required":["path","values","approvalId"],"properties":{"path":{"type":"string"},"values":{"type":"object"},"approvalId":{"type":"string"}},"additionalProperties":false}"#, + }, + ToolDefinition { + name: "vault.runtime.patch", + title: "Patch Runtime Secret", + description: "Patch selected runtime secret keys while preserving other fields.", + required_scope: Scope::VaultRuntimeWrite, + approval_class: ApprovalClass::RuntimeSecretWrite, + read_only: false, + destructive: false, + input_schema: r#"{"type":"object","required":["path","values","approvalId"],"properties":{"path":{"type":"string"},"values":{"type":"object"},"approvalId":{"type":"string"}},"additionalProperties":false}"#, + }, + ToolDefinition { + name: "vault.operator.guide", + title: "Guide Operator Secret Handling", + description: "Provide instructions for operator-only secret handling without reading values.", + required_scope: Scope::Admin, + approval_class: ApprovalClass::OperatorSecretGuidance, + read_only: true, + destructive: false, + input_schema: r#"{"type":"object","properties":{"topic":{"type":"string"}},"additionalProperties":false}"#, + }, + ToolDefinition { + name: "vault.operator.metadata.get", + title: "Get Operator Secret Metadata", + description: "Optional break-glass operator path and key metadata only.", + required_scope: Scope::VaultOperatorMetadata, + approval_class: ApprovalClass::OperatorSecretBreakGlass, + read_only: true, + destructive: false, + input_schema: r#"{"type":"object","required":["path","approvalId"],"properties":{"path":{"type":"string"},"approvalId":{"type":"string"}},"additionalProperties":false}"#, + }, + ToolDefinition { + name: "vault.operator.write", + title: "Write Operator Secret", + description: "Optional break-glass write to kv/operator paths.", + required_scope: Scope::VaultOperatorWrite, + approval_class: ApprovalClass::OperatorSecretBreakGlass, + read_only: false, + destructive: false, + input_schema: r#"{"type":"object","required":["path","values","approvalId"],"properties":{"path":{"type":"string"},"values":{"type":"object"},"approvalId":{"type":"string"}},"additionalProperties":false}"#, + }, + ToolDefinition { + name: "vault.operator.read", + title: "Read Operator Secret", + description: "Optional break-glass read from kv/operator paths; redacted by default.", + required_scope: Scope::VaultOperatorRead, + approval_class: ApprovalClass::OperatorSecretBreakGlass, + read_only: true, + destructive: false, + input_schema: r#"{"type":"object","required":["path","approvalId"],"properties":{"path":{"type":"string"},"approvalId":{"type":"string"}},"additionalProperties":false}"#, + }, + ] +} diff --git a/mcp-server/src/tools/workloads/delete.rs b/mcp-server/src/tools/workloads/delete.rs new file mode 100644 index 0000000..3934cb6 --- /dev/null +++ b/mcp-server/src/tools/workloads/delete.rs @@ -0,0 +1,335 @@ +use std::collections::BTreeSet; + +use k8s_openapi::api::apps::v1::StatefulSet; +use k8s_openapi::api::core::v1::PersistentVolumeClaim; +use rmcp::model::{CallToolResult, JsonObject}; +use serde_json::{Value, json}; + +use crate::catalog::ExtensionCatalog; +use crate::helm::{self, HelmUninstallPlan}; +use crate::k8s::{HelmReleaseDiscovery, KubernetesClient, ResourceListParams}; +use crate::tools::common::{kube_error, success, tool_error}; + +use super::registry; + +const TOOL_NAME: &str = "workloads.delete"; + +pub(crate) async fn delete( + arguments: Option<&JsonObject>, + catalog: &ExtensionCatalog, +) -> CallToolResult { + let namespace = match required_string(arguments, "namespace") { + Ok(value) => value, + Err(error) => return error, + }; + let release_name = match required_string(arguments, "releaseName") { + Ok(value) => value, + Err(error) => return error, + }; + let delete_pvcs = optional_bool(arguments, "deletePvcs").unwrap_or(false); + let dry_run = optional_bool(arguments, "dryRun").unwrap_or(true); + let client = match KubernetesClient::try_default().await { + Ok(client) => client, + Err(error) => return kube_error(TOOL_NAME, error), + }; + + let release = match HelmReleaseDiscovery::new(client.clone()) + .get_latest(&namespace, &release_name) + .await + { + Ok(Some(release)) => release, + Ok(None) => { + return tool_error( + "not_found", + format!("workload release not found: {namespace}/{release_name}"), + json!({ "namespace": namespace, "releaseName": release_name }), + ); + } + Err(error) => { + return tool_error( + "helm_release_discovery_error", + error.to_string(), + json!({ "tool": TOOL_NAME, "namespace": namespace, "releaseName": release_name }), + ); + } + }; + + let extension = match registry::extension_for_release(&release, catalog) { + Some(extension) => extension, + None => { + return tool_error( + "unsupported_workload", + "workloads.delete only deletes catalog-managed extension releases", + json!({ + "namespace": namespace, + "releaseName": release_name, + "chart": release.chart, + }), + ); + } + }; + + let pvc_candidates = match pvc_candidates(&client, &namespace, &release_name).await { + Ok(pvcs) => pvcs, + Err(error) => return kube_error(TOOL_NAME, error), + }; + + let plan = json!({ + "helmRelease": release, + "extension": { + "id": extension.id, + "name": extension.name, + "version": extension.default_version, + }, + "deletePvcs": delete_pvcs, + "preservedPvcs": if delete_pvcs { Vec::::new() } else { pvc_candidates.iter().map(pvc_summary).collect::>() }, + "pvcsToDelete": if delete_pvcs { pvc_candidates.iter().map(pvc_summary).collect::>() } else { Vec::::new() }, + }); + + if dry_run { + return success(json!({ + "action": "delete", + "dryRun": true, + "wouldMutate": false, + "namespace": namespace, + "releaseName": release_name, + "plan": plan, + "notes": [ + "dry-run planning only; no Kubernetes or Helm mutation was performed", + "PVCs are preserved by default; set deletePvcs=true to delete candidate PVCs after Helm uninstall" + ], + })); + } + + let helm_result = match helm::uninstall(&HelmUninstallPlan { + release_name: release_name.clone(), + namespace: namespace.clone(), + }) + .await + { + Ok(result) => result, + Err(error) => { + let helm_details = match &error { + helm::HelmUninstallError::Failed { + status, + stdout, + stderr, + } => json!({ + "tool": TOOL_NAME, + "releaseName": release_name, + "namespace": namespace, + "status": status, + "stdout": stdout, + "stderr": stderr, + }), + _ => json!({ + "tool": TOOL_NAME, + "releaseName": release_name, + "namespace": namespace, + }), + }; + return tool_error("helm_uninstall_failed", error.to_string(), helm_details); + } + }; + + let mut deleted_pvcs = Vec::new(); + if delete_pvcs { + for pvc_name in pvc_candidates.iter().filter_map(pvc_name) { + match client + .delete_persistent_volume_claim(&namespace, pvc_name) + .await + { + Ok(()) => deleted_pvcs.push(pvc_name.to_string()), + Err(kube::Error::Api(error)) if error.code == 404 => {} + Err(error) => return kube_error(TOOL_NAME, error), + } + } + } + + success(json!({ + "action": "delete", + "dryRun": false, + "wouldMutate": true, + "namespace": namespace, + "releaseName": release_name, + "plan": plan, + "helm": helm_result, + "deletedPvcs": deleted_pvcs, + "notes": if delete_pvcs { + vec![ + "Helm release was uninstalled successfully", + "Candidate PVCs were deleted after Helm uninstall", + ] + } else { + vec![ + "Helm release was uninstalled successfully", + "PVCs were preserved; deletePvcs defaults to false", + ] + }, + })) +} + +async fn pvc_candidates( + client: &KubernetesClient, + namespace: &str, + release_name: &str, +) -> Result, kube::Error> { + let stateful_sets = client + .list_stateful_sets( + Some(namespace), + &ResourceListParams { + label_selector: Some(format!("app.kubernetes.io/instance={release_name}")), + ..Default::default() + }, + ) + .await?; + let managed_prefixes = managed_pvc_prefixes(&stateful_sets.items); + let all_pvcs = client + .list_persistent_volume_claims(Some(namespace), &ResourceListParams::default()) + .await?; + let mut seen = BTreeSet::new(); + let mut candidates = Vec::new(); + + for pvc in all_pvcs.items { + let Some(name) = pvc_name(&pvc) else { + continue; + }; + let label_match = pvc + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get("app.kubernetes.io/instance")) + .is_some_and(|value| value == release_name); + let prefix_match = managed_prefixes + .iter() + .any(|prefix| name.starts_with(prefix)); + + if (label_match || prefix_match) && seen.insert(name.to_string()) { + candidates.push(pvc); + } + } + + candidates.sort_by(|left, right| left.metadata.name.cmp(&right.metadata.name)); + Ok(candidates) +} + +fn managed_pvc_prefixes(stateful_sets: &[StatefulSet]) -> Vec { + stateful_sets + .iter() + .flat_map(|stateful_set| { + let stateful_set_name = stateful_set.metadata.name.as_deref().unwrap_or_default(); + stateful_set + .spec + .as_ref() + .and_then(|spec| spec.volume_claim_templates.as_ref()) + .into_iter() + .flatten() + .filter_map(move |template| { + let claim_name = template.metadata.name.as_deref()?; + Some(format!("{claim_name}-{stateful_set_name}-")) + }) + }) + .collect() +} + +fn pvc_summary(pvc: &PersistentVolumeClaim) -> Value { + json!({ + "name": pvc.metadata.name, + "namespace": pvc.metadata.namespace, + "phase": pvc.status.as_ref().and_then(|status| status.phase.clone()), + }) +} + +fn pvc_name(pvc: &PersistentVolumeClaim) -> Option<&str> { + pvc.metadata.name.as_deref() +} + +fn required_string(arguments: Option<&JsonObject>, name: &str) -> Result { + arguments + .and_then(|arguments| arguments.get(name)) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .ok_or_else(|| { + tool_error( + "invalid_arguments", + format!("missing required string argument: {name}"), + json!({ "argument": name }), + ) + }) +} + +fn optional_bool(arguments: Option<&JsonObject>, name: &str) -> Option { + arguments + .and_then(|arguments| arguments.get(name)) + .and_then(Value::as_bool) +} + +#[cfg(test)] +mod tests { + use k8s_openapi::api::apps::v1::StatefulSetSpec; + use k8s_openapi::api::core::v1::PersistentVolumeClaimSpec; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + + use crate::policy::{ApprovalClass, Scope}; + use crate::tools::workloads; + + use super::*; + + #[test] + fn workload_delete_definition_is_destructive() { + let definition = workloads::definitions() + .iter() + .find(|definition| definition.name == "workloads.delete") + .unwrap(); + + assert!(definition.destructive); + assert_eq!(definition.required_scope, Scope::WorkloadsDelete); + assert_eq!(definition.approval_class, ApprovalClass::Destructive); + assert!(definition.input_schema.contains("dryRun")); + assert!(definition.input_schema.contains("deletePvcs")); + } + + #[test] + fn managed_pvc_prefixes_follow_stateful_set_templates() { + let stateful_set = StatefulSet { + metadata: ObjectMeta { + name: Some("hydra-offline-hydra-node".to_string()), + ..Default::default() + }, + spec: Some(StatefulSetSpec { + volume_claim_templates: Some(vec![PersistentVolumeClaim { + metadata: ObjectMeta { + name: Some("data".to_string()), + ..Default::default() + }, + spec: Some(PersistentVolumeClaimSpec::default()), + ..Default::default() + }]), + ..Default::default() + }), + ..Default::default() + }; + + assert_eq!( + managed_pvc_prefixes(&[stateful_set]), + vec!["data-hydra-offline-hydra-node-".to_string()] + ); + } + + #[test] + fn missing_release_name_returns_invalid_arguments() { + let mut arguments = JsonObject::new(); + arguments.insert("namespace".to_string(), Value::String("hydra".to_string())); + + let error = required_string(Some(&arguments), "releaseName").unwrap_err(); + + assert_eq!( + error + .structured_content + .as_ref() + .and_then(|content| content.get("error")), + Some(&Value::String("invalid_arguments".to_string())) + ); + } +} diff --git a/mcp-server/src/tools/workloads/dolos.rs b/mcp-server/src/tools/workloads/dolos.rs new file mode 100644 index 0000000..7c5234e --- /dev/null +++ b/mcp-server/src/tools/workloads/dolos.rs @@ -0,0 +1,375 @@ +use k8s_openapi::api::apps::v1::StatefulSet; +use k8s_openapi::api::core::v1::PersistentVolumeClaim; +use rmcp::model::CallToolResult; +use rmcp::model::JsonObject; +use serde_json::Value; +use serde_json::json; +use tokio::time::Duration; +use tokio::time::Instant; + +use crate::k8s::HelmReleaseDiscovery; +use crate::k8s::KubernetesClient; +use crate::k8s::ResourceListParams; +use crate::policy::ApprovalClass; +use crate::policy::Scope; +use crate::tools::ToolDefinition; +use crate::tools::common::kube_error; +use crate::tools::common::success; +use crate::tools::common::tool_error; + +use super::registry; + +const SNAPSHOT_REFRESH_WAIT_TIMEOUT_SECONDS: u64 = 120; +const SNAPSHOT_REFRESH_WAIT_INTERVAL_SECONDS: u64 = 2; + +pub(crate) fn definitions() -> &'static [ToolDefinition] { + &[ToolDefinition { + name: "dolos.snapshot.refresh", + title: "Refresh Dolos Snapshot", + description: "Delete the managed Dolos data PVC and restart the StatefulSet so Dolos downloads a fresh snapshot.", + required_scope: Scope::WorkloadsDelete, + approval_class: ApprovalClass::Destructive, + read_only: false, + destructive: true, + input_schema: r#"{"type":"object","required":["namespace","releaseName"],"properties":{"namespace":{"type":"string"},"releaseName":{"type":"string"},"dryRun":{"type":"boolean"},"approvalId":{"type":"string"}},"additionalProperties":false}"#, + }] +} + +pub(crate) async fn snapshot_refresh(arguments: Option<&JsonObject>) -> CallToolResult { + let namespace = match required_string(arguments, "namespace") { + Ok(value) => value, + Err(error) => return error, + }; + let release_name = match required_string(arguments, "releaseName") { + Ok(value) => value, + Err(error) => return error, + }; + let dry_run = optional_bool(arguments, "dryRun").unwrap_or(true); + let client = match KubernetesClient::try_default().await { + Ok(client) => client, + Err(error) => return kube_error("dolos.snapshot.refresh", error), + }; + + let release = match HelmReleaseDiscovery::new(client.clone()) + .get_latest(&namespace, &release_name) + .await + { + Ok(Some(release)) => release, + Ok(None) => { + return tool_error( + "not_found", + format!("Dolos release not found: {namespace}/{release_name}"), + json!({ "namespace": namespace, "releaseName": release_name }), + ); + } + Err(error) => { + return tool_error( + "helm_release_discovery_error", + error.to_string(), + json!({ "tool": "dolos.snapshot.refresh", "namespace": namespace, "releaseName": release_name }), + ); + } + }; + + if release.chart.name.as_deref() != Some(registry::DOLOS_CHART_NAME) { + return tool_error( + "unsupported_workload", + "snapshot refresh is only supported for Dolos workloads", + json!({ + "namespace": namespace, + "releaseName": release_name, + "chart": release.chart, + }), + ); + } + + let stateful_set = match find_dolos_stateful_set(&client, &namespace, &release_name).await { + Ok(Some(stateful_set)) => stateful_set, + Ok(None) => { + return tool_error( + "not_found", + format!("Dolos StatefulSet not found: {namespace}/{release_name}"), + json!({ "namespace": namespace, "releaseName": release_name }), + ); + } + Err(error) => return kube_error("dolos.snapshot.refresh", error), + }; + let stateful_set_name = stateful_set.metadata.name.clone().unwrap_or_default(); + let original_replicas = stateful_set + .spec + .as_ref() + .and_then(|spec| spec.replicas) + .unwrap_or(1); + let pvc_names = managed_pvc_names(&stateful_set); + if pvc_names.is_empty() { + return tool_error( + "invalid_workload_state", + "Dolos StatefulSet does not define managed volume claim templates", + json!({ + "namespace": namespace, + "releaseName": release_name, + "statefulSet": stateful_set_name, + }), + ); + } + let existing_pvcs = match existing_pvcs(&client, &namespace, &pvc_names).await { + Ok(pvcs) => pvcs, + Err(error) => return kube_error("dolos.snapshot.refresh", error), + }; + + let plan = json!({ + "statefulSet": stateful_set_name, + "originalReplicas": original_replicas, + "scaleDownReplicas": 0, + "scaleUpReplicas": original_replicas, + "managedPvcs": pvc_names, + "existingPvcs": existing_pvcs.iter().map(pvc_summary).collect::>(), + }); + + if dry_run { + return success(json!({ + "action": "refresh-dolos-snapshot", + "dryRun": true, + "wouldMutate": false, + "namespace": namespace, + "releaseName": release_name, + "plan": plan, + "notes": [ + "dry-run planning only; no Kubernetes mutation was performed", + "actual execution scales the StatefulSet to zero, deletes managed data PVCs, then restores the original replica count" + ], + })); + } + + if let Err(error) = client + .scale_stateful_set(&namespace, &stateful_set_name, 0) + .await + { + return kube_error("dolos.snapshot.refresh", error); + } + if let Err(error) = wait_for_dolos_pods_absent(&client, &namespace, &release_name).await { + let _ = client + .scale_stateful_set(&namespace, &stateful_set_name, original_replicas) + .await; + return error; + } + for pvc_name in &pvc_names { + if let Err(error) = client + .delete_persistent_volume_claim(&namespace, pvc_name) + .await + { + let _ = client + .scale_stateful_set(&namespace, &stateful_set_name, original_replicas) + .await; + return kube_error("dolos.snapshot.refresh", error); + } + } + if let Err(error) = client + .scale_stateful_set(&namespace, &stateful_set_name, original_replicas) + .await + { + return kube_error("dolos.snapshot.refresh", error); + } + + success(json!({ + "action": "refresh-dolos-snapshot", + "dryRun": false, + "wouldMutate": true, + "namespace": namespace, + "releaseName": release_name, + "plan": plan, + "deletedPvcs": pvc_names, + "notes": [ + "Dolos StatefulSet was scaled down before PVC deletion", + "Dolos StatefulSet replica count was restored so Kubernetes can create a new PVC and pod" + ], + })) +} + +async fn find_dolos_stateful_set( + client: &KubernetesClient, + namespace: &str, + release_name: &str, +) -> Result, kube::Error> { + let stateful_sets = client + .list_stateful_sets( + Some(namespace), + &ResourceListParams { + label_selector: Some(format!( + "app.kubernetes.io/instance={release_name},app.kubernetes.io/name=dolos" + )), + ..Default::default() + }, + ) + .await?; + + Ok(stateful_sets + .items + .into_iter() + .min_by(|left, right| left.metadata.name.cmp(&right.metadata.name))) +} + +pub(crate) fn managed_pvc_names(stateful_set: &StatefulSet) -> Vec { + let Some(stateful_set_name) = stateful_set.metadata.name.as_deref() else { + return vec![]; + }; + let replicas = stateful_set + .spec + .as_ref() + .and_then(|spec| spec.replicas) + .unwrap_or(1) + .max(1); + + stateful_set + .spec + .as_ref() + .and_then(|spec| spec.volume_claim_templates.as_ref()) + .map(|templates| { + templates + .iter() + .flat_map(|template| { + let claim_name = template.metadata.name.as_deref().unwrap_or_default(); + (0..replicas) + .map(move |ordinal| format!("{claim_name}-{stateful_set_name}-{ordinal}")) + }) + .collect() + }) + .unwrap_or_default() +} + +async fn existing_pvcs( + client: &KubernetesClient, + namespace: &str, + pvc_names: &[String], +) -> Result, kube::Error> { + let mut pvcs = Vec::new(); + + for pvc_name in pvc_names { + match client + .get_persistent_volume_claim(namespace, pvc_name) + .await + { + Ok(pvc) => pvcs.push(pvc), + Err(kube::Error::Api(error)) if error.code == 404 => {} + Err(error) => return Err(error), + } + } + + Ok(pvcs) +} + +async fn wait_for_dolos_pods_absent( + client: &KubernetesClient, + namespace: &str, + release_name: &str, +) -> Result<(), CallToolResult> { + let deadline = Instant::now() + Duration::from_secs(SNAPSHOT_REFRESH_WAIT_TIMEOUT_SECONDS); + let params = ResourceListParams { + label_selector: Some(format!( + "app.kubernetes.io/instance={release_name},app.kubernetes.io/name=dolos" + )), + ..Default::default() + }; + + loop { + let pods = client + .list_pods(Some(namespace), ¶ms) + .await + .map_err(|error| kube_error("dolos.snapshot.refresh", error))?; + if pods.items.is_empty() { + return Ok(()); + } + if Instant::now() >= deadline { + return Err(tool_error( + "workload_restart_timeout", + "timed out waiting for Dolos pods to terminate after scaling StatefulSet to zero", + json!({ + "namespace": namespace, + "releaseName": release_name, + "remainingPods": pods.items.iter().filter_map(|pod| pod.metadata.name.clone()).collect::>(), + "timeoutSeconds": SNAPSHOT_REFRESH_WAIT_TIMEOUT_SECONDS, + }), + )); + } + tokio::time::sleep(Duration::from_secs(SNAPSHOT_REFRESH_WAIT_INTERVAL_SECONDS)).await; + } +} + +fn pvc_summary(pvc: &PersistentVolumeClaim) -> Value { + json!({ + "name": pvc.metadata.name, + "namespace": pvc.metadata.namespace, + "phase": pvc.status.as_ref().and_then(|status| status.phase.clone()), + }) +} + +fn required_string(arguments: Option<&JsonObject>, name: &str) -> Result { + arguments + .and_then(|arguments| arguments.get(name)) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .ok_or_else(|| { + tool_error( + "invalid_arguments", + format!("missing required string argument: {name}"), + json!({ "argument": name }), + ) + }) +} + +fn optional_bool(arguments: Option<&JsonObject>, name: &str) -> Option { + arguments + .and_then(|arguments| arguments.get(name)) + .and_then(Value::as_bool) +} + +#[cfg(test)] +mod tests { + use k8s_openapi::api::core::v1::PersistentVolumeClaim; + use k8s_openapi::api::core::v1::PersistentVolumeClaimSpec; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + + use super::*; + + #[test] + fn definition_is_destructive_and_requires_workload_delete_scope() { + let definition = definitions().first().unwrap(); + + assert_eq!(definition.name, "dolos.snapshot.refresh"); + assert!(definition.destructive); + assert_eq!(definition.required_scope, Scope::WorkloadsDelete); + assert_eq!(definition.approval_class, ApprovalClass::Destructive); + } + + #[test] + fn managed_pvc_names_follow_stateful_set_volume_claim_template_names() { + let stateful_set = StatefulSet { + metadata: ObjectMeta { + name: Some("dolos-preview".to_string()), + ..Default::default() + }, + spec: Some(k8s_openapi::api::apps::v1::StatefulSetSpec { + replicas: Some(2), + volume_claim_templates: Some(vec![PersistentVolumeClaim { + metadata: ObjectMeta { + name: Some("data".to_string()), + ..Default::default() + }, + spec: Some(PersistentVolumeClaimSpec::default()), + ..Default::default() + }]), + ..Default::default() + }), + ..Default::default() + }; + + assert_eq!( + managed_pvc_names(&stateful_set), + vec![ + "data-dolos-preview-0".to_string(), + "data-dolos-preview-1".to_string(), + ] + ); + } +} diff --git a/mcp-server/src/tools/workloads/install.rs b/mcp-server/src/tools/workloads/install.rs new file mode 100644 index 0000000..0c40886 --- /dev/null +++ b/mcp-server/src/tools/workloads/install.rs @@ -0,0 +1,774 @@ +use k8s_openapi::api::storage::v1::StorageClass; +use rmcp::model::CallToolResult; +use rmcp::model::JsonObject; +use serde_json::Value; +use serde_json::json; + +use crate::catalog::ExtensionDefinition; +use crate::k8s::HelmReleaseDiscovery; +use crate::k8s::HelmReleaseSummary; +use crate::k8s::KubernetesClient; +use crate::k8s::ResourceListParams; + +use super::registry; +use crate::tools::common::kube_error; +use crate::tools::common::tool_error; +use crate::tools::k8s_summaries::storage_class_summary; + +#[derive(Debug, Clone)] +pub(crate) struct InstallResolution { + pub configuration: Value, + pub available_storage_classes: Vec, + pub recommended_storage_classes: Vec, +} + +pub(crate) async fn resolve_configuration( + extension: &ExtensionDefinition, + namespace: &str, + resolved_configuration: Value, + dry_run: bool, +) -> Result { + if extension.id != registry::DOLOS_EXTENSION_ID { + return Ok(InstallResolution { + configuration: resolved_configuration, + available_storage_classes: vec![], + recommended_storage_classes: vec![], + }); + } + + let client = match KubernetesClient::try_default().await { + Ok(client) => Some(client), + Err(_) if dry_run => None, + Err(error) => return Err(kube_error("workloads.install", error)), + }; + + let mut configuration = resolved_configuration; + let mut available_storage_classes = vec![]; + let mut recommended_storage_classes = vec![]; + + if let Some(client) = &client { + let storage_classes = client + .list_storage_classes(&ResourceListParams::default()) + .await + .map_err(|error| kube_error("workloads.install", error))?; + available_storage_classes = storage_classes + .items + .iter() + .map(storage_class_summary) + .collect(); + recommended_storage_classes = recommended_storage_class_names(&storage_classes.items); + + if let Some(storage_class) = input_string(&configuration, "storageClass") + && !storage_classes + .items + .iter() + .any(|candidate| candidate.metadata.name.as_deref() == Some(storage_class)) + { + return Err(tool_error( + "invalid_extension_configuration", + "unsupported storage class for extension configuration field: storageClass", + json!({ + "field": "storageClass", + "actualValue": storage_class, + "availableStorageClasses": available_storage_classes, + "recommendedStorageClasses": recommended_storage_classes, + }), + )); + } + + configuration = + resolve_dolos_upstream_configuration(namespace, configuration, client).await?; + } + + if input_string(&configuration, "upstreamAddress").is_none() { + return Err(tool_error( + "invalid_extension_configuration", + "missing required extension configuration value: upstreamAddress", + json!({ + "field": "upstreamAddress", + "reason": "no same-network Cardano relay could be resolved from installed workloads", + }), + )); + } + + Ok(InstallResolution { + configuration, + available_storage_classes, + recommended_storage_classes, + }) +} + +pub(crate) fn apply_defaults(extension: &ExtensionDefinition, inputs: Value) -> Value { + let defaults = match extension.id.as_str() { + "cardano-node-relay" => cardano_node_relay_defaults(input_string(&inputs, "network")), + "dolos" => dolos_defaults(input_string(&inputs, "network")), + "hydra-node" => hydra_node_defaults(), + _ => json!({}), + }; + + merge_defaults(&defaults, inputs) +} + +pub(crate) fn planned_helm_values( + extension: &ExtensionDefinition, + release_name: &str, + inputs: &Value, +) -> Value { + let mut helm_values = JsonObject::new(); + helm_values.insert( + "displayName".to_string(), + Value::String(release_name.to_string()), + ); + + if let Some(storage_class) = input_string(inputs, "storageClass") { + insert_path( + &mut helm_values, + &["persistence", "storageClass"], + Value::String(storage_class.to_string()), + ); + } + + if extension.id == "cardano-node-relay" { + plan_cardano_node_values(inputs, &mut helm_values); + } else if extension.id == registry::DOLOS_EXTENSION_ID { + plan_dolos_values(inputs, &mut helm_values); + } else if extension.id == registry::HYDRA_NODE_EXTENSION_ID { + plan_hydra_node_values(inputs, &mut helm_values); + } + + Value::Object(helm_values) +} + +pub(crate) fn dolos_defaults(network: Option<&str>) -> Value { + let pvc_size = match network { + Some("cardano-mainnet") => "300Gi", + _ => "50Gi", + }; + + json!({ + "imageTag": "v1.1.1", + "exposeLoadBalancer": false, + "pvcSize": pvc_size, + }) +} + +fn hydra_node_defaults() -> Value { + json!({ + "mode": "offline", + "nodeId": "hydra-node-1", + "imageTag": "2.1.0", + "pvcSize": "5Gi", + "exposeLoadBalancer": false, + "contestationPeriod": "43200s", + "offline": { "headSeed": "0001" }, + "peers": [], + }) +} + +pub(crate) fn resolve_dolos_upstream_from_releases( + namespace: &str, + dolos_network: &str, + releases: &[HelmReleaseSummary], +) -> Option { + let mut matches = releases + .iter() + .filter(|release| release.chart.name.as_deref() == Some(registry::CARDANO_NODE_CHART_NAME)) + .filter(|release| { + release + .status + .as_deref() + .is_none_or(|status| status == "deployed") + }) + .filter(|release| { + cardano_release_network(release).as_deref() + == Some(dolos_to_cardano_network(dolos_network)) + }) + .collect::>(); + + matches.sort_by(|left, right| { + let left_same_namespace = left.namespace == namespace; + let right_same_namespace = right.namespace == namespace; + right_same_namespace + .cmp(&left_same_namespace) + .then_with(|| left.namespace.cmp(&right.namespace)) + .then_with(|| left.name.cmp(&right.name)) + }); + + let selected = matches.into_iter().next()?; + let port = selected + .config + .as_ref() + .and_then(|config| config.pointer("/service/n2nPort")) + .and_then(Value::as_i64) + .unwrap_or(3000); + let service_name = release_fullname(&selected.name, registry::CARDANO_NODE_CHART_NAME); + + Some(format!( + "{service_name}.{}.svc.cluster.local:{port}", + selected.namespace + )) +} + +async fn resolve_dolos_upstream_configuration( + namespace: &str, + configuration: Value, + client: &KubernetesClient, +) -> Result { + if input_string(&configuration, "upstreamAddress").is_some() { + return Ok(configuration); + } + + let network = input_string(&configuration, "network").ok_or_else(|| { + tool_error( + "invalid_extension_configuration", + "missing required extension configuration value: network", + json!({ "field": "network" }), + ) + })?; + let releases = HelmReleaseDiscovery::new(client.clone()) + .list_latest(None, true) + .await + .map_err(|error| { + tool_error( + "helm_release_discovery_error", + error.to_string(), + json!({ "tool": "workloads.install" }), + ) + })?; + + let Some(upstream_address) = + resolve_dolos_upstream_from_releases(namespace, network, &releases) + else { + return Ok(configuration); + }; + + Ok(merge_defaults( + &json!({ "upstreamAddress": upstream_address }), + configuration, + )) +} + +fn cardano_node_relay_defaults(network: Option<&str>) -> Value { + let (pvc_size, cpu_request, memory_request, cpu_limit, memory_limit) = match network { + Some("mainnet") => ("250Gi", "2", "8Gi", "4", "16Gi"), + Some("preprod") => ("120Gi", "1", "4Gi", "2", "8Gi"), + _ => ("80Gi", "500m", "2Gi", "1", "4Gi"), + }; + + json!({ + "topology": { "mode": "image-default" }, + "exposeLoadBalancer": false, + "imageTag": "11.0.1", + "resources": { + "requests": { + "cpu": cpu_request, + "memory": memory_request, + }, + "limits": { + "cpu": cpu_limit, + "memory": memory_limit, + }, + }, + "pvcSize": pvc_size, + }) +} + +fn plan_cardano_node_values(inputs: &Value, helm_values: &mut JsonObject) { + if let Some(network) = input_string(inputs, "network") { + insert_path( + helm_values, + &["node", "network"], + Value::String(network.to_string()), + ); + } + if let Some(pvc_size) = input_string(inputs, "pvcSize") { + insert_path( + helm_values, + &["persistence", "size"], + Value::String(pvc_size.to_string()), + ); + } + if let Some(image_tag) = input_string(inputs, "imageTag") { + insert_path( + helm_values, + &["image", "tag"], + Value::String(image_tag.to_string()), + ); + } + if inputs + .get("exposeLoadBalancer") + .and_then(Value::as_bool) + .unwrap_or(false) + { + insert_path( + helm_values, + &["service", "type"], + Value::String("LoadBalancer".to_string()), + ); + } + if let Some(resources) = inputs.get("resources") { + helm_values.insert("resources".to_string(), resources.clone()); + } + if let Some(topology) = inputs.get("topology") { + insert_path(helm_values, &["node", "topology"], topology.clone()); + } + insert_path( + helm_values, + &["node", "blockProducer", "enabled"], + Value::Bool(false), + ); +} + +fn plan_dolos_values(inputs: &Value, helm_values: &mut JsonObject) { + if let Some(network) = input_string(inputs, "network") { + insert_path( + helm_values, + &["dolos", "network"], + Value::String(network.to_string()), + ); + } + if let Some(upstream_address) = input_string(inputs, "upstreamAddress") { + insert_path( + helm_values, + &["config", "upstreamAddress"], + Value::String(upstream_address.to_string()), + ); + } + if let Some(pvc_size) = input_string(inputs, "pvcSize") { + insert_path( + helm_values, + &["persistence", "size"], + Value::String(pvc_size.to_string()), + ); + } + if let Some(image_tag) = input_string(inputs, "imageTag") { + insert_path( + helm_values, + &["image", "tag"], + Value::String(image_tag.to_string()), + ); + } + if inputs + .get("exposeLoadBalancer") + .and_then(Value::as_bool) + .unwrap_or(false) + { + insert_path( + helm_values, + &["service", "type"], + Value::String("LoadBalancer".to_string()), + ); + } + if let Some(resources) = inputs.get("resources") { + helm_values.insert("resources".to_string(), resources.clone()); + } +} + +fn plan_hydra_node_values(inputs: &Value, helm_values: &mut JsonObject) { + if let Some(mode) = input_string(inputs, "mode") { + insert_path( + helm_values, + &["node", "offlineMode"], + Value::Bool(mode != "online"), + ); + } + if let Some(node_id) = input_string(inputs, "nodeId") { + insert_path( + helm_values, + &["node", "nodeId"], + Value::String(node_id.to_string()), + ); + } + if let Some(image_tag) = input_string(inputs, "imageTag") { + insert_path( + helm_values, + &["image", "tag"], + Value::String(image_tag.to_string()), + ); + } + if let Some(pvc_size) = input_string(inputs, "pvcSize") { + insert_path( + helm_values, + &["persistence", "size"], + Value::String(pvc_size.to_string()), + ); + } + if inputs + .get("exposeLoadBalancer") + .and_then(Value::as_bool) + .unwrap_or(false) + { + insert_path( + helm_values, + &["service", "type"], + Value::String("LoadBalancer".to_string()), + ); + } + if let Some(contestation_period) = input_string(inputs, "contestationPeriod") { + insert_path( + helm_values, + &["node", "contestationPeriod"], + Value::String(contestation_period.to_string()), + ); + } + if let Some(deposit_period) = input_string(inputs, "depositPeriod") { + insert_path( + helm_values, + &["node", "depositPeriod"], + Value::String(deposit_period.to_string()), + ); + } + if let Some(unsynced_period) = inputs.get("unsyncedPeriod").and_then(Value::as_i64) { + insert_path( + helm_values, + &["node", "unsyncedPeriod"], + Value::Number(unsynced_period.into()), + ); + } + if let Some(peers) = inputs.get("peers") { + insert_path(helm_values, &["node", "peers"], peers.clone()); + } + if let Some(resources) = inputs.get("resources") { + helm_values.insert("resources".to_string(), resources.clone()); + } + + if let Some(offline) = inputs.get("offline") { + if let Some(head_seed) = input_string(offline, "headSeed") { + insert_path( + helm_values, + &["node", "offlineHeadSeed"], + Value::String(head_seed.to_string()), + ); + } + if let Some(protocol_parameters) = offline.get("protocolParameters") + && let Some(data) = pretty_json_string(protocol_parameters) + { + insert_path( + helm_values, + &["ledger", "protocolParameters", "data"], + Value::String(data), + ); + } + if let Some(initial_utxo) = offline.get("initialUtxo") + && let Some(data) = pretty_json_string(initial_utxo) + { + insert_path( + helm_values, + &["ledger", "initialUtxo", "data"], + Value::String(data), + ); + } + } + + if let Some(hydra_signing_key) = inputs.get("hydraSigningKey") { + plan_secret_ref(hydra_signing_key, helm_values, &["keys", "hydraSigning"]); + } + if let Some(hydra_verification_keys) = inputs.get("hydraVerificationKeys") { + insert_path( + helm_values, + &["keys", "hydraVerification", "items"], + hydra_verification_keys.clone(), + ); + } + + if input_string(inputs, "mode") == Some("online") { + insert_path( + helm_values, + &["keys", "cardano", "enabled"], + Value::Bool(true), + ); + if let Some(network) = input_string(inputs, "network") { + insert_path( + helm_values, + &["node", "network"], + Value::String(network.to_string()), + ); + } + if let Some(cardano_signing_key) = inputs.get("cardanoSigningKey") { + plan_secret_ref( + cardano_signing_key, + helm_values, + &["keys", "cardano", "signing"], + ); + } + if let Some(cardano_verification_key) = inputs.get("cardanoVerificationKey") { + if let Some(filename) = input_string(cardano_verification_key, "filename") { + insert_path( + helm_values, + &["keys", "cardano", "verification", "filename"], + Value::String(filename.to_string()), + ); + } + if let Some(value) = input_string(cardano_verification_key, "value") { + insert_path( + helm_values, + &["keys", "cardano", "verification", "value"], + Value::String(value.to_string()), + ); + } + if let Some(existing_config_map) = + input_string(cardano_verification_key, "existingConfigMap") + { + insert_path( + helm_values, + &[ + "keys", + "cardano", + "verification", + "existingConfigMap", + "name", + ], + Value::String(existing_config_map.to_string()), + ); + } + } + if let Some(cardano_backend) = inputs.get("cardanoBackend") { + if let Some(socket_path) = input_string(cardano_backend, "socketPath") { + insert_path( + helm_values, + &["keys", "cardano", "socketPath"], + Value::String(socket_path.to_string()), + ); + } + if let Some(hydra_scripts_tx_id) = input_string(cardano_backend, "hydraScriptsTxId") { + insert_path( + helm_values, + &["node", "hydraScriptsTxId"], + Value::String(hydra_scripts_tx_id.to_string()), + ); + } + if let Some(start_chain_from) = input_string(cardano_backend, "startChainFrom") { + insert_path( + helm_values, + &["node", "startChainFrom"], + Value::String(start_chain_from.to_string()), + ); + } + if matches!( + input_string(cardano_backend, "mode"), + Some("socketProxy") | Some("autoRelay") + ) && let Some(upstream_address) = input_string(cardano_backend, "upstreamAddress") + && let Some((host, port)) = upstream_address.rsplit_once(':') + && let Ok(port) = port.parse::() + { + insert_path( + helm_values, + &["node", "cardanoSocketProxy", "enabled"], + Value::Bool(true), + ); + insert_path( + helm_values, + &["node", "cardanoSocketProxy", "targetHost"], + Value::String(host.to_string()), + ); + insert_path( + helm_values, + &["node", "cardanoSocketProxy", "targetPort"], + Value::Number(port.into()), + ); + } + } + } +} + +fn plan_secret_ref(secret_ref: &Value, helm_values: &mut JsonObject, base_path: &[&str]) { + if input_string(secret_ref, "source") == Some("vaultStaticSecret") { + if let Some(vault_path) = input_string(secret_ref, "vaultPath") { + insert_path( + helm_values, + &path(base_path, &["vaultStaticSecret", "path"]), + Value::String(vault_path.to_string()), + ); + } + if let Some(key) = input_string(secret_ref, "key") { + insert_path( + helm_values, + &path(base_path, &["filename"]), + Value::String(key.to_string()), + ); + } + } +} + +fn path<'a>(base_path: &'a [&'a str], suffix: &'a [&'a str]) -> Vec<&'a str> { + base_path.iter().chain(suffix.iter()).copied().collect() +} + +fn pretty_json_string(value: &Value) -> Option { + serde_json::to_string_pretty(value).ok() +} + +fn merge_defaults(defaults: &Value, values: Value) -> Value { + match (defaults, values) { + (Value::Object(defaults), Value::Object(mut values)) => { + for (key, default_value) in defaults { + let value = values + .remove(key) + .map(|value| merge_defaults(default_value, value)) + .unwrap_or_else(|| default_value.clone()); + values.insert(key.clone(), value); + } + Value::Object(values) + } + (_, values) => values, + } +} + +fn insert_path(root: &mut JsonObject, path: &[&str], value: Value) { + let Some((last, parents)) = path.split_last() else { + return; + }; + let mut current = root; + + for parent in parents { + current = current + .entry((*parent).to_string()) + .or_insert_with(|| Value::Object(JsonObject::new())) + .as_object_mut() + .expect("planned Helm value parent must be an object"); + } + + current.insert((*last).to_string(), value); +} + +fn input_string<'a>(inputs: &'a Value, name: &str) -> Option<&'a str> { + inputs.get(name).and_then(Value::as_str) +} + +fn cardano_release_network(release: &HelmReleaseSummary) -> Option { + release + .config + .as_ref() + .and_then(|config| config.pointer("/node/network")) + .and_then(Value::as_str) + .map(str::to_string) +} + +fn dolos_to_cardano_network(network: &str) -> &str { + match network { + "cardano-mainnet" => "mainnet", + "cardano-preprod" => "preprod", + _ => "preview", + } +} + +fn recommended_storage_class_names(storage_classes: &[StorageClass]) -> Vec { + let mut defaults = storage_classes + .iter() + .filter(|storage_class| { + storage_class + .metadata + .annotations + .as_ref() + .is_some_and(|annotations| { + annotations + .get("storageclass.kubernetes.io/is-default-class") + .is_some_and(|value| value == "true") + || annotations + .get("storageclass.beta.kubernetes.io/is-default-class") + .is_some_and(|value| value == "true") + }) + }) + .filter_map(|storage_class| storage_class.metadata.name.clone()) + .collect::>(); + + defaults.sort(); + if !defaults.is_empty() { + return defaults; + } + + let mut names = storage_classes + .iter() + .filter_map(|storage_class| storage_class.metadata.name.clone()) + .collect::>(); + names.sort(); + names +} + +fn release_fullname(release_name: &str, chart_name: &str) -> String { + if release_name.contains(chart_name) { + release_name.to_string() + } else { + format!("{release_name}-{chart_name}") + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::k8s::HelmChartSummary; + + use super::*; + + #[test] + fn upstream_resolution_prefers_same_namespace_then_name() { + let releases = vec![ + helm_release_with_config( + "relay-z", + "other", + Some("cardano-node"), + json!({ "node": { "network": "preview" } }), + ), + helm_release_with_config( + "relay-b", + "cardano", + Some("cardano-node"), + json!({ "node": { "network": "preview" } }), + ), + helm_release_with_config( + "relay-a", + "cardano", + Some("cardano-node"), + json!({ "node": { "network": "preview" }, "service": { "n2nPort": 3100 } }), + ), + ]; + + let upstream = + resolve_dolos_upstream_from_releases("cardano", "cardano-preview", &releases).unwrap(); + + assert_eq!( + upstream, + "relay-a-cardano-node.cardano.svc.cluster.local:3100" + ); + } + + #[test] + fn dolos_defaults_match_network_size_policy() { + assert_eq!( + dolos_defaults(Some("cardano-mainnet")).pointer("/pvcSize"), + Some(&json!("300Gi")) + ); + assert_eq!( + dolos_defaults(Some("cardano-preprod")).pointer("/pvcSize"), + Some(&json!("50Gi")) + ); + assert_eq!( + dolos_defaults(Some("cardano-preview")).pointer("/imageTag"), + Some(&json!("v1.1.1")) + ); + } + + fn helm_release_with_config( + name: &str, + namespace: &str, + chart_name: Option<&str>, + config: Value, + ) -> HelmReleaseSummary { + HelmReleaseSummary { + name: name.to_string(), + namespace: namespace.to_string(), + revision: 1, + status: Some("deployed".to_string()), + chart: HelmChartSummary { + name: chart_name.map(str::to_string), + version: Some("0.1.0".to_string()), + }, + app_version: Some("11.0.1".to_string()), + description: None, + updated: None, + secret_name: Some(format!("sh.helm.release.v1.{name}.v1")), + config: Some(config), + } + } +} diff --git a/mcp-server/src/tools/workloads/logs.rs b/mcp-server/src/tools/workloads/logs.rs new file mode 100644 index 0000000..4c53997 --- /dev/null +++ b/mcp-server/src/tools/workloads/logs.rs @@ -0,0 +1,390 @@ +use k8s_openapi::api::core::v1::Pod; +use rmcp::model::CallToolResult; +use rmcp::model::JsonObject; +use serde_json::json; + +use crate::k8s::KubernetesClient; +use crate::k8s::PodLogParams; +use crate::k8s::ResourceListParams; +use crate::tools::common::kube_error; +use crate::tools::common::success; +use crate::tools::common::tool_error; +use crate::tools::k8s_summaries; + +pub(crate) async fn get(arguments: Option<&JsonObject>) -> CallToolResult { + let namespace = match required_string(arguments, "namespace") { + Ok(value) => value, + Err(error) => return error, + }; + let workload = match required_string(arguments, "workload") { + Ok(value) => value, + Err(error) => return error, + }; + let pod = optional_string(arguments, "pod"); + let container = optional_string(arguments, "container"); + let tail_lines = optional_i64(arguments, "tailLines"); + let since_seconds = optional_i64(arguments, "sinceSeconds"); + let previous = optional_bool(arguments, "previous").unwrap_or(false); + let timestamps = optional_bool(arguments, "timestamps").unwrap_or(false); + let client = match KubernetesClient::try_default().await { + Ok(client) => client, + Err(error) => return kube_error("workloads.logs.get", error), + }; + + let pods = match workload_log_pods(&client, &namespace, &workload).await { + Ok(pods) if pods.is_empty() => { + return tool_error( + "not_found", + format!("no pod found for workload: {namespace}/{workload}"), + json!({ "namespace": namespace, "workload": workload }), + ); + } + Ok(pods) => pods, + Err(error) => return kube_error("workloads.logs.get", error), + }; + let target = match resolve_log_target( + &namespace, + &workload, + &pods, + pod.as_deref(), + container.as_deref(), + ) { + Ok(target) => target, + Err(error) => return error, + }; + let params = PodLogParams { + container: Some(target.container.clone()), + previous, + tail_lines, + since_seconds, + timestamps, + }; + + match client.pod_logs(&namespace, &target.pod, ¶ms).await { + Ok(logs) => success(json!({ + "namespace": namespace, + "workload": workload, + "pod": target.pod, + "container": target.container, + "tailLines": params.to_kube().tail_lines, + "sinceSeconds": since_seconds, + "previous": previous, + "timestamps": timestamps, + "logs": logs, + })), + Err(error) => kube_error("workloads.logs.get", error), + } +} + +#[derive(Debug, Clone, Eq, PartialEq)] +struct ResolvedLogTarget { + pod: String, + container: String, +} + +fn resolve_log_target( + namespace: &str, + workload: &str, + pods: &[Pod], + requested_pod: Option<&str>, + requested_container: Option<&str>, +) -> Result { + let available_targets = || k8s_summaries::pod_log_target_summaries(pods); + let pod = if let Some(requested_pod) = requested_pod { + pods.iter() + .find(|pod| pod.metadata.name.as_deref() == Some(requested_pod)) + .ok_or_else(|| { + tool_error( + "invalid_log_target", + format!("pod is not part of workload: {namespace}/{workload}/{requested_pod}"), + json!({ + "namespace": namespace, + "workload": workload, + "pod": requested_pod, + "availableTargets": available_targets(), + }), + ) + })? + } else { + let active_pods = pods + .iter() + .filter(|pod| pod.metadata.deletion_timestamp.is_none()) + .collect::>(); + match active_pods.as_slice() { + [pod] => *pod, + [] => { + return Err(tool_error( + "not_found", + format!("no active pod found for workload: {namespace}/{workload}"), + json!({ + "namespace": namespace, + "workload": workload, + "availableTargets": available_targets(), + }), + )); + } + _ => { + return Err(tool_error( + "ambiguous_log_target", + "workload has multiple active pods; specify the pod argument", + json!({ + "namespace": namespace, + "workload": workload, + "availableTargets": available_targets(), + }), + )); + } + } + }; + let pod_name = pod.metadata.name.clone().unwrap_or_default(); + let containers = loggable_container_names(pod); + let container = if let Some(requested_container) = requested_container { + if !containers + .iter() + .any(|container| container == requested_container) + { + return Err(tool_error( + "invalid_log_target", + format!( + "container is not part of pod: {namespace}/{pod_name}/{requested_container}" + ), + json!({ + "namespace": namespace, + "workload": workload, + "pod": pod_name, + "container": requested_container, + "availableTargets": available_targets(), + }), + )); + } + requested_container.to_string() + } else { + match containers.as_slice() { + [container] => container.clone(), + [] => { + return Err(tool_error( + "not_found", + format!("no loggable container found for pod: {namespace}/{pod_name}"), + json!({ + "namespace": namespace, + "workload": workload, + "pod": pod_name, + "availableTargets": available_targets(), + }), + )); + } + _ => { + return Err(tool_error( + "ambiguous_log_target", + "pod has multiple loggable containers; specify the container argument", + json!({ + "namespace": namespace, + "workload": workload, + "pod": pod_name, + "availableTargets": available_targets(), + }), + )); + } + } + }; + + Ok(ResolvedLogTarget { + pod: pod_name, + container, + }) +} + +fn loggable_container_names(pod: &Pod) -> Vec { + let mut containers = pod + .spec + .as_ref() + .map(|spec| { + spec.containers + .iter() + .map(|container| container.name.clone()) + .chain( + spec.init_containers + .as_ref() + .into_iter() + .flatten() + .map(|container| container.name.clone()), + ) + .collect::>() + }) + .unwrap_or_default(); + containers.sort(); + containers +} + +async fn workload_log_pods( + client: &KubernetesClient, + namespace: &str, + workload: &str, +) -> Result, kube::Error> { + let mut pods = client + .list_pods( + Some(namespace), + &ResourceListParams { + label_selector: Some(format!("app.kubernetes.io/instance={workload}")), + ..Default::default() + }, + ) + .await? + .items; + + if pods.is_empty() + && let Some(pod) = get_optional(client.get_pod(namespace, workload).await)? + { + pods.push(pod); + } + + pods.sort_by(|left, right| left.metadata.name.cmp(&right.metadata.name)); + Ok(pods) +} + +fn get_optional(result: Result) -> Result, kube::Error> { + match result { + Ok(value) => Ok(Some(value)), + Err(kube::Error::Api(error)) if error.code == 404 => Ok(None), + Err(error) => Err(error), + } +} + +fn required_string(arguments: Option<&JsonObject>, name: &str) -> Result { + optional_string(arguments, name).ok_or_else(|| { + tool_error( + "invalid_arguments", + format!("missing required string argument: {name}"), + json!({ "argument": name }), + ) + }) +} + +fn optional_string(arguments: Option<&JsonObject>, name: &str) -> Option { + arguments? + .get(name)? + .as_str() + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) +} + +fn optional_bool(arguments: Option<&JsonObject>, name: &str) -> Option { + arguments?.get(name)?.as_bool() +} + +fn optional_i64(arguments: Option<&JsonObject>, name: &str) -> Option { + arguments?.get(name)?.as_i64() +} + +#[cfg(test)] +mod tests { + use k8s_openapi::api::core::v1::Container; + use k8s_openapi::api::core::v1::PodSpec; + use k8s_openapi::api::core::v1::PodStatus; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + use serde_json::Value; + + use super::*; + + #[test] + fn log_target_auto_selects_single_pod_and_container() { + let pods = vec![pod_with_containers("relay-0", &["cardano-node"], &[])]; + + let target = resolve_log_target("cardano", "relay", &pods, None, None).unwrap(); + + assert_eq!( + target, + ResolvedLogTarget { + pod: "relay-0".to_string(), + container: "cardano-node".to_string(), + } + ); + } + + #[test] + fn log_target_requires_pod_when_workload_has_multiple_active_pods() { + let pods = vec![ + pod_with_containers("relay-0", &["cardano-node"], &[]), + pod_with_containers("relay-1", &["cardano-node"], &[]), + ]; + + let error = resolve_log_target("cardano", "relay", &pods, None, None).unwrap_err(); + + assert_eq!(error.is_error, Some(true)); + assert_eq!( + error + .structured_content + .as_ref() + .and_then(|value| value.get("error")), + Some(&Value::String("ambiguous_log_target".to_string())) + ); + assert!( + error + .structured_content + .as_ref() + .and_then(|value| value.pointer("/details/availableTargets")) + .is_some() + ); + } + + #[test] + fn log_target_requires_container_when_pod_has_multiple_containers() { + let pods = vec![pod_with_containers( + "relay-0", + &["cardano-node", "metrics-sidecar"], + &[], + )]; + + let error = resolve_log_target("cardano", "relay", &pods, None, None).unwrap_err(); + + assert_eq!( + error + .structured_content + .as_ref() + .and_then(|value| value.get("error")), + Some(&Value::String("ambiguous_log_target".to_string())) + ); + } + + #[test] + fn log_target_rejects_container_not_in_selected_pod() { + let pods = vec![pod_with_containers("relay-0", &["cardano-node"], &[])]; + + let error = resolve_log_target("cardano", "relay", &pods, Some("relay-0"), Some("missing")) + .unwrap_err(); + + assert_eq!( + error + .structured_content + .as_ref() + .and_then(|value| value.get("error")), + Some(&Value::String("invalid_log_target".to_string())) + ); + } + + fn pod_with_containers(name: &str, containers: &[&str], init_containers: &[&str]) -> Pod { + Pod { + metadata: ObjectMeta { + name: Some(name.to_string()), + ..Default::default() + }, + spec: Some(PodSpec { + containers: containers.iter().map(|name| container(name)).collect(), + init_containers: (!init_containers.is_empty()) + .then(|| init_containers.iter().map(|name| container(name)).collect()), + ..Default::default() + }), + status: Some(PodStatus { + phase: Some("Running".to_string()), + ..Default::default() + }), + } + } + + fn container(name: &str) -> Container { + Container { + name: name.to_string(), + ..Default::default() + } + } +} diff --git a/mcp-server/src/tools/workloads/metrics.rs b/mcp-server/src/tools/workloads/metrics.rs new file mode 100644 index 0000000..c886536 --- /dev/null +++ b/mcp-server/src/tools/workloads/metrics.rs @@ -0,0 +1,110 @@ +use crate::catalog::ExtensionCatalog; +use crate::catalog::ExtensionDefinition; +use crate::k8s::HelmReleaseSummary; + +use super::registry; + +pub(crate) const SCRIPT_PATH: &str = "/opt/metis/bin/metrics.sh"; + +#[derive(Clone, Copy)] +pub(crate) struct MetricsTarget<'a> { + pub extension: &'a ExtensionDefinition, + pub container: &'static str, +} + +pub(crate) fn target_for_release<'a>( + release: &HelmReleaseSummary, + catalog: &'a ExtensionCatalog, +) -> Option> { + match release.chart.name.as_deref() { + Some(registry::CARDANO_NODE_CHART_NAME) => catalog + .get(registry::CARDANO_NODE_RELAY_EXTENSION_ID) + .map(|extension| MetricsTarget { + extension, + container: registry::CARDANO_NODE_METRICS_CONTAINER, + }), + Some(registry::DOLOS_CHART_NAME) => { + catalog + .get(registry::DOLOS_EXTENSION_ID) + .map(|extension| MetricsTarget { + extension, + container: registry::DOLOS_METRICS_CONTAINER, + }) + } + Some(registry::HYDRA_NODE_CHART_NAME) => catalog + .get(registry::HYDRA_NODE_EXTENSION_ID) + .map(|extension| MetricsTarget { + extension, + container: registry::HYDRA_NODE_METRICS_CONTAINER, + }), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use crate::catalog::ExtensionCatalog; + use crate::k8s::HelmChartSummary; + use crate::k8s::HelmReleaseSummary; + + use super::*; + + #[test] + fn resolves_cardano_node_chart() { + let catalog = ExtensionCatalog::embedded(); + let release = helm_release(Some("cardano-node")); + + let target = target_for_release(&release, &catalog).unwrap(); + + assert_eq!(target.extension.id, "cardano-node-relay"); + assert_eq!(target.container, registry::CARDANO_NODE_METRICS_CONTAINER); + } + + #[test] + fn resolves_dolos_chart() { + let catalog = ExtensionCatalog::embedded(); + let release = helm_release(Some("dolos")); + + let target = target_for_release(&release, &catalog).unwrap(); + + assert_eq!(target.extension.id, "dolos"); + assert_eq!(target.container, registry::DOLOS_METRICS_CONTAINER); + } + + #[test] + fn resolves_hydra_node_chart() { + let catalog = ExtensionCatalog::embedded(); + let release = helm_release(Some("hydra-node")); + + let target = target_for_release(&release, &catalog).unwrap(); + + assert_eq!(target.extension.id, "hydra-node"); + assert_eq!(target.container, registry::HYDRA_NODE_METRICS_CONTAINER); + } + + #[test] + fn rejects_unsupported_chart() { + let catalog = ExtensionCatalog::embedded(); + let release = helm_release(Some("midnight")); + + assert!(target_for_release(&release, &catalog).is_none()); + } + + fn helm_release(chart_name: Option<&str>) -> HelmReleaseSummary { + HelmReleaseSummary { + name: "relay-preview".to_string(), + namespace: "cardano".to_string(), + revision: 1, + status: Some("deployed".to_string()), + chart: HelmChartSummary { + name: chart_name.map(str::to_string), + version: Some("0.1.0".to_string()), + }, + app_version: Some("11.0.1".to_string()), + description: None, + updated: None, + secret_name: Some("sh.helm.release.v1.relay-preview.v1".to_string()), + config: None, + } + } +} diff --git a/mcp-server/src/tools/workloads/mod.rs b/mcp-server/src/tools/workloads/mod.rs new file mode 100644 index 0000000..9fb78c2 --- /dev/null +++ b/mcp-server/src/tools/workloads/mod.rs @@ -0,0 +1,101 @@ +use crate::policy::ApprovalClass; +use crate::policy::Scope; + +use super::ToolDefinition; +use std::collections::BTreeSet; + +pub(crate) mod delete; +pub(crate) mod dolos; +pub(crate) mod install; +pub(crate) mod logs; +pub(crate) mod metrics; +pub(crate) mod outputs; +pub(crate) mod registry; +pub(crate) mod upgrade; + +pub fn definitions() -> &'static [ToolDefinition] { + &[ + ToolDefinition { + name: "workloads.list", + title: "List Workloads", + description: "List installed Helm workloads, namespaces, charts, versions, and status.", + required_scope: Scope::Discover, + approval_class: ApprovalClass::Discovery, + read_only: true, + destructive: false, + input_schema: r#"{"type":"object","properties":{"includeControlPlane":{"type":"boolean"}},"additionalProperties":false}"#, + }, + ToolDefinition { + name: "workloads.get", + title: "Get Workload", + description: "Inspect one workload release and related Kubernetes objects.", + required_scope: Scope::Discover, + approval_class: ApprovalClass::Discovery, + read_only: true, + destructive: false, + input_schema: r#"{"type":"object","required":["namespace","name"],"properties":{"namespace":{"type":"string"},"name":{"type":"string"}},"additionalProperties":false}"#, + }, + ToolDefinition { + name: "workloads.logs.get", + title: "Get Workload Logs", + description: "Read bounded pod logs for one selected workload pod and container.", + required_scope: Scope::Debug, + approval_class: ApprovalClass::ReadOnlyDebug, + read_only: true, + destructive: false, + input_schema: r#"{"type":"object","required":["namespace","workload"],"properties":{"namespace":{"type":"string"},"workload":{"type":"string"},"pod":{"type":"string","description":"Exact pod name to read. Required when the workload has multiple active pods."},"container":{"type":"string","description":"Exact container or init-container name to read. Required when the selected pod has multiple loggable containers."},"tailLines":{"type":"integer","minimum":1,"maximum":1000},"sinceSeconds":{"type":"integer","minimum":1},"previous":{"type":"boolean"},"timestamps":{"type":"boolean"}},"additionalProperties":false}"#, + }, + ToolDefinition { + name: "workloads.metrics.get", + title: "Get Workload Metrics", + description: "Read raw or derived metrics for a workload.", + required_scope: Scope::Debug, + approval_class: ApprovalClass::ReadOnlyDebug, + read_only: true, + destructive: false, + input_schema: r#"{"type":"object","required":["namespace","workload"],"properties":{"namespace":{"type":"string"},"workload":{"type":"string"}},"additionalProperties":false}"#, + }, + ToolDefinition { + name: "workloads.install", + title: "Install Workload", + description: "Install a supported catalog extension with validated inputs.", + required_scope: Scope::WorkloadsInstall, + approval_class: ApprovalClass::Mutation, + read_only: false, + destructive: false, + input_schema: r#"{"type":"object","required":["extensionId","releaseName","namespace","configuration"],"properties":{"extensionId":{"type":"string","description":"Catalog extension ID to install, for example dolos or cardano-node-relay."},"releaseName":{"type":"string","description":"Helm release name to create or update."},"namespace":{"type":"string","description":"Kubernetes namespace where the workload will be installed."},"configuration":{"type":"object","description":"Required extension-specific configuration object. Use extensions.catalog.get for the selected extensionId and pass values matching that extension configuration schema.","additionalProperties":true},"dryRun":{"type":"boolean","description":"When true, validate and return the install plan without mutating Kubernetes. Defaults to true."}},"additionalProperties":false}"#, + }, + ToolDefinition { + name: "workloads.upgrade", + title: "Upgrade Workload", + description: "Upgrade an installed workload using catalog schema validation.", + required_scope: Scope::WorkloadsUpgrade, + approval_class: ApprovalClass::Mutation, + read_only: false, + destructive: false, + input_schema: r#"{"type":"object","required":["namespace","releaseName","configuration"],"properties":{"namespace":{"type":"string"},"releaseName":{"type":"string"},"configuration":{"type":"object","description":"Required extension-specific configuration object. Use extensions.catalog.get for the installed extension and pass values matching that extension configuration schema.","additionalProperties":true},"dryRun":{"type":"boolean","description":"When true, validate and return the upgrade plan without mutating Kubernetes."}},"additionalProperties":false}"#, + }, + ToolDefinition { + name: "workloads.delete", + title: "Delete Workload", + description: "Delete a workload, preserving PVCs by default.", + required_scope: Scope::WorkloadsDelete, + approval_class: ApprovalClass::Destructive, + read_only: false, + destructive: true, + input_schema: r#"{"type":"object","required":["namespace","releaseName"],"properties":{"namespace":{"type":"string"},"releaseName":{"type":"string"},"dryRun":{"type":"boolean","description":"When true, validate and return the delete plan without mutating Kubernetes. Defaults to true."},"deletePvcs":{"type":"boolean","description":"Delete candidate PVCs after Helm uninstall. Defaults to false."},"approvalId":{"type":"string"}},"additionalProperties":false}"#, + }, + ] +} + +pub(crate) fn dynamic_definitions( + installed_extension_ids: &BTreeSet, +) -> Vec { + let mut definitions = Vec::new(); + + if installed_extension_ids.contains(registry::DOLOS_EXTENSION_ID) { + definitions.extend(dolos::definitions().iter().copied()); + } + + definitions +} diff --git a/mcp-server/src/tools/workloads/outputs.rs b/mcp-server/src/tools/workloads/outputs.rs new file mode 100644 index 0000000..4cfe965 --- /dev/null +++ b/mcp-server/src/tools/workloads/outputs.rs @@ -0,0 +1,463 @@ +use k8s_openapi::api::core::v1::Service; +use serde::Serialize; + +use crate::catalog::ExtensionCatalog; +use crate::catalog::ExtensionOutputDefinition; +use crate::k8s::HelmReleaseSummary; + +use super::registry; + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WorkloadOutput { + pub name: String, + pub description: String, + pub scope: String, + pub url: String, + pub namespace: String, + pub service_name: String, + pub service_type: Option, + pub port_name: String, + pub port: i32, + pub protocol: String, +} + +pub(crate) fn outputs_for_release( + namespace: &str, + release_name: &str, + release: Option<&HelmReleaseSummary>, + services: &[Service], + catalog: &ExtensionCatalog, +) -> Vec { + let Some(release) = release else { + return vec![]; + }; + let Some(extension) = registry::extension_for_release(release, catalog) else { + return vec![]; + }; + + services + .iter() + .filter(|service| is_release_service(namespace, release_name, service)) + .flat_map(|service| { + extension.outputs.iter().flat_map(move |output| { + let internal = output_entries( + namespace, + service, + output, + "internal", + internal_hosts(namespace, service), + ); + let external = output_entries( + namespace, + service, + output, + "external", + external_hosts(service), + ); + internal.into_iter().chain(external).collect::>() + }) + }) + .collect() +} + +fn is_release_service(namespace: &str, release_name: &str, service: &Service) -> bool { + service.metadata.namespace.as_deref() == Some(namespace) + && service + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get("app.kubernetes.io/instance")) + .is_some_and(|instance| instance == release_name) + && service + .spec + .as_ref() + .and_then(|spec| spec.cluster_ip.as_deref()) + != Some("None") +} + +fn output_entries( + namespace: &str, + service: &Service, + output: &ExtensionOutputDefinition, + scope: &str, + hosts: Vec, +) -> Vec { + let service_name = service.metadata.name.as_deref().unwrap_or_default(); + let service_type = service.spec.as_ref().and_then(|spec| spec.type_.clone()); + let Some(port) = service + .spec + .as_ref() + .and_then(|spec| spec.ports.as_ref()) + .and_then(|ports| { + ports + .iter() + .find(|port| port.name.as_deref() == Some(output.port_name.as_str())) + }) + else { + return vec![]; + }; + + hosts + .into_iter() + .map(|host| WorkloadOutput { + name: output.name.clone(), + description: output.description.clone(), + scope: scope.to_string(), + url: format!("{}{}:{}", output_scheme(&output.protocol), host, port.port), + namespace: namespace.to_string(), + service_name: service_name.to_string(), + service_type: service_type.clone(), + port_name: output.port_name.clone(), + port: port.port, + protocol: output.protocol.clone(), + }) + .collect() +} + +fn internal_hosts(namespace: &str, service: &Service) -> Vec { + service + .metadata + .name + .as_ref() + .map(|name| vec![format!("{name}.{namespace}.svc.cluster.local")]) + .unwrap_or_default() +} + +fn external_hosts(service: &Service) -> Vec { + if service.spec.as_ref().and_then(|spec| spec.type_.as_deref()) != Some("LoadBalancer") { + return vec![]; + } + + service + .status + .as_ref() + .and_then(|status| status.load_balancer.as_ref()) + .and_then(|load_balancer| load_balancer.ingress.as_ref()) + .map(|ingress| { + ingress + .iter() + .filter_map(|entry| entry.ip.clone().or_else(|| entry.hostname.clone())) + .collect::>() + }) + .unwrap_or_default() +} + +fn output_scheme(protocol: &str) -> &'static str { + match protocol { + "HTTP" => "http://", + "WebSocket" => "ws://", + "gRPC" => "grpc://", + _ => "tcp://", + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use k8s_openapi::api::core::v1::LoadBalancerIngress; + use k8s_openapi::api::core::v1::LoadBalancerStatus; + use k8s_openapi::api::core::v1::ServicePort; + use k8s_openapi::api::core::v1::ServiceSpec; + use k8s_openapi::api::core::v1::ServiceStatus; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + + use crate::catalog::ExtensionCatalog; + use crate::k8s::HelmChartSummary; + use crate::k8s::HelmReleaseSummary; + + use super::*; + + #[test] + fn relay_outputs_include_internal_n2n_and_n2c() { + let catalog = ExtensionCatalog::embedded(); + let release = helm_release(Some("cardano-node")); + let service = service_with_ports( + "relay-preview-cardano-node", + "cardano", + "relay-preview", + "ClusterIP", + vec![("n2n", 3000), ("n2c", 3307), ("metrics", 12798)], + vec![], + ); + + let outputs = outputs_for_release( + "cardano", + "relay-preview", + Some(&release), + &[service], + &catalog, + ); + + assert_eq!(outputs.len(), 2); + assert!(outputs.iter().any(|output| { + output.name == "n2n" + && output.scope == "internal" + && output.url == "tcp://relay-preview-cardano-node.cardano.svc.cluster.local:3000" + })); + assert!(outputs.iter().any(|output| { + output.name == "n2c" + && output.url == "tcp://relay-preview-cardano-node.cardano.svc.cluster.local:3307" + })); + } + + #[test] + fn dolos_outputs_include_internal_endpoints() { + let catalog = ExtensionCatalog::embedded(); + let release = helm_release(Some("dolos")); + let service = service_with_ports( + "dolos-preview", + "cardano-preview", + "relay-preview", + "ClusterIP", + vec![ + ("grpc", 50051), + ("minibf", 3001), + ("minikupo", 1442), + ("trp", 8164), + ], + vec![], + ); + + let outputs = outputs_for_release( + "cardano-preview", + "relay-preview", + Some(&release), + &[service], + &catalog, + ); + + assert_eq!(outputs.len(), 4); + assert!(outputs.iter().any(|output| output.name == "trp")); + assert!(outputs.iter().any(|output| output.name == "blockfrost")); + assert!(outputs.iter().any(|output| output.name == "kupo")); + assert!(outputs.iter().any(|output| { + output.name == "utxorpc" + && output.url == "grpc://dolos-preview.cardano-preview.svc.cluster.local:50051" + })); + } + + #[test] + fn hydra_outputs_include_api_websocket_p2p_and_monitoring() { + let catalog = ExtensionCatalog::embedded(); + let release = helm_release(Some("hydra-node")); + let service = service_with_ports( + "hydra-preview-hydra-node", + "hydra", + "hydra-preview", + "ClusterIP", + vec![("api", 4001), ("p2p", 5001), ("monitoring", 6001)], + vec![], + ); + + let outputs = outputs_for_release( + "hydra", + "hydra-preview", + Some(&release), + &[service], + &catalog, + ); + + assert_eq!(outputs.len(), 4); + assert!(outputs.iter().any(|output| { + output.name == "api" + && output.url == "http://hydra-preview-hydra-node.hydra.svc.cluster.local:4001" + })); + assert!(outputs.iter().any(|output| { + output.name == "ws" + && output.url == "ws://hydra-preview-hydra-node.hydra.svc.cluster.local:4001" + })); + assert!(outputs.iter().any(|output| output.name == "p2p")); + assert!(outputs.iter().any(|output| output.name == "monitoring")); + } + + #[test] + fn outputs_filter_by_namespace() { + let catalog = ExtensionCatalog::embedded(); + let release = helm_release(Some("dolos")); + let wrong_namespace = service_with_ports( + "dolos-preview", + "other", + "relay-preview", + "ClusterIP", + vec![ + ("grpc", 50051), + ("minibf", 3001), + ("minikupo", 1442), + ("trp", 8164), + ], + vec![], + ); + + let outputs = outputs_for_release( + "cardano-preview", + "relay-preview", + Some(&release), + &[wrong_namespace], + &catalog, + ); + + assert!(outputs.is_empty()); + } + + #[test] + fn load_balancer_service_adds_external_outputs() { + let catalog = ExtensionCatalog::embedded(); + let release = helm_release(Some("dolos")); + let service = service_with_ports( + "dolos-preview", + "cardano-preview", + "relay-preview", + "LoadBalancer", + vec![ + ("grpc", 50051), + ("minibf", 3001), + ("minikupo", 1442), + ("trp", 8164), + ], + vec![load_balancer_ingress(Some("203.0.113.10"), None)], + ); + + let outputs = outputs_for_release( + "cardano-preview", + "relay-preview", + Some(&release), + &[service], + &catalog, + ); + + assert!(outputs.iter().any(|output| { + output.name == "blockfrost" + && output.scope == "external" + && output.url == "http://203.0.113.10:3001" + })); + } + + #[test] + fn load_balancer_hostname_is_used_for_external_outputs() { + let catalog = ExtensionCatalog::embedded(); + let release = helm_release(Some("cardano-node")); + let service = service_with_ports( + "relay-preview-cardano-node", + "cardano", + "relay-preview", + "LoadBalancer", + vec![("n2n", 3000), ("n2c", 3307)], + vec![load_balancer_ingress(None, Some("relay.example.com"))], + ); + + let outputs = outputs_for_release( + "cardano", + "relay-preview", + Some(&release), + &[service], + &catalog, + ); + + assert!(outputs.iter().any(|output| { + output.name == "n2n" + && output.scope == "external" + && output.url == "tcp://relay.example.com:3000" + })); + } + + #[test] + fn headless_services_are_not_reported_as_outputs() { + let catalog = ExtensionCatalog::embedded(); + let release = helm_release(Some("dolos")); + let mut service = service_with_ports( + "dolos-preview-headless", + "cardano-preview", + "relay-preview", + "ClusterIP", + vec![ + ("grpc", 50051), + ("minibf", 3001), + ("minikupo", 1442), + ("trp", 8164), + ], + vec![], + ); + service.spec.as_mut().unwrap().cluster_ip = Some("None".to_string()); + + let outputs = outputs_for_release( + "cardano-preview", + "relay-preview", + Some(&release), + &[service], + &catalog, + ); + + assert!(outputs.is_empty()); + } + + fn helm_release(chart_name: Option<&str>) -> HelmReleaseSummary { + HelmReleaseSummary { + name: "relay-preview".to_string(), + namespace: "cardano".to_string(), + revision: 1, + status: Some("deployed".to_string()), + chart: HelmChartSummary { + name: chart_name.map(str::to_string), + version: Some("0.1.0".to_string()), + }, + app_version: Some("11.0.1".to_string()), + description: None, + updated: None, + secret_name: Some("sh.helm.release.v1.relay-preview.v1".to_string()), + config: None, + } + } + + fn service_with_ports( + name: &str, + namespace: &str, + release_name: &str, + service_type: &str, + ports: Vec<(&str, i32)>, + ingress: Vec, + ) -> Service { + Service { + metadata: ObjectMeta { + name: Some(name.to_string()), + namespace: Some(namespace.to_string()), + labels: Some(BTreeMap::from([( + "app.kubernetes.io/instance".to_string(), + release_name.to_string(), + )])), + ..Default::default() + }, + spec: Some(ServiceSpec { + type_: Some(service_type.to_string()), + cluster_ip: Some("10.0.0.1".to_string()), + ports: Some( + ports + .into_iter() + .map(|(name, port)| ServicePort { + name: Some(name.to_string()), + port, + protocol: Some("TCP".to_string()), + ..Default::default() + }) + .collect(), + ), + ..Default::default() + }), + status: Some(ServiceStatus { + load_balancer: (!ingress.is_empty()).then_some(LoadBalancerStatus { + ingress: Some(ingress), + }), + ..Default::default() + }), + } + } + + fn load_balancer_ingress(ip: Option<&str>, hostname: Option<&str>) -> LoadBalancerIngress { + LoadBalancerIngress { + ip: ip.map(str::to_string), + hostname: hostname.map(str::to_string), + ..Default::default() + } + } +} diff --git a/mcp-server/src/tools/workloads/registry.rs b/mcp-server/src/tools/workloads/registry.rs new file mode 100644 index 0000000..d6b72de --- /dev/null +++ b/mcp-server/src/tools/workloads/registry.rs @@ -0,0 +1,40 @@ +use std::collections::BTreeSet; + +use crate::catalog::ExtensionCatalog; +use crate::catalog::ExtensionDefinition; +use crate::k8s::HelmReleaseSummary; + +pub(crate) const CARDANO_NODE_CHART_NAME: &str = "cardano-node"; +pub(crate) const CARDANO_NODE_RELAY_EXTENSION_ID: &str = "cardano-node-relay"; +pub(crate) const CARDANO_NODE_METRICS_CONTAINER: &str = "cardano-node"; +pub(crate) const DOLOS_CHART_NAME: &str = "dolos"; +pub(crate) const DOLOS_EXTENSION_ID: &str = "dolos"; +pub(crate) const DOLOS_METRICS_CONTAINER: &str = "dolos"; +pub(crate) const HYDRA_NODE_CHART_NAME: &str = "hydra-node"; +pub(crate) const HYDRA_NODE_EXTENSION_ID: &str = "hydra-node"; +pub(crate) const HYDRA_NODE_METRICS_CONTAINER: &str = "hydra-node"; + +pub(crate) fn extension_for_release<'a>( + release: &HelmReleaseSummary, + catalog: &'a ExtensionCatalog, +) -> Option<&'a ExtensionDefinition> { + catalog.get(extension_id_for_chart(release.chart.name.as_deref())?) +} + +pub(crate) fn extension_id_for_chart(chart_name: Option<&str>) -> Option<&'static str> { + match chart_name { + Some(CARDANO_NODE_CHART_NAME) => Some(CARDANO_NODE_RELAY_EXTENSION_ID), + Some(DOLOS_CHART_NAME) => Some(DOLOS_EXTENSION_ID), + Some(HYDRA_NODE_CHART_NAME) => Some(HYDRA_NODE_EXTENSION_ID), + _ => None, + } +} + +pub(crate) fn installed_extension_ids(releases: &[HelmReleaseSummary]) -> BTreeSet { + releases + .iter() + .filter(|release| release.status.as_deref() == Some("deployed")) + .filter_map(|release| extension_id_for_chart(release.chart.name.as_deref())) + .map(str::to_string) + .collect() +} diff --git a/mcp-server/src/tools/workloads/upgrade.rs b/mcp-server/src/tools/workloads/upgrade.rs new file mode 100644 index 0000000..47b18ad --- /dev/null +++ b/mcp-server/src/tools/workloads/upgrade.rs @@ -0,0 +1,404 @@ +use rmcp::model::{CallToolResult, JsonObject}; +use serde_json::{Value, json}; + +use crate::catalog::ExtensionCatalog; +use crate::helm::{self, HelmChartRef, HelmUpgradePlan}; +use crate::k8s::{HelmReleaseDiscovery, KubernetesClient}; +use crate::tools::common::{kube_error, success, tool_error}; + +use super::{install, registry}; + +const TOOL_NAME: &str = "workloads.upgrade"; + +pub(crate) async fn upgrade( + arguments: Option<&JsonObject>, + catalog: &ExtensionCatalog, +) -> CallToolResult { + let namespace = match required_string(arguments, "namespace") { + Ok(value) => value, + Err(error) => return error, + }; + let release_name = match required_string(arguments, "releaseName") { + Ok(value) => value, + Err(error) => return error, + }; + let dry_run = optional_bool(arguments, "dryRun").unwrap_or(true); + let configuration = match required_object(arguments, "configuration") { + Ok(value) => value, + Err(error) => return error, + }; + + let client = match KubernetesClient::try_default().await { + Ok(client) => client, + Err(error) => return kube_error(TOOL_NAME, error), + }; + let release = match HelmReleaseDiscovery::new(client) + .get_latest(&namespace, &release_name) + .await + { + Ok(Some(release)) => release, + Ok(None) => { + return tool_error( + "not_found", + format!("workload release not found: {namespace}/{release_name}"), + json!({ "namespace": namespace, "releaseName": release_name }), + ); + } + Err(error) => { + return tool_error( + "helm_release_discovery_error", + error.to_string(), + json!({ "tool": TOOL_NAME, "namespace": namespace, "releaseName": release_name }), + ); + } + }; + let extension = match registry::extension_for_release(&release, catalog) { + Some(extension) => extension, + None => { + return tool_error( + "unsupported_workload", + "workloads.upgrade only upgrades catalog-managed extension releases", + json!({ + "namespace": namespace, + "releaseName": release_name, + "chart": release.chart, + }), + ); + } + }; + + if let Err(error) = validate_configuration_schema(&configuration, &extension.configuration) { + return error; + } + if configuration.get("namespace").and_then(Value::as_str) != Some(namespace.as_str()) { + return tool_error( + "invalid_arguments", + "configuration.namespace must match namespace", + json!({ "namespace": namespace, "configurationNamespace": configuration.get("namespace") }), + ); + } + + let resolved_configuration = install::apply_defaults(extension, Value::Object(configuration)); + let resolution = match install::resolve_configuration( + extension, + &namespace, + resolved_configuration, + dry_run, + ) + .await + { + Ok(resolution) => resolution, + Err(error) => return error, + }; + let helm_values = + install::planned_helm_values(extension, &release_name, &resolution.configuration); + let chart = HelmChartRef { + chart: extension.chart.clone(), + version: extension.default_version.clone(), + }; + + if dry_run { + return success(json!({ + "action": "upgrade", + "dryRun": true, + "wouldMutate": false, + "release": { + "name": release_name, + "namespace": namespace, + "current": release, + }, + "extension": { + "id": extension.id, + "name": extension.name, + "version": extension.default_version, + }, + "chart": chart, + "resolvedConfiguration": resolution.configuration, + "helmValues": helm_values, + "availableStorageClasses": resolution.available_storage_classes, + "recommendedStorageClasses": resolution.recommended_storage_classes, + "notes": [ + "dry-run planning only; no Kubernetes or Helm mutation was performed", + "raw Helm values are rejected by the extension configuration schema", + "upgrade requires an existing release and will not install missing releases" + ], + })); + } + + let helm_result = match helm::upgrade(&HelmUpgradePlan { + release_name: release_name.clone(), + namespace: namespace.clone(), + chart: chart.clone(), + values: helm_values.clone(), + }) + .await + { + Ok(result) => result, + Err(error) => { + let helm_details = match &error { + helm::HelmUpgradeError::Failed { + status, + stdout, + stderr, + } => json!({ + "tool": TOOL_NAME, + "extensionId": extension.id, + "releaseName": release_name, + "namespace": namespace, + "status": status, + "stdout": stdout, + "stderr": stderr, + }), + _ => json!({ + "tool": TOOL_NAME, + "extensionId": extension.id, + "releaseName": release_name, + "namespace": namespace, + }), + }; + return tool_error("helm_upgrade_failed", error.to_string(), helm_details); + } + }; + + success(json!({ + "action": "upgrade", + "dryRun": false, + "wouldMutate": true, + "release": { + "name": release_name, + "namespace": namespace, + "previous": release, + }, + "extension": { + "id": extension.id, + "name": extension.name, + "version": extension.default_version, + }, + "chart": chart, + "resolvedConfiguration": resolution.configuration, + "helmValues": helm_values, + "availableStorageClasses": resolution.available_storage_classes, + "recommendedStorageClasses": resolution.recommended_storage_classes, + "helm": helm_result, + "notes": [ + "Helm upgrade completed successfully", + "raw Helm values are rejected by the extension configuration schema" + ], + })) +} + +fn validate_configuration_schema( + values: &JsonObject, + schema: &Value, +) -> Result<(), CallToolResult> { + let schema = schema.as_object().ok_or_else(|| { + tool_error( + "catalog_schema_error", + "extension configuration schema must be an object schema", + json!({}), + ) + })?; + let properties = schema + .get("properties") + .and_then(Value::as_object) + .ok_or_else(|| { + tool_error( + "catalog_schema_error", + "extension configuration schema must define properties", + json!({}), + ) + })?; + + if schema.get("additionalProperties").and_then(Value::as_bool) == Some(false) { + for key in values.keys() { + if !properties.contains_key(key) { + return Err(tool_error( + "invalid_extension_configuration", + format!("unknown extension configuration value: {key}"), + json!({ "field": key }), + )); + } + } + } + + if let Some(required) = schema.get("required").and_then(Value::as_array) { + for field in required.iter().filter_map(Value::as_str) { + if !values.contains_key(field) { + return Err(tool_error( + "invalid_extension_configuration", + format!("missing required extension configuration value: {field}"), + json!({ "field": field }), + )); + } + } + } + + for (key, value) in values { + if let Some(property_schema) = properties.get(key) { + validate_property_value(key, value, property_schema)?; + } + } + + Ok(()) +} + +fn validate_property_value( + name: &str, + value: &Value, + schema: &Value, +) -> Result<(), CallToolResult> { + if let Some(expected_type) = schema.get("type").and_then(Value::as_str) { + let matches = match expected_type { + "boolean" => value.is_boolean(), + "integer" => value.as_i64().is_some(), + "number" => value.as_f64().is_some(), + "object" => value.is_object(), + "string" => value.is_string(), + _ => true, + }; + + if !matches { + return Err(tool_error( + "invalid_extension_configuration", + format!("invalid type for extension configuration value: {name}"), + json!({ + "field": name, + "expectedType": expected_type, + "actualType": value_type_name(value), + }), + )); + } + } + + if let Some(allowed_values) = schema.get("enum").and_then(Value::as_array) + && !allowed_values.iter().any(|allowed| allowed == value) + { + return Err(tool_error( + "invalid_extension_configuration", + format!("unsupported value for extension configuration field: {name}"), + json!({ + "field": name, + "allowedValues": allowed_values, + "actualValue": value, + }), + )); + } + + Ok(()) +} + +fn value_type_name(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +fn required_object( + arguments: Option<&JsonObject>, + name: &str, +) -> Result { + match arguments.and_then(|arguments| arguments.get(name)) { + Some(Value::Object(value)) => Ok(value.clone()), + Some(value) => Err(tool_error( + "invalid_arguments", + format!("expected object argument: {name}"), + json!({ "argument": name, "actualType": value_type_name(value) }), + )), + None => Err(tool_error( + "invalid_arguments", + format!("missing required object argument: {name}"), + json!({ "argument": name }), + )), + } +} + +fn required_string(arguments: Option<&JsonObject>, name: &str) -> Result { + arguments + .and_then(|arguments| arguments.get(name)) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .ok_or_else(|| { + tool_error( + "invalid_arguments", + format!("missing required string argument: {name}"), + json!({ "argument": name }), + ) + }) +} + +fn optional_bool(arguments: Option<&JsonObject>, name: &str) -> Option { + arguments + .and_then(|arguments| arguments.get(name)) + .and_then(Value::as_bool) +} + +#[cfg(test)] +mod tests { + use crate::policy::{ApprovalClass, Scope}; + use crate::tools::workloads; + + use super::*; + + #[test] + fn workload_upgrade_definition_is_mutating_but_not_destructive() { + let definition = workloads::definitions() + .iter() + .find(|definition| definition.name == "workloads.upgrade") + .unwrap(); + + assert!(!definition.destructive); + assert!(!definition.read_only); + assert_eq!(definition.required_scope, Scope::WorkloadsUpgrade); + assert_eq!(definition.approval_class, ApprovalClass::Mutation); + } + + #[test] + fn missing_configuration_returns_invalid_arguments() { + let mut arguments = JsonObject::new(); + arguments.insert("namespace".to_string(), Value::String("hydra".to_string())); + arguments.insert( + "releaseName".to_string(), + Value::String("hydra-offline".to_string()), + ); + + let error = required_object(Some(&arguments), "configuration").unwrap_err(); + + assert_eq!( + error + .structured_content + .as_ref() + .and_then(|content| content.get("error")), + Some(&Value::String("invalid_arguments".to_string())) + ); + } + + #[test] + fn schema_validation_rejects_unknown_values() { + let schema = json!({ + "type": "object", + "properties": { "namespace": { "type": "string" } }, + "additionalProperties": false + }); + let mut values = JsonObject::new(); + values.insert("namespace".to_string(), Value::String("hydra".to_string())); + values.insert("rawValues".to_string(), json!({})); + + let error = validate_configuration_schema(&values, &schema).unwrap_err(); + + assert_eq!( + error + .structured_content + .as_ref() + .and_then(|content| content.get("error")), + Some(&Value::String( + "invalid_extension_configuration".to_string() + )) + ); + } +} diff --git a/mcp-server/src/vault/client.rs b/mcp-server/src/vault/client.rs new file mode 100644 index 0000000..dd93620 --- /dev/null +++ b/mcp-server/src/vault/client.rs @@ -0,0 +1,363 @@ +use std::env; +use std::fs; + +use reqwest::StatusCode; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; +use serde_json::json; + +use crate::vault::paths::VaultPath; +use crate::vault::redaction::SecretObject; +use crate::vault::redaction::SecretString; +use crate::vault::redaction::sorted_keys; + +const DEFAULT_KV_MOUNT: &str = "kv"; + +#[derive(Clone)] +pub struct VaultClient { + http: reqwest::Client, + addr: String, + token: SecretString, + kv_mount: String, +} + +impl VaultClient { + pub fn from_env() -> Result { + let addr = env::var("VAULT_ADDR").map_err(|_| VaultError::MissingConfig("VAULT_ADDR"))?; + let token = vault_token_from_env()?; + let kv_mount = env::var("VAULT_KV_MOUNT").unwrap_or_else(|_| DEFAULT_KV_MOUNT.to_string()); + + Self::new(addr, token, kv_mount) + } + + pub fn new( + addr: impl Into, + token: SecretString, + kv_mount: impl Into, + ) -> Result { + let addr = addr.into().trim_end_matches('/').to_string(); + let kv_mount = kv_mount.into().trim_matches('/').to_string(); + + if addr.is_empty() { + return Err(VaultError::MissingConfig("VAULT_ADDR")); + } + + if kv_mount.is_empty() || kv_mount.contains('/') { + return Err(VaultError::InvalidConfig("VAULT_KV_MOUNT")); + } + + if token.expose_secret().trim().is_empty() { + return Err(VaultError::MissingConfig("VAULT_TOKEN")); + } + + if token.expose_secret() == "root" { + return Err(VaultError::RootTokenRejected); + } + + Ok(Self { + http: reqwest::Client::new(), + addr, + token, + kv_mount, + }) + } + + pub async fn runtime_metadata( + &self, + path: &VaultPath, + ) -> Result { + let metadata_response = self.get_metadata(path).await?; + let secret_response = self.get_secret(path).await?; + + Ok(VaultSecretMetadata { + path: path.as_str().to_string(), + exists: metadata_response.is_some() || secret_response.is_some(), + key_names: secret_response + .as_ref() + .map(|secret| sorted_keys(secret.data.data.as_object())) + .unwrap_or_default(), + key_names_available: secret_response.is_some(), + current_version: metadata_response + .as_ref() + .and_then(|metadata| metadata.data.current_version), + }) + } + + pub async fn write_runtime_secret( + &self, + path: &VaultPath, + secret: &SecretObject, + mode: WriteMode, + ) -> Result { + let data = match mode { + WriteMode::Replace => secret.expose_secret().clone(), + WriteMode::Patch => { + let mut existing = self + .get_secret(path) + .await? + .map(|secret| secret.data.data) + .unwrap_or_else(|| json!({})); + merge_secret_objects(&mut existing, secret.expose_secret())?; + existing + } + }; + let written_keys = sorted_keys(data.as_object()); + let response = self.put_secret(path, &data).await?; + + Ok(VaultWriteReceipt { + path: path.as_str().to_string(), + written_keys, + version: response.and_then(|response| response.data.version), + }) + } + + async fn get_metadata( + &self, + path: &VaultPath, + ) -> Result, VaultError> { + self.get_optional_json(self.metadata_url(path)).await + } + + async fn get_secret( + &self, + path: &VaultPath, + ) -> Result, VaultError> { + self.get_optional_json(self.data_url(path)).await + } + + async fn put_secret( + &self, + path: &VaultPath, + data: &Value, + ) -> Result, VaultError> { + let response = self + .http + .post(self.data_url(path)) + .header("X-Vault-Token", self.token.expose_secret()) + .json(&json!({ "data": data })) + .send() + .await?; + + if !response.status().is_success() { + return Err(VaultError::Status(response.status())); + } + + if response.status() == StatusCode::NO_CONTENT { + return Ok(None); + } + + Ok(Some(response.json().await?)) + } + + async fn get_optional_json Deserialize<'de>>( + &self, + url: String, + ) -> Result, VaultError> { + let response = self + .http + .get(url) + .header("X-Vault-Token", self.token.expose_secret()) + .send() + .await?; + + if response.status() == StatusCode::NOT_FOUND { + return Ok(None); + } + + if !response.status().is_success() { + return Err(VaultError::Status(response.status())); + } + + Ok(Some(response.json().await?)) + } + + fn metadata_url(&self, path: &VaultPath) -> String { + format!( + "{}/v1/{}/metadata/{}", + self.addr, + self.kv_mount, + path.as_str() + ) + } + + fn data_url(&self, path: &VaultPath) -> String { + format!("{}/v1/{}/data/{}", self.addr, self.kv_mount, path.as_str()) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum WriteMode { + Replace, + Patch, +} + +impl WriteMode { + pub fn parse(value: Option<&str>) -> Result { + match value.unwrap_or("patch") { + "replace" => Ok(Self::Replace), + "patch" => Ok(Self::Patch), + _ => Err(VaultError::InvalidWriteMode), + } + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VaultSecretMetadata { + pub path: String, + pub exists: bool, + pub key_names: Vec, + pub key_names_available: bool, + pub current_version: Option, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VaultWriteReceipt { + pub path: String, + pub written_keys: Vec, + pub version: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum VaultError { + #[error("missing Vault configuration: {0}")] + MissingConfig(&'static str), + #[error("invalid Vault configuration: {0}")] + InvalidConfig(&'static str), + #[error("Vault root token is not allowed")] + RootTokenRejected, + #[error("invalid Vault write mode")] + InvalidWriteMode, + #[error("Vault path is invalid: {0}")] + Path(#[from] crate::vault::paths::VaultPathError), + #[error("Vault secret value is invalid: {0}")] + SecretValue(#[from] crate::vault::redaction::SecretValueError), + #[error("Vault request failed with status {0}")] + Status(StatusCode), + #[error("Vault HTTP request failed")] + Http(#[from] reqwest::Error), + #[error("failed to read Vault token file")] + TokenFile(#[from] std::io::Error), +} + +#[derive(Debug, Deserialize)] +struct VaultSecretResponse { + data: VaultSecretEnvelope, +} + +#[derive(Debug, Deserialize)] +struct VaultSecretEnvelope { + data: Value, +} + +#[derive(Debug, Deserialize)] +struct VaultMetadataResponse { + data: VaultMetadataEnvelope, +} + +#[derive(Debug, Deserialize)] +struct VaultMetadataEnvelope { + current_version: Option, +} + +#[derive(Debug, Deserialize)] +struct VaultWriteResponse { + data: VaultWriteEnvelope, +} + +#[derive(Debug, Deserialize)] +struct VaultWriteEnvelope { + version: Option, +} + +fn vault_token_from_env() -> Result { + if let Ok(token) = env::var("VAULT_TOKEN") { + return Ok(SecretString::new(token)); + } + + if let Ok(path) = env::var("VAULT_TOKEN_FILE") { + return Ok(SecretString::new( + fs::read_to_string(path)?.trim().to_string(), + )); + } + + Err(VaultError::MissingConfig("VAULT_TOKEN or VAULT_TOKEN_FILE")) +} + +fn merge_secret_objects(existing: &mut Value, patch: &Value) -> Result<(), VaultError> { + let existing = existing + .as_object_mut() + .ok_or(crate::vault::redaction::SecretValueError::NotObject)?; + let patch = patch + .as_object() + .ok_or(crate::vault::redaction::SecretValueError::NotObject)?; + + for (key, value) in patch { + existing.insert(key.clone(), value.clone()); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::vault::paths::VaultPath; + + #[test] + fn rejects_root_token() { + let error = match VaultClient::new("http://127.0.0.1:8200", SecretString::new("root"), "kv") + { + Ok(_) => panic!("root token should be rejected"), + Err(error) => error, + }; + + assert!(matches!(error, VaultError::RootTokenRejected)); + } + + #[test] + fn write_mode_defaults_to_patch() { + assert_eq!(WriteMode::parse(None).unwrap(), WriteMode::Patch); + assert_eq!( + WriteMode::parse(Some("replace")).unwrap(), + WriteMode::Replace + ); + assert!(matches!( + WriteMode::parse(Some("delete")), + Err(VaultError::InvalidWriteMode) + )); + } + + #[test] + fn patch_merge_preserves_existing_keys() { + let mut existing = json!({ "a": "one", "b": "two" }); + let patch = json!({ "b": "changed", "c": "three" }); + + merge_secret_objects(&mut existing, &patch).unwrap(); + + assert_eq!( + existing, + json!({ "a": "one", "b": "changed", "c": "three" }) + ); + } + + #[test] + fn client_builds_runtime_kv_v2_urls() { + let client = + VaultClient::new("http://vault:8200/", SecretString::new("hvs.token"), "kv").unwrap(); + let path = VaultPath::runtime("runtime/cardano-node/mainnet").unwrap(); + + assert_eq!( + client.data_url(&path), + "http://vault:8200/v1/kv/data/runtime/cardano-node/mainnet" + ); + assert_eq!( + client.metadata_url(&path), + "http://vault:8200/v1/kv/metadata/runtime/cardano-node/mainnet" + ); + } +} diff --git a/mcp-server/src/vault/mod.rs b/mcp-server/src/vault/mod.rs new file mode 100644 index 0000000..77ef7c6 --- /dev/null +++ b/mcp-server/src/vault/mod.rs @@ -0,0 +1,13 @@ +pub mod client; +pub mod paths; +pub mod redaction; + +pub use client::VaultClient; +pub use client::VaultError; +pub use client::VaultSecretMetadata; +pub use client::VaultWriteReceipt; +pub use client::WriteMode; +pub use paths::VaultPath; +pub use paths::VaultPathKind; +pub use redaction::SecretObject; +pub use redaction::SecretString; diff --git a/mcp-server/src/vault/paths.rs b/mcp-server/src/vault/paths.rs new file mode 100644 index 0000000..9d3013b --- /dev/null +++ b/mcp-server/src/vault/paths.rs @@ -0,0 +1,120 @@ +use std::fmt; + +const MAX_PATH_LEN: usize = 512; + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum VaultPathKind { + Runtime, + Operator, +} + +#[derive(Clone, Eq, PartialEq)] +pub struct VaultPath { + kind: VaultPathKind, + path: String, +} + +impl VaultPath { + pub fn runtime(path: &str) -> Result { + Self::parse(path, VaultPathKind::Runtime) + } + + pub fn operator(path: &str) -> Result { + Self::parse(path, VaultPathKind::Operator) + } + + pub fn kind(&self) -> VaultPathKind { + self.kind + } + + pub fn as_str(&self) -> &str { + &self.path + } + + fn parse(path: &str, kind: VaultPathKind) -> Result { + let path = path.trim(); + let expected_prefix = match kind { + VaultPathKind::Runtime => "runtime/", + VaultPathKind::Operator => "operator/", + }; + + if path.is_empty() || path.len() > MAX_PATH_LEN { + return Err(VaultPathError::Invalid); + } + + if !path.starts_with(expected_prefix) { + return Err(VaultPathError::NotAllowed); + } + + if path.starts_with('/') || path.ends_with('/') || path.contains("//") { + return Err(VaultPathError::Invalid); + } + + for segment in path.split('/') { + if segment.is_empty() || segment == "." || segment == ".." { + return Err(VaultPathError::Invalid); + } + + if !segment.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') + }) { + return Err(VaultPathError::Invalid); + } + } + + Ok(Self { + kind, + path: path.to_string(), + }) + } +} + +impl fmt::Debug for VaultPath { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VaultPath") + .field("kind", &self.kind) + .field("path", &self.path) + .finish() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum VaultPathError { + #[error("Vault path is outside the allowed prefix")] + NotAllowed, + #[error("Vault path is invalid")] + Invalid, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_path_accepts_runtime_prefix() { + let path = VaultPath::runtime("runtime/cardano-node/mainnet-bp/block-producer").unwrap(); + + assert_eq!(path.kind(), VaultPathKind::Runtime); + assert_eq!( + path.as_str(), + "runtime/cardano-node/mainnet-bp/block-producer" + ); + } + + #[test] + fn runtime_path_rejects_operator_or_traversal_paths() { + assert!(matches!( + VaultPath::runtime("operator/root"), + Err(VaultPathError::NotAllowed) + )); + assert!(matches!( + VaultPath::runtime("runtime/../operator/root"), + Err(VaultPathError::Invalid) + )); + assert!(matches!( + VaultPath::runtime("kv/data/runtime/foo"), + Err(VaultPathError::NotAllowed) + )); + } +} diff --git a/mcp-server/src/vault/redaction.rs b/mcp-server/src/vault/redaction.rs new file mode 100644 index 0000000..1d0661e --- /dev/null +++ b/mcp-server/src/vault/redaction.rs @@ -0,0 +1,104 @@ +use std::fmt; + +use serde_json::Map; +use serde_json::Value; + +#[derive(Clone, Eq, PartialEq)] +pub struct SecretString(String); + +impl SecretString { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn expose_secret(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for SecretString { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("[REDACTED]") + } +} + +impl fmt::Display for SecretString { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("[REDACTED]") + } +} + +#[derive(Clone)] +pub struct SecretObject { + value: Value, +} + +impl SecretObject { + pub fn new(value: Value) -> Result { + if !value.is_object() { + return Err(SecretValueError::NotObject); + } + + Ok(Self { value }) + } + + pub fn expose_secret(&self) -> &Value { + &self.value + } + + pub fn key_names(&self) -> Vec { + sorted_keys(self.value.as_object()) + } +} + +impl fmt::Debug for SecretObject { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SecretObject") + .field("keys", &self.key_names()) + .field("values", &"[REDACTED]") + .finish() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum SecretValueError { + #[error("secret value must be a JSON object")] + NotObject, +} + +pub fn sorted_keys(object: Option<&Map>) -> Vec { + let mut keys = object + .map(|object| object.keys().cloned().collect::>()) + .unwrap_or_default(); + keys.sort(); + keys +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn secret_string_debug_and_display_are_redacted() { + let secret = SecretString::new("plaintext"); + + assert_eq!(format!("{secret:?}"), "[REDACTED]"); + assert_eq!(secret.to_string(), "[REDACTED]"); + assert_eq!(secret.expose_secret(), "plaintext"); + } + + #[test] + fn secret_object_debug_redacts_values_but_keeps_keys() { + let secret = SecretObject::new(json!({ "b": "two", "a": "one" })).unwrap(); + let debug = format!("{secret:?}"); + + assert!(debug.contains("a")); + assert!(debug.contains("b")); + assert!(!debug.contains("one")); + assert!(!debug.contains("two")); + assert_eq!(secret.key_names(), vec!["a".to_string(), "b".to_string()]); + } +} diff --git a/skills/README.md b/skills/README.md index b6aa392..45d3a2a 100644 --- a/skills/README.md +++ b/skills/README.md @@ -8,7 +8,7 @@ Use these skills as follows: - `kubernetes-extension-discovery.md`: discover which Metis extensions are already installed and whether prerequisites like `control-plane` are missing. - `kubernetes-storage-and-prereqs.md`: validate storage classes, PVC behavior, node readiness, and scheduling prerequisites before installs or upgrades. -- `cardano-relay-setup.md`: install and validate a Cardano relay workload first. +- `cardano-relay-setup.md`: install and validate a Cardano relay workload first through the MCP catalog-driven workload lifecycle. - `cardano-stake-pool-from-scratch.md`: guide a human operator through creating a new Cardano stake pool on any supported network, including key custody, metadata, pool registration, Vault runtime upload, and debug-first producer activation. - `cardano-block-producer-upgrade.md`: upgrade an existing relay to block-producer mode from an existing pool, using debug mode first, with explicit producer topology guidance. - `cardano-spo-maintenance-overview.md`: choose the right ongoing SPO maintenance workflow, understand custody boundaries, and apply dry-run rules before touching live Vault or the live ledger. @@ -17,7 +17,11 @@ Use these skills as follows: - `cardano-spo-pool-retirement.md`: retire a stake pool with a deliberate epoch choice and offline signing flow. - `cardano-block-producer-verification.md`: explain what can be verified today from the dashboard and what still requires external confirmation. - `cardano-block-producer-troubleshooting.md`: diagnose cases where a producer looks healthy locally but recent pool blocks are missing from the canonical external chain view. -- `dolos-supernode-deployment.md`: deploy Dolos on the supernode cluster, including storage-class selection, size/display-name prompts, and internal relay upstream selection. +- `dolos-supernode-deployment.md`: deploy Dolos on the supernode cluster through MCP, including storage-class validation, internal relay upstream resolution, and basic Dolos metrics checks. +- `hydra-node-deployment.md`: deploy a Hydra node through MCP using catalog configuration, `hydra.keys.generate`, runtime secret references, and offline or online mode guidance. +- `hydra-head-operations.md`: interact directly with the Hydra HTTP/WebSocket API after discovering and port-forwarding workload outputs. +- `hydra-node-troubleshooting.md`: diagnose Hydra startup, metrics, topology, peer, sync, and stuck-snapshot issues. +- `workload-output-port-forward.md`: expose a discovered workload output locally with `kubectl port-forward` using the Kubernetes context that maps to the Supernode cluster. - `cardano-node-metrics-access.md`: read raw node metrics and the derived Metis metrics payload directly from a running pod via `kubectl exec`. - `supernode-dashboard-port-forward.md`: expose the user-facing `supernode-dashboard` locally with `kubectl port-forward`, with Grafana and Prometheus as supporting debug paths. diff --git a/skills/cardano-relay-setup.md b/skills/cardano-relay-setup.md index 47d9345..0252b90 100644 --- a/skills/cardano-relay-setup.md +++ b/skills/cardano-relay-setup.md @@ -2,21 +2,18 @@ ## Goal -Install and validate a Cardano relay workload first. Relay-first is the base path before any block-producer activation. +Install and validate a Cardano relay workload through the Supernode MCP catalog workflow first. Relay-first is the base path before any block-producer activation. ## Assumptions -- The cluster is reachable with `kubectl` and `helm`. +- The Supernode MCP server is available and can reach Kubernetes. - `control-plane` is expected to exist, but should still be verified when diagnosing problems. -- The operator has selected the target chart: - - `extensions/cardano-node` - - `extensions/apex-fusion` +- The supported relay install path in MCP is the `cardano-node-relay` catalog entry. ## Required Inputs - target namespace - target release name -- target chart path - target network - storage class, if the default is not correct for the cluster - any required tolerations or resource overrides @@ -28,6 +25,7 @@ Install and validate a Cardano relay workload first. Relay-first is the base pat - Open only the ports actually required by the workload and topology. - Use hardened SSH and avoid password-based logins on underlying hosts. - Validate storage classes before installation. +- Use MCP catalog validation and typed workload tools instead of raw Helm values. ## Relay Topology Guidance @@ -58,70 +56,57 @@ For a producer later: ### Discover Installed Extensions -```bash -helm list -A -o json -kubectl get ns -``` +Use MCP resources and tools first: + +- read `supernode://status` +- read `supernode://extensions/catalog` +- read `supernode://extensions/catalog/cardano-node-relay` +- call `extensions.catalog.get` with `extensionId=cardano-node-relay` when you need the structured schema +- call `workloads.list` to inspect existing installed releases ### Check Storage Classes -```bash -kubectl get storageclass -``` +Use `cluster.storage_classes.list`. -If storage classes are unclear, inspect the likely candidate before install: - -```bash -kubectl describe storageclass -``` +Pick a storage class that the cluster actually supports. Do not invent or assume a storage class name. ### Check Cluster Scheduling Basics -```bash -kubectl get nodes -kubectl get events -A --sort-by=.lastTimestamp -``` +Use `cluster.events.list` for recent scheduling or provisioning problems. Fall back to direct `kubectl` inspection only when MCP data is not enough for diagnosis. ## Install Pattern -### Cardano Node +Use `workloads.install` with `extensionId=cardano-node-relay`. -For non-mainnet networks, set the network magic explicitly when needed. +Start with a dry run so the MCP server validates the configuration schema and shows the exact Helm plan it would apply. -Example pattern: +Minimal configuration shape: -```bash -helm install ./extensions/cardano-node \ - --namespace \ - --create-namespace \ - --set node.network= \ - --set node.networkMagic= \ - --set persistence.storageClass= +```json +{ + "extensionId": "cardano-node-relay", + "releaseName": "", + "namespace": "", + "dryRun": true, + "configuration": { + "network": "preview", + "namespace": "", + "storageClass": "" + } +} ``` -If you want the simple default relay networking path, leave topology at the -chart default `image-default` until a producer is attached. - -### Apex Fusion +Safe optional fields exposed by the catalog include: -The chart derives built-in testnet network magic automatically for supported networks. +- `topology` +- `exposeLoadBalancer` +- `imageTag` +- `resources` +- `pvcSize` -Known built-in values: +Do not pass raw Helm values. The MCP workflow rejects them intentionally. -- `vector-testnet` -> `1` -- `prime-testnet` -> `3311` - -Example pattern: - -```bash -helm install ./extensions/apex-fusion \ - --namespace \ - --create-namespace \ - --set node.network= \ - --set persistence.storageClass= -``` - -Only override `node.networkMagic` when the network is non-standard or the chart default does not apply. +If you want the simple default relay networking path, leave topology at the catalog default `image-default` until a producer is attached. ### Relay Topology After A Producer Exists @@ -164,13 +149,11 @@ private path in `localRoots`. ## Post-Install Validation -Check the namespace: +Use MCP workload inspection first: -```bash -kubectl get all -n -kubectl get pvc -n -kubectl get pods -n -``` +- `workloads.get` for the installed release +- `workloads.logs.get` for bounded relay pod logs +- `workloads.metrics.get` for typed relay metrics Healthy relay expectations: @@ -181,6 +164,8 @@ Healthy relay expectations: - sync is progressing - transaction processing is not stuck at zero for long periods on a healthy synced node +If you need lower-level Kubernetes details after the MCP view, then inspect the namespace directly with `kubectl`. + Topology interpretation: - a healthy relay can start on `image-default` before a producer is attached @@ -201,8 +186,9 @@ If the dashboard is available, validate: - PVC pending forever - pod unschedulable because of node taints or resource requests - wrong network magic -- unsupported network value for the selected chart +- unsupported network value for the catalog entry - missing tolerations in constrained clusters +- raw Helm values supplied instead of extension configuration ## Escalation Rule diff --git a/skills/dolos-supernode-deployment.md b/skills/dolos-supernode-deployment.md index 62522bf..fc90553 100644 --- a/skills/dolos-supernode-deployment.md +++ b/skills/dolos-supernode-deployment.md @@ -2,13 +2,13 @@ ## Goal -Deploy Dolos on a supernode cluster so it can act as an internal chain data source for operators and validation workflows. +Deploy Dolos on a supernode cluster through the Supernode MCP catalog workflow so it can act as an internal chain data source for operators and validation workflows. ## Assumptions - `control-plane` is already installed and functional. -- The agent has `kubectl` and `helm` access to the target cluster. -- The Dolos chart in this repository is the deployment source of truth. +- The MCP server can reach Kubernetes and expose the workload lifecycle tools. +- The supported MCP deployment path currently covers Cardano Dolos networks only. ## Inputs The Agent Must Ask For @@ -18,19 +18,20 @@ Always ask the user to confirm these values, even when proposing defaults: - target namespace and release name - storage class - persistent volume size -- display name - whether to use the default upstream relay or override it Recommended defaults to propose: -- display name: - - `Dolos Prime Testnet` for `prime-testnet` - - `Dolos Prime Mainnet` for `prime-mainnet` - - `Dolos ` for other networks - volume size: - - `50Gi` for `prime-testnet` - - `50Gi` for `prime-mainnet` - - `20Gi` only for lightweight/test usage + - `300Gi` for `cardano-mainnet` + - `50Gi` for `cardano-preprod` + - `50Gi` for `cardano-preview` + +Supported networks in MCP: + +- `cardano-mainnet` +- `cardano-preprod` +- `cardano-preview` ## Preflight Checks @@ -38,43 +39,41 @@ Recommended defaults to propose: Use the extension discovery skill if needed. -At minimum: +At minimum, inspect: -```bash -helm list -A -o json -kubectl get ns -kubectl get pods -A -``` +- `supernode://status` +- `supernode://extensions/catalog` +- `supernode://extensions/catalog/dolos` +- `workloads.list` ### 2. Check Storage Classes Always ask the user to choose the storage class after checking what is actually available: -```bash -kubectl get storageclass -``` +Use `cluster.storage_classes.list`. -Do not assume a default storage class name. +Do not assume a default storage class name. Prefer a class the cluster marks as default when appropriate, but still confirm the choice. ### 3. Check For An Existing Relay For The Same Network If a relay already exists on the supernode for the target network, propose using its internal service DNS as the Dolos upstream. -Useful checks: +Use `workloads.list` and existing release metadata first. -```bash -helm list -A -o json -kubectl get svc -A -``` +For the MCP install path, Dolos will automatically try to resolve a same-network Cardano relay when `upstreamAddress` is not provided. + +Resolution behavior: -Common internal relay address patterns: +- same network match only +- prefer a relay in the same namespace +- if still ambiguous, prefer deterministic name ordering + +Common internal relay address pattern for the supported Cardano relay workflow: -- Apex Fusion relay service: - - `-apex-fusion..svc.cluster.local:6000` - Cardano Node relay service: - `-cardano-node..svc.cluster.local:3000` -If a matching relay is already installed, propose that internal URL first. +If a matching relay is already installed, propose that internal URL first. If the user provides `upstreamAddress` explicitly, that wins over auto-discovery. ## Recommended Deployment Flow @@ -82,104 +81,55 @@ If a matching relay is already installed, propose that internal URL first. 2. Check available storage classes. 3. Ask the user to choose the storage class, proposing the most appropriate one found in the cluster. 4. Ask the user to choose the PVC size, proposing a reasonable default. -5. Ask the user to choose the display name, proposing a reasonable default. -6. Check whether a relay already exists for that network. -7. If a relay exists, propose its internal service URL as the upstream. -8. If no internal relay exists, ask the user to confirm the external or custom upstream. -9. Build the Helm values. -10. Install or upgrade the Dolos release. - -## Prime Testnet - -For `prime-testnet`, the Dolos chart now supports a built-in preset with: - -- bundled genesis files -- network magic `3311` -- default upstream relay `relay-0.prime.testnet.apexfusion.org:5521` -- `bootstrap relay` - -Minimal values example: - -```yaml -displayName: "Dolos Prime Testnet" - -dolos: - network: prime-testnet - -persistence: - storageClass: "" - size: 50Gi -``` - -If an internal relay already exists, propose overriding the upstream with that service instead. - -Example using an internal Apex Fusion relay: - -```yaml -displayName: "Dolos Prime Testnet" - -dolos: - network: prime-testnet +5. Check whether a relay already exists for that network. +6. If a relay exists, propose its internal service URL as the upstream. +7. If no internal relay exists, ask the user to confirm the external or custom upstream. +8. Read `supernode://extensions/catalog/dolos` so the current MCP schema and defaults drive the request. +9. Call `workloads.install` with `dryRun: true` first. +10. Review the resolved configuration, including the chosen or discovered upstream and available storage classes. +11. Call `workloads.install` with `dryRun: false` only after the plan is accepted. -config: - upstreamAddress: "prime-testnet-relay-apex-fusion.prime-testnet-relay.svc.cluster.local:6000" - -persistence: - storageClass: "" - size: 100Gi -``` - -## Prime Mainnet - -For `prime-mainnet`, the Dolos chart now supports a built-in preset with: - -- bundled genesis files -- network magic `764824073` -- default upstream relay `relay-g1.prime.mainnet.apexfusion.org:5521` -- `bootstrap relay` - -Minimal values example: - -```yaml -displayName: "Dolos Prime Mainnet" - -dolos: - network: prime-mainnet +## Install Pattern -persistence: - storageClass: "" - size: 50Gi +Use `workloads.install` with `extensionId=dolos`. + +Minimal dry-run example: + +```json +{ + "extensionId": "dolos", + "releaseName": "dolos-preview", + "namespace": "cardano-preview", + "dryRun": true, + "configuration": { + "network": "cardano-preview", + "namespace": "cardano-preview", + "storageClass": "" + } +} ``` -If an internal relay already exists, propose overriding the upstream with that service instead. - -## Install Pattern +Useful optional fields exposed by the catalog: -```bash -helm install ./extensions/dolos \ - --namespace \ - --create-namespace \ - -f my-values.yaml -``` +- `upstreamAddress` +- `imageTag` +- `resources` +- `pvcSize` +- `exposeLoadBalancer` -For upgrades: +Do not use raw Helm values through MCP. The typed extension configuration is the supported interface. -```bash -helm upgrade ./extensions/dolos \ - --namespace \ - -f my-values.yaml -``` +Prime presets still exist in the chart, but they are not part of the supported MCP Dolos workflow right now. ## Validation Checklist ### Kubernetes Checks -```bash -kubectl get pods -n -kubectl get pvc -n -kubectl get svc -n -kubectl describe pod -n -``` +Use MCP workload inspection first: + +- `workloads.get` +- `workloads.logs.get` +- `workloads.metrics.get` Confirm: @@ -196,13 +146,20 @@ Confirm: Confirm the service ports are present: -```bash -kubectl get svc -n -o yaml -``` +- `grpc` +- `minibf` +- `minikupo` +- `trp` ### Basic Functional Check -If `minibf` is exposed through the service, port-forward it locally and verify it responds. +Use `workloads.metrics.get` as the first functional check. The Dolos metrics payload should expose: + +- `blockHeight` +- `epoch` +- `slotNum` + +If you need deeper verification, port-forward `minibf` locally and query it directly. Example: @@ -214,8 +171,9 @@ Then query it from another shell. ## Best Practices -- Always ask the user to choose storage class, size, and display name. +- Always ask the user to choose storage class and confirm the proposed volume size. - Always propose a reasonable default rather than silently choosing one. - Prefer an internal relay URL when a matching relay already exists in the cluster. - Keep Dolos close to the relay path that the operator already trusts. -- Use the built-in `prime-testnet` preset instead of a custom config unless there is a concrete reason not to. +- Treat `upstreamAddress` as required unless MCP can resolve a same-network internal relay automatically. +- Treat bootstrap as always enabled in the supported MCP deployment flow. diff --git a/skills/hydra-head-operations.md b/skills/hydra-head-operations.md new file mode 100644 index 0000000..05e8ad7 --- /dev/null +++ b/skills/hydra-head-operations.md @@ -0,0 +1,113 @@ +# Hydra Head Operations + +## Goal + +Guide an operator or agent through direct Hydra HTTP and WebSocket API interactions after a Hydra workload is deployed. + +## Boundary + +Hydra lifecycle operations are not MCP dynamic tools. Use MCP to discover workload outputs and health. Interact with the Hydra API directly through the approved port-forward workflow. + +Never read signing key values through MCP. Transactions that require wallet signatures should be signed outside MCP by the operator's wallet or CLI workflow. + +## Access The API + +1. Call `workloads.get` for the Hydra workload. +2. Find the `api` or `ws` output. +3. Use `workload-output-port-forward.md` to create a local port-forward to the API service. +4. Default local API address is usually `http://127.0.0.1:4001` and WebSocket address is `ws://127.0.0.1:4001`. + +## Read Head State + +```bash +curl -s http://127.0.0.1:4001/head +curl -s http://127.0.0.1:4001/snapshot +curl -s http://127.0.0.1:4001/snapshot/utxo +curl -s http://127.0.0.1:4001/snapshot/last-seen +curl -s http://127.0.0.1:4001/commits +``` + +Use `snapshot-utxo=no` on WebSocket clients when full UTxO replay would be noisy. + +## Open A Head + +Use the WebSocket API: + +```bash +printf '{"tag":"Init"}\n' | websocat 'ws://127.0.0.1:4001?history=no' +``` + +Offline heads open immediately according to local configuration. Online heads drive L1 transactions and require a working Cardano backend and fuel. + +## Submit An L2 Transaction + +HTTP API: + +```bash +curl -s -X POST http://127.0.0.1:4001/transaction --data @signed-l2-tx.json +``` + +WebSocket API: + +```bash +jq -c '{tag:"NewTx", transaction:.}' signed-l2-tx.json | websocat 'ws://127.0.0.1:4001?history=no' +``` + +The transaction must be valid against the current Hydra ledger state. A node may observe `TxValid` before the transaction is included in a confirmed snapshot. + +## Draft A Deposit + +For online heads, draft a deposit transaction with `/commit`: + +```bash +curl -s -X POST http://127.0.0.1:4001/commit --data @commit-request.json > deposit-tx.json +``` + +The returned transaction must be signed and submitted to L1 outside MCP. After it appears on-chain, the Hydra node should emit deposit-related outputs and eventually make funds available on L2. + +## Recover A Pending Deposit + +List pending deposits: + +```bash +curl -s http://127.0.0.1:4001/commits +``` + +Recover one by transaction ID: + +```bash +curl -s -X DELETE http://127.0.0.1:4001/commits/ +``` + +## Decommit + +Build and sign a transaction that spends UTxO from `/snapshot/utxo`, then submit it: + +```bash +curl -s -X POST http://127.0.0.1:4001/decommit --data @signed-decommit-tx.json +``` + +The decommit will become available on L1 after consensus and L1 processing. + +## Close, Contest, Fanout + +Use WebSocket client inputs: + +```bash +printf '{"tag":"Close"}\n' | websocat 'ws://127.0.0.1:4001?history=no' +printf '{"tag":"Contest"}\n' | websocat 'ws://127.0.0.1:4001?history=no' +printf '{"tag":"Fanout"}\n' | websocat 'ws://127.0.0.1:4001?history=no' +``` + +Use `Close` deliberately. The Hydra API is unauthenticated by default, so exposing it broadly can let anyone close an open head. + +## Sideload Snapshot + +Sideloading is a recovery action for stuck heads and must be coordinated by participants. + +```bash +curl -s http://127.0.0.1:4001/snapshot > snapshot.json +curl -s -X POST http://127.0.0.1:4001/snapshot --data @snapshot.json +``` + +After sideloading, pending transactions are pruned and may need to be resubmitted. diff --git a/skills/hydra-node-deployment.md b/skills/hydra-node-deployment.md new file mode 100644 index 0000000..e36e6b4 --- /dev/null +++ b/skills/hydra-node-deployment.md @@ -0,0 +1,78 @@ +# Hydra Node Deployment + +## Goal + +Deploy a Hydra node as a Metis extension using MCP catalog-driven workload lifecycle tools and runtime secret references. + +## Boundary + +Use MCP for Supernode infrastructure tasks: catalog discovery, Vault runtime writes, workload install, workload status, logs, and metrics. + +Do not use MCP to read Hydra or Cardano signing key values. Do not ask for raw signing key values in normal chat unless the user is explicitly using `vault.runtime.write`, and never echo the values back. + +## Deployment Modes + +Use `mode: offline` for local experiments and kind-cluster validation. Offline mode does not connect to Cardano L1 and requires an offline head seed, initial UTxO, Hydra ledger protocol parameters, a Hydra signing key, and Hydra verification keys. + +Use `mode: online` only when the operator has deliberately selected a Cardano network, supplied Cardano fuel keys, configured a Cardano backend, and understood the mainnet risks. + +## Recommended Offline Workflow + +1. Ask for namespace, release name, storage class, and whether persistence should stay enabled. +2. Ask for the runtime Vault path where the Hydra key pair should be staged and synced by VaultStaticSecret. +3. Prefer `hydra.keys.generate` to create a Hydra signing/verification key pair. The tool writes both `hydra.sk` and `hydra.vk` to Vault and returns only the public verification key payload. +4. If the operator already has key material, call `vault.runtime.write` with a `runtime/...` path and the expected key such as `hydra.sk`; do not echo the value. +5. Prepare public Hydra verification key entries under `hydraVerificationKeys`; these are public and may be inline ConfigMap data. When using `hydra.keys.generate`, use the returned `verificationKey.value`. +6. Use `extensions.catalog.get` for `hydra-node` and build configuration against the schema. +7. Call `workloads.install` with `dryRun: true` first. +8. Inspect `helmValues` for only safe mapped values and no raw secret values. +9. After user approval, call `workloads.install` with `dryRun: false`. +10. Use `workloads.get`, `workloads.logs.get`, and `workloads.metrics.get` to validate startup. + +Minimal offline configuration shape: + +```json +{ + "namespace": "hydra", + "storageClass": "standard", + "mode": "offline", + "hydraSigningKey": { + "source": "vaultStaticSecret", + "vaultPath": "runtime/hydra/demo/hydra-signing", + "key": "hydra.sk" + }, + "hydraVerificationKeys": [ + { + "filename": "hydra.vk", + "value": "" + } + ], + "offline": { + "headSeed": "0001" + } +} +``` + +## Online Cautions + +Online mode can move real funds on L1 through Hydra lifecycle transactions. Mainnet use should be treated as high risk. + +Before online install, confirm: + +- Cardano signing key is runtime material and has only the fuel required for Hydra lifecycle fees. +- Cardano verification key matches the signing key and peers' participant configuration. +- Hydra verification keys match every participant. +- Every participant agrees on contestation period, deposit period, protocol parameters, network, and peer topology. +- The Cardano backend is reachable and synchronized. +- Mainnet contestation period is at least 12 hours unless the user explicitly accepts the risk. + +## Validation + +After install, use MCP: + +- `workloads.get` to inspect services, pods, PVCs, and outputs. +- `workloads.logs.get` for bounded Hydra node logs. +- `workloads.metrics.get` for the derived Metis Hydra metrics payload. +- `cluster.events.list` scoped to the workload namespace if pods, mounts, or probes are unhealthy. + +Expected outputs include `api`, `ws`, `p2p`, and `monitoring`. diff --git a/skills/hydra-node-troubleshooting.md b/skills/hydra-node-troubleshooting.md new file mode 100644 index 0000000..d53277d --- /dev/null +++ b/skills/hydra-node-troubleshooting.md @@ -0,0 +1,93 @@ +# Hydra Node Troubleshooting + +## Goal + +Diagnose Hydra node startup, networking, metrics, and head-progress issues on a Supernode cluster. + +## First Checks + +Use MCP: + +1. `workloads.get` for the release. +2. `workloads.logs.get` for recent Hydra logs. +3. `workloads.metrics.get` for the derived Hydra metrics payload. +4. `cluster.events.list` for scheduling, mount, probe, or image errors. + +## Startup Failures + +Common causes: + +- Missing Hydra signing key Secret or VaultStaticSecret sync. +- Missing Hydra verification key ConfigMap items. +- Offline mode missing `offlineHeadSeed`, initial UTxO, or protocol parameters. +- Online mode missing Cardano signing key, Cardano verification key, Cardano socket, or scripts transaction ID. +- PVC cannot bind because the storage class is wrong or unavailable. +- Hydra image tag and chart arguments are incompatible. + +## Metrics Checks + +Use `workloads.metrics.get` for the derived Metis Hydra metrics payload. The tool runs the chart-mounted `/opt/metis/bin/metrics.sh` script and returns the metrics schema with collection errors. + +If raw Prometheus metric names are needed for interpretation, use the names below as reference points. Prefer `workloads.metrics.get` for normal troubleshooting rather than direct container access. + +Important raw Hydra metrics: + +- `hydra_head_confirmed_tx` +- `hydra_head_inputs` +- `hydra_head_peers_connected` +- `hydra_head_requested_tx` +- `hydra_head_tx_confirmation_time_ms_bucket` +- `hydra_head_tx_confirmation_time_ms_sum` +- `hydra_head_tx_confirmation_time_ms_count` + +## Head Does Not Progress + +First inspect `workloads.metrics.get` for `headStatus`, `lastSeenSnapshotTag`, `peersConnected`, `snapshotNumber`, and `errors`. + +If deeper API state is needed, use `workloads.get` to discover the `api` output and use `workload-output-port-forward.md` to expose it locally. Then query Hydra's API through the port-forward: + +```bash +curl -s http://127.0.0.1:4001/head +curl -s http://127.0.0.1:4001/snapshot +curl -s http://127.0.0.1:4001/snapshot/last-seen +``` + +Likely causes: + +- Peers are not connected. +- Peer topology differs between participants. +- Hydra verification keys do not match expected parties. +- Protocol parameters differ across participants. +- A peer is out of sync with L1. +- A transaction is valid on one participant but invalid on another. + +## Network Or Topology Mismatch + +Hydra topology is static. Participants should agree on the peer set. + +Look for log messages such as: + +- `NetworkVersionMismatch` +- `NetworkClusterIDMismatch` +- `PeerDisconnected` +- `NetworkDisconnected` + +Mirror nodes must use unique node IDs and advertise unique peer addresses while sharing the original party credentials. + +## Out Of Sync + +Online nodes stop accepting unsafe inputs when chain sync is too stale. Check for: + +- `NodeUnsynced` +- `RejectedInputBecauseUnsynced` +- `SyncedStatusReport` + +The unsynced period should be shorter than the contestation period. Mainnet contestation should generally be at least 12 hours. + +## Stuck Snapshot Recovery + +Use `/snapshot/last-seen` to identify whether there is an in-flight snapshot and which peers have not signed. + +Sideloading the last confirmed snapshot can recover a stuck local state, but it must be coordinated by participants. See `hydra-head-operations.md` for the API procedure. + +After sideloading, fix the underlying cause before resubmitting transactions. diff --git a/skills/workload-output-port-forward.md b/skills/workload-output-port-forward.md new file mode 100644 index 0000000..dc0f4e4 --- /dev/null +++ b/skills/workload-output-port-forward.md @@ -0,0 +1,83 @@ +# Workload Output Port Forward + +## Goal + +Help a user expose one discovered workload output locally with `kubectl port-forward` after discovering the output through MCP. + +## Important Constraint + +MCP can discover workload outputs, but it cannot hold open a local `kubectl port-forward` session for the user. The user must run the generated `kubectl` command locally. + +## Discovery Workflow + +1. Ask for the workload namespace and workload name if they are not already known. +2. Call `workloads.get` for that workload. +3. Inspect `outputs` in the workload status. +4. If there are multiple outputs, ask the user which one they want to expose locally. +5. Prefer the `internal` entry for the selected output name when building a port-forward command. + +Expected output fields from MCP: + +- `name` +- `description` +- `scope` +- `namespace` +- `serviceName` +- `serviceType` +- `portName` +- `port` +- `protocol` +- `url` + +## Kubernetes Context + +Use the Kubernetes context that corresponds to the Supernode cluster. + +If the context name is unknown, ask the user to confirm it before generating or running commands. Do not assume the context name is literally `supernode`. + +Command template: + +```bash +kubectl --context -n port-forward service/ : +``` + +## Port Selection Rules + +- Use the selected workload output's `port` as the remote port. +- By default, use the same number for the local port. +- If that local port is already taken or the user wants a different local port, ask for an alternate local port. +- Do not hardcode ports by workload type. Always take them from the discovered workload status. + +## Generic Procedure + +After the user selects an output, produce a command like: + +```bash +kubectl --context -n port-forward service/ : +``` + +Where: + +- `` comes from workload status +- `` comes from the selected output +- `` comes from the selected output's `port` +- `` defaults to the same value unless the user requests otherwise + +## Local Access Guidance + +After the port-forward is established, tell the user how to reach it locally based on `protocol`: + +- `HTTP`: `http://127.0.0.1:` +- `gRPC`: `127.0.0.1:` or `grpc://127.0.0.1:` depending on the client +- `TCP`: `127.0.0.1:` + +Do not assume extra URL paths such as `/blocks/latest` unless the user asks for a specific API call or the selected output description explicitly requires one. + +## Example Agent Behavior + +1. Read `workloads.get` for the workload. +2. Present the discovered `outputs` by name, protocol, scope, and URL. +3. Ask which output to expose if more than one is available. +4. Ask which Kubernetes context corresponds to the Supernode cluster if it is not already known. +5. Build the exact `kubectl --context ... port-forward ...` command from the selected output. +6. Explain the local address the user should hit after the tunnel is up.