From 1cc41700136fd5f827fe9631b8a8547a69a1674d Mon Sep 17 00:00:00 2001 From: Felipe Gonzalez Date: Thu, 21 May 2026 13:03:53 -0300 Subject: [PATCH 1/4] feat: Get catalog from OCI registry --- catalog/README.md | 86 + catalog/extension-catalog.json | 4202 +++++++++++++++++ extensions/control-plane/README.md | 4 + .../stage4-04-statefulset-supernode-mcp.yaml | 8 + extensions/control-plane/values.yaml | 5 + .../src/catalog/apex_fusion_block_producer.rs | 45 - mcp-server/src/catalog/apex_fusion_relay.rs | 67 - .../src/catalog/cardano_block_producer.rs | 45 - mcp-server/src/catalog/cardano_relay.rs | 323 -- mcp-server/src/catalog/dolos.rs | 69 - mcp-server/src/catalog/extension.rs | 96 +- mcp-server/src/catalog/hydra_node.rs | 117 - mcp-server/src/catalog/mod.rs | 357 +- mcp-server/src/catalog/oci.rs | 299 ++ mcp-server/src/catalog/schema.rs | 14 - mcp-server/src/catalog/source.rs | 26 + mcp-server/src/config.rs | 97 + mcp-server/src/errors.rs | 8 + mcp-server/src/mcp.rs | 5 +- mcp-server/src/prompts/catalog.rs | 2 +- mcp-server/src/resources/router.rs | 38 +- mcp-server/src/server.rs | 9 +- mcp-server/src/tools/dynamic.rs | 24 +- mcp-server/src/tools/k8s_summaries.rs | 32 - mcp-server/src/tools/router.rs | 169 +- mcp-server/src/tools/supernode.rs | 4 +- mcp-server/src/tools/workloads/dolos.rs | 2 +- mcp-server/src/tools/workloads/mod.rs | 4 +- mcp-server/src/tools/workloads/outputs.rs | 14 +- mcp-server/src/tools/workloads/registry.rs | 89 +- 30 files changed, 5308 insertions(+), 952 deletions(-) create mode 100644 catalog/README.md create mode 100644 catalog/extension-catalog.json delete mode 100644 mcp-server/src/catalog/apex_fusion_block_producer.rs delete mode 100644 mcp-server/src/catalog/apex_fusion_relay.rs delete mode 100644 mcp-server/src/catalog/cardano_block_producer.rs delete mode 100644 mcp-server/src/catalog/cardano_relay.rs delete mode 100644 mcp-server/src/catalog/dolos.rs delete mode 100644 mcp-server/src/catalog/hydra_node.rs create mode 100644 mcp-server/src/catalog/oci.rs delete mode 100644 mcp-server/src/catalog/schema.rs create mode 100644 mcp-server/src/catalog/source.rs diff --git a/catalog/README.md b/catalog/README.md new file mode 100644 index 0000000..e3e8997 --- /dev/null +++ b/catalog/README.md @@ -0,0 +1,86 @@ +# Extension Catalog + +This directory contains the Supernode MCP extension catalog. + +`extension-catalog.json` is the catalog document consumed by the MCP server. It is also the payload that should be published as the official OCI catalog artifact. + +## Contract + +The catalog document uses this top-level shape: + +```json +{ + "schemaVersion": "supernode.extensionCatalog/v1", + "extensions": [] +} +``` + +Each extension entry describes the MCP-facing extension contract: + +- `id`: canonical extension ID. This must match the Helm chart name. +- `name` and `description`: human-readable product metadata for agents and users. +- `versions` and `defaultVersion`: supported chart versions and the default version MCP installs. +- `configuration`: JSON Schema for accepted workload configuration. +- `secrets`: runtime secret metadata exposed to agents without returning secret values. +- `dependencies`: other extension IDs this extension depends on. +- `metrics`: JSON Schema for metrics returned by `workloads.metrics.get`. +- `metricsCollection`: pod exec metadata used by MCP to collect metrics. +- `outputs`: user-facing endpoints provided by workloads of this extension. +- `chart`: OCI Helm chart reference used by MCP install and upgrade operations. + +## Trusted Sources + +By default, MCP only trusts official Supernode OCI references: + +- Catalog artifacts must be fetched from `oci.supernode.store`. +- Extension charts must be under `oci://oci.supernode.store/extensions/{extensionId}`. + +This is intentional. The catalog influences what agents recommend and what MCP can install or execute for metrics collection. Treat it as a supply-chain input, not a cosmetic document. + +For local development only, MCP can be started with: + +```text +MCP_EXTENSION_CATALOG_ALLOW_UNTRUSTED=true +``` + +Do not enable this in production. An untrusted catalog can point MCP at untrusted charts, mislead agents through descriptions and schemas, or define unsafe metrics collection metadata. + +## Publishing + +The official catalog should be published as a standalone OCI artifact containing `extension-catalog.json` as a JSON layer. + +Recommended layer media type: + +```text +application/vnd.supernode.extension-catalog.v1+json +``` + +Example using `oras`: + +```sh +oras push \ + oci.supernode.store/extension-catalog:0.1.0 \ + extension-catalog.json:application/vnd.supernode.extension-catalog.v1+json +``` + +Production deployments should prefer digest-pinned catalog references when practical: + +```text +oci://oci.supernode.store/extension-catalog@sha256: +``` + +Tag references are supported and convenient for development or release channels, but digest references are safer because they are immutable. + +## Validation + +MCP validates the catalog at load time: + +- `schemaVersion` must be `supernode.extensionCatalog/v1`. +- extension IDs must be unique and non-empty. +- `defaultVersion` must appear in `versions`. +- `configuration` and `metrics` must be JSON objects. +- dependency IDs must exist in the same catalog. +- chart references must be OCI references. +- by default, chart references must point to `oci://oci.supernode.store/extensions/{extensionId}`. + +Longer term, official catalog artifacts should also be signed and verified before MCP accepts them. diff --git a/catalog/extension-catalog.json b/catalog/extension-catalog.json new file mode 100644 index 0000000..2069e83 --- /dev/null +++ b/catalog/extension-catalog.json @@ -0,0 +1,4202 @@ +{ + "schemaVersion": "supernode.extensionCatalog/v1", + "extensions": [ + { + "id": "apex-fusion-block-producer", + "name": "Apex Fusion Block Producer", + "description": "A private Apex Fusion block producer workload with optional managed relays and Vault-synced runtime producer material.", + "versions": [ + "0.1.0" + ], + "defaultVersion": "0.1.0", + "configuration": { + "$schema": "https://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "persistence": { + "additionalProperties": false, + "properties": { + "accessModes": { + "default": [ + "ReadWriteOnce" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "annotations": { + "$ref": "#/definitions/stringMap" + }, + "existingClaim": { + "default": "", + "description": "Advanced, usually not modified. Existing PVC name for the producer. Managed relay persistence always creates PVCs.", + "type": "string" + }, + "size": { + "default": "20Gi", + "pattern": "^[0-9]+(Mi|Gi|Ti)$", + "type": "string" + }, + "storageClass": { + "description": "Required. Kubernetes StorageClass used for the chain-data PVC.", + "minLength": 1, + "type": "string", + "x-supernodeCategory": "required" + } + }, + "required": [ + "storageClass" + ], + "type": "object" + }, + "resourceList": { + "additionalProperties": false, + "properties": { + "cpu": { + "type": "string" + }, + "memory": { + "type": "string" + } + }, + "type": "object" + }, + "resources": { + "additionalProperties": false, + "properties": { + "limits": { + "$ref": "#/definitions/resourceList" + }, + "requests": { + "$ref": "#/definitions/resourceList" + } + }, + "type": "object" + }, + "service": { + "additionalProperties": false, + "description": "Optional. Kubernetes Service settings for the private producer Service. Managed relays are always ClusterIP.", + "properties": { + "annotations": { + "$ref": "#/definitions/stringMap" + }, + "clusterIP": { + "default": "", + "type": "string" + }, + "externalTrafficPolicy": { + "default": "", + "enum": [ + "", + "Cluster", + "Local" + ], + "type": "string" + }, + "labels": { + "$ref": "#/definitions/stringMap" + }, + "loadBalancerIP": { + "default": "", + "type": "string" + }, + "metricsNodePort": { + "default": null, + "type": [ + "integer", + "null" + ] + }, + "metricsPort": { + "default": 12789, + "type": "integer" + }, + "n2cNodePort": { + "default": null, + "type": [ + "integer", + "null" + ] + }, + "n2cPort": { + "default": 3307, + "type": "integer" + }, + "n2nNodePort": { + "default": null, + "type": [ + "integer", + "null" + ] + }, + "n2nPort": { + "default": 3000, + "type": "integer" + }, + "type": { + "default": "ClusterIP", + "enum": [ + "ClusterIP", + "NodePort", + "LoadBalancer" + ], + "type": "string" + } + }, + "type": "object" + }, + "stringMap": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "trustedRelay": { + "additionalProperties": false, + "properties": { + "address": { + "minLength": 1, + "type": "string" + }, + "port": { + "default": 3000, + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "valency": { + "default": 1, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "address" + ], + "type": "object" + }, + "vaultAuth": { + "additionalProperties": false, + "properties": { + "ref": { + "default": "control-plane/default", + "description": "VaultAuth reference used by chart-managed VaultStaticSecret resources.", + "type": "string" + }, + "serviceAccount": { + "additionalProperties": false, + "properties": { + "create": { + "default": true, + "type": "boolean" + }, + "name": { + "default": "vault-auth", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "vaultStaticSecret": { + "additionalProperties": false, + "properties": { + "mount": { + "default": "kv", + "type": "string" + }, + "path": { + "description": "Required. Runtime Vault path without kv/data prefix, for example runtime/apex-fusion/prime-mainnet-bp/block-producer.", + "minLength": 1, + "type": "string" + }, + "refreshAfter": { + "default": "1m", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + } + }, + "description": "Supernode-opinionated Apex Fusion block producer chart values. This schema is the public configuration interface for LLMs and MCP clients; pass values in this shape directly to Helm. Producer runtime material must be sourced from Vault.", + "properties": { + "affinity": { + "additionalProperties": true, + "default": {}, + "description": "Advanced, usually not modified. Kubernetes affinity rules for producer pod placement.", + "type": "object", + "x-supernodeCategory": "advanced" + }, + "blockProducer": { + "additionalProperties": false, + "description": "Required. Private producer runtime settings. Only kes.skey, vrf.skey, and op.cert runtime material should be synced into this pod from Vault; keep cold keys and counters outside the cluster.", + "properties": { + "debug": { + "default": false, + "description": "Optional. Mount producer material and emit block-producer metrics env, but do not pass forging flags to the node.", + "type": "boolean" + }, + "kesKeyFile": { + "default": "kes.skey", + "description": "Advanced, usually not modified. KES signing key filename in the Vault-synced Secret.", + "type": "string" + }, + "mountPath": { + "default": "/block-producer", + "description": "Advanced, usually not modified. Mount path for producer runtime material.", + "type": "string" + }, + "operationalCertificateFile": { + "default": "op.cert", + "description": "Advanced, usually not modified. Operational certificate filename in the Vault-synced Secret.", + "type": "string" + }, + "poolId": { + "default": "", + "description": "Optional but recommended. Pool ID used by metrics for leader schedule calculations.", + "type": "string" + }, + "vaultStaticSecret": { + "$ref": "#/definitions/vaultStaticSecret" + }, + "vrfKeyFile": { + "default": "vrf.skey", + "description": "Advanced, usually not modified. VRF signing key filename in the Vault-synced Secret.", + "type": "string" + } + }, + "required": [ + "vaultStaticSecret" + ], + "type": "object" + }, + "displayName": { + "default": "", + "description": "Optional. Human-readable workload name shown by Supernode tooling. MCP normally sets this from releaseName.", + "type": "string", + "x-supernodeCategory": "optional" + }, + "fullnameOverride": { + "default": "", + "description": "Advanced, usually not modified. Overrides the full Kubernetes resource name. Leave empty so the release name controls resource names.", + "type": "string", + "x-supernodeCategory": "advanced" + }, + "image": { + "additionalProperties": false, + "description": "Optional. Apex Fusion node container image settings.", + "properties": { + "repository": { + "default": "ghcr.io/blinklabs-io/cardano-node", + "type": "string" + }, + "tag": { + "default": "10.1.4", + "type": "string" + } + }, + "type": "object" + }, + "nameOverride": { + "default": "", + "description": "Advanced, usually not modified. Overrides the Kubernetes app name used by the chart.", + "type": "string", + "x-supernodeCategory": "advanced" + }, + "node": { + "additionalProperties": false, + "description": "Required. Apex Fusion block producer runtime settings. Set node.network and usually leave low-level ports unchanged.", + "properties": { + "metricsPort": { + "default": 12789, + "description": "Advanced, usually not modified. Internal node metrics port.", + "type": "integer" + }, + "n2nPort": { + "default": 3000, + "description": "Advanced, usually not modified. Internal node-to-node port.", + "type": "integer" + }, + "network": { + "default": "vector-testnet", + "description": "Required. Apex Fusion network to join.", + "enum": [ + "vector-testnet", + "prime-testnet", + "prime-mainnet" + ], + "type": "string", + "x-supernodeCategory": "required" + }, + "networkMagic": { + "default": null, + "description": "Advanced, usually not modified. Overrides derived testnet magic. vector-testnet derives 1 and prime-testnet derives 3311.", + "type": [ + "integer", + "null" + ] + }, + "socketPath": { + "default": "/ipc/node.socket", + "description": "Advanced, usually not modified. Node socket path shared with the proxy sidecar.", + "type": "string" + } + }, + "required": [ + "network" + ], + "type": "object" + }, + "nodeSelector": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "description": "Advanced, usually not modified. Kubernetes nodeSelector for producer scheduling.", + "type": "object", + "x-supernodeCategory": "advanced" + }, + "persistence": { + "$ref": "#/definitions/persistence", + "description": "Required. Persistent storage for the block producer chain database." + }, + "proxy": { + "additionalProperties": false, + "description": "Advanced, usually not modified. Node-to-client TCP proxy settings. The proxy is always enabled and uses chart-owned nginx configuration.", + "properties": { + "n2cPort": { + "default": 3307, + "type": "integer" + } + }, + "type": "object" + }, + "relayPersistence": { + "$ref": "#/definitions/persistence", + "description": "Required. Persistent storage for each managed relay chain database." + }, + "relayResources": { + "$ref": "#/definitions/resources", + "description": "Optional. Kubernetes CPU and memory requests and limits for managed relay containers." + }, + "relays": { + "additionalProperties": false, + "description": "Required. Relay topology for the private block producer. relays.count creates managed relays inside this release. If relays.count=0, provide relays.trusted explicitly. MCP does not auto-resolve trusted relays; use workloads.list to inspect candidate apex-fusion-relay workloads.", + "properties": { + "count": { + "default": 1, + "description": "Number of managed relays to create inside this release. Use 0 only when relays.trusted is provided.", + "minimum": 0, + "type": "integer" + }, + "trusted": { + "default": [], + "description": "Explicit trusted relay endpoints used when count=0.", + "items": { + "$ref": "#/definitions/trustedRelay" + }, + "type": "array" + }, + "useLedgerAfterSlot": { + "default": -1, + "description": "Topology useLedgerAfterSlot value. Private producers typically use -1.", + "type": "integer" + } + }, + "type": "object" + }, + "resources": { + "$ref": "#/definitions/resources", + "description": "Optional. Kubernetes CPU and memory requests and limits for the private block producer container." + }, + "service": { + "$ref": "#/definitions/service" + }, + "tolerations": { + "default": [], + "description": "Advanced, usually not modified. Kubernetes tolerations for producer scheduling.", + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "x-supernodeCategory": "advanced" + }, + "topologySpreadConstraints": { + "default": [], + "description": "Advanced, usually not modified. Kubernetes topology spread constraints.", + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "x-supernodeCategory": "advanced" + }, + "vaultAuth": { + "$ref": "#/definitions/vaultAuth" + } + }, + "required": [ + "node", + "persistence", + "relayPersistence", + "blockProducer" + ], + "title": "Apex Fusion Block Producer Helm Values", + "type": "object" + }, + "secrets": [ + { + "name": "blockProducerRuntime", + "description": "Runtime Apex Fusion producer material synced from Vault: kes.skey, vrf.skey, and op.cert. Cold keys and counters must not be mounted into the producer pod.", + "required": true, + "requiredWhen": null, + "scope": "runtime", + "material": "apex-fusion-block-producer-runtime", + "writeOnly": true, + "acceptedSources": [ + "vaultStaticSecret" + ] + } + ], + "dependencies": [], + "metrics": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, + "properties": { + "blockHeight": { + "description": "Latest block number observed by the node.", + "type": [ + "number", + "null" + ] + }, + "epoch": { + "description": "Current epoch observed by the node.", + "type": [ + "number", + "null" + ] + }, + "errors": { + "items": { + "type": "string" + }, + "type": "array" + }, + "peersIncoming": { + "description": "Incoming peer connection count.", + "type": [ + "number", + "null" + ] + }, + "peersOutgoing": { + "description": "Outgoing peer connection count.", + "type": [ + "number", + "null" + ] + }, + "slotNum": { + "description": "Absolute slot number observed by the node.", + "type": [ + "number", + "null" + ] + }, + "type": { + "const": "apex-fusion" + } + }, + "required": [ + "type", + "errors" + ], + "title": "Apex Fusion Relay Metrics", + "type": "object" + }, + "metricsCollection": { + "container": "apex-fusion", + "command": [ + "/opt/metis/bin/metrics.sh" + ] + }, + "outputs": [ + { + "name": "n2n", + "description": "Apex Fusion node-to-node networking endpoint for relay peer connectivity.", + "portName": "n2n", + "protocol": "TCP" + }, + { + "name": "n2c", + "description": "Apex Fusion node-to-client endpoint for local clients through the chart proxy.", + "portName": "n2c", + "protocol": "TCP" + } + ], + "chart": "oci://oci.supernode.store/extensions/apex-fusion-block-producer" + }, + { + "id": "apex-fusion-relay", + "name": "Apex Fusion Relay", + "description": "An Apex Fusion relay node workload for participating in Vector and Prime network topology without block-producing keys.", + "versions": [ + "0.1.0" + ], + "defaultVersion": "0.1.0", + "configuration": { + "$schema": "https://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "relayTarget": { + "additionalProperties": false, + "properties": { + "chart": { + "default": "apex-fusion-relay", + "minLength": 1, + "type": "string" + }, + "namespace": { + "minLength": 1, + "type": "string" + }, + "port": { + "default": 3000, + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "releaseName": { + "minLength": 1, + "type": "string" + }, + "valency": { + "default": 1, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "releaseName" + ], + "type": "object" + }, + "resourceList": { + "additionalProperties": false, + "properties": { + "cpu": { + "type": "string" + }, + "memory": { + "type": "string" + } + }, + "type": "object" + }, + "stringMap": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "topology": { + "additionalProperties": false, + "description": "Optional. Relay topology configuration. Leave mode=image-default for normal public relay behavior. Use relay-service to peer with specific in-cluster relay Services or custom for explicit topology.json roots.", + "properties": { + "localRoots": { + "default": [], + "description": "Custom topology localRoots when mode=custom.", + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + "mode": { + "default": "image-default", + "enum": [ + "image-default", + "relay-service", + "custom" + ], + "type": "string" + }, + "publicRoots": { + "default": [], + "description": "Custom topology publicRoots when mode=custom.", + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + "relayTargets": { + "default": [], + "description": "Internal relay services to use when mode=relay-service.", + "items": { + "$ref": "#/definitions/relayTarget" + }, + "type": "array" + }, + "useLedgerAfterSlot": { + "default": 0, + "description": "Topology useLedgerAfterSlot value. Public relays typically use 0.", + "type": "integer" + } + }, + "type": "object" + } + }, + "description": "Supernode-opinionated Apex Fusion relay chart values. This schema is the public configuration interface for LLMs and MCP clients; pass values in this shape directly to Helm.", + "properties": { + "affinity": { + "additionalProperties": true, + "default": {}, + "description": "Advanced, usually not modified. Kubernetes affinity rules for relay pod placement.", + "type": "object", + "x-supernodeCategory": "advanced" + }, + "displayName": { + "default": "", + "description": "Optional. Human-readable workload name shown by Supernode tooling. MCP normally sets this from releaseName.", + "type": "string", + "x-supernodeCategory": "optional" + }, + "fullnameOverride": { + "default": "", + "description": "Advanced, usually not modified. Overrides the full Kubernetes resource name. Leave empty so the release name controls resource names.", + "type": "string", + "x-supernodeCategory": "advanced" + }, + "image": { + "additionalProperties": false, + "description": "Optional. Apex Fusion node container image settings. Most installs only change image.tag when pinning a specific release.", + "properties": { + "repository": { + "default": "ghcr.io/blinklabs-io/cardano-node", + "description": "Advanced, usually not modified. Container image repository for the Apex Fusion compatible node image.", + "type": "string" + }, + "tag": { + "default": "10.1.4", + "description": "Optional. Node image tag to deploy.", + "type": "string" + } + }, + "type": "object" + }, + "nameOverride": { + "default": "", + "description": "Advanced, usually not modified. Overrides the Kubernetes app name used by the chart.", + "type": "string", + "x-supernodeCategory": "advanced" + }, + "node": { + "additionalProperties": false, + "description": "Required. Apex Fusion relay runtime settings. Set node.network and usually leave ports and topology defaults unchanged.", + "properties": { + "metricsPort": { + "default": 12789, + "description": "Advanced, usually not modified. Internal node metrics port.", + "type": "integer" + }, + "n2nPort": { + "default": 3000, + "description": "Advanced, usually not modified. Internal node-to-node port.", + "type": "integer" + }, + "network": { + "default": "vector-testnet", + "description": "Required. Apex Fusion network to join.", + "enum": [ + "vector-testnet", + "prime-testnet", + "prime-mainnet" + ], + "type": "string", + "x-supernodeCategory": "required" + }, + "networkMagic": { + "default": null, + "description": "Advanced, usually not modified. Overrides derived testnet magic. vector-testnet derives 1 and prime-testnet derives 3311.", + "type": [ + "integer", + "null" + ] + }, + "replicas": { + "default": 1, + "description": "Advanced, usually not modified. Number of relay pods.", + "minimum": 1, + "type": "integer" + }, + "socketPath": { + "default": "/ipc/node.socket", + "description": "Advanced, usually not modified. Node socket path shared with the proxy sidecar.", + "type": "string" + }, + "topology": { + "$ref": "#/definitions/topology" + } + }, + "required": [ + "network" + ], + "type": "object" + }, + "nodeSelector": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "description": "Advanced, usually not modified. Kubernetes nodeSelector for relay scheduling.", + "type": "object", + "x-supernodeCategory": "advanced" + }, + "persistence": { + "additionalProperties": false, + "description": "Required. Persistent storage for the Apex Fusion chain database. Persistence is always enabled for this chart.", + "properties": { + "accessModes": { + "default": [ + "ReadWriteOnce" + ], + "description": "Advanced, usually not modified. PVC access modes.", + "items": { + "type": "string" + }, + "type": "array" + }, + "annotations": { + "$ref": "#/definitions/stringMap" + }, + "existingClaim": { + "default": "", + "description": "Advanced, usually not modified. Existing PVC name to mount instead of creating a StatefulSet volumeClaimTemplate.", + "type": "string" + }, + "size": { + "default": "20Gi", + "description": "Optional. Requested chain database PVC size.", + "pattern": "^[0-9]+(Mi|Gi|Ti)$", + "type": "string" + }, + "storageClass": { + "description": "Required. Kubernetes StorageClass used for the chain-data PVC.", + "minLength": 1, + "type": "string", + "x-supernodeCategory": "required" + } + }, + "required": [ + "storageClass" + ], + "type": "object" + }, + "proxy": { + "additionalProperties": false, + "description": "Advanced, usually not modified. Node-to-client TCP proxy settings. The proxy is always enabled and uses chart-owned nginx configuration.", + "properties": { + "n2cPort": { + "default": 3307, + "description": "Internal proxy listener port for node-to-client traffic.", + "type": "integer" + } + }, + "type": "object" + }, + "resources": { + "additionalProperties": false, + "description": "Optional. Kubernetes CPU and memory requests and limits for the relay container. Start with 500m CPU requested, 2 CPU limit, and 4Gi memory for test networks; increase memory for larger networks.", + "properties": { + "limits": { + "$ref": "#/definitions/resourceList" + }, + "requests": { + "$ref": "#/definitions/resourceList" + } + }, + "type": "object" + }, + "service": { + "additionalProperties": false, + "description": "Optional. Kubernetes Service settings. Keep ClusterIP for in-cluster access; use LoadBalancer only when external node-to-node access is required.", + "properties": { + "annotations": { + "$ref": "#/definitions/stringMap" + }, + "clusterIP": { + "default": "", + "type": "string" + }, + "externalTrafficPolicy": { + "default": "", + "enum": [ + "", + "Cluster", + "Local" + ], + "type": "string" + }, + "labels": { + "$ref": "#/definitions/stringMap" + }, + "loadBalancerIP": { + "default": "", + "type": "string" + }, + "metricsNodePort": { + "default": null, + "type": [ + "integer", + "null" + ] + }, + "metricsPort": { + "default": 12789, + "description": "Advanced, usually not modified. Metrics Service port.", + "type": "integer" + }, + "n2cNodePort": { + "default": null, + "type": [ + "integer", + "null" + ] + }, + "n2cPort": { + "default": 3307, + "description": "Advanced, usually not modified. Node-to-client Service port exposed through the proxy sidecar.", + "type": "integer" + }, + "n2nNodePort": { + "default": null, + "type": [ + "integer", + "null" + ] + }, + "n2nPort": { + "default": 3000, + "description": "Advanced, usually not modified. Node-to-node Service port.", + "type": "integer" + }, + "type": { + "default": "ClusterIP", + "enum": [ + "ClusterIP", + "NodePort", + "LoadBalancer" + ], + "type": "string" + } + }, + "type": "object" + }, + "tolerations": { + "default": [], + "description": "Advanced, usually not modified. Kubernetes tolerations for relay scheduling.", + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "x-supernodeCategory": "advanced" + }, + "topologySpreadConstraints": { + "default": [], + "description": "Advanced, usually not modified. Kubernetes topology spread constraints.", + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "x-supernodeCategory": "advanced" + } + }, + "required": [ + "node", + "persistence" + ], + "title": "Apex Fusion Relay Helm Values", + "type": "object" + }, + "secrets": [], + "dependencies": [], + "metrics": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, + "properties": { + "blockHeight": { + "description": "Latest block number observed by the node.", + "type": [ + "number", + "null" + ] + }, + "epoch": { + "description": "Current epoch observed by the node.", + "type": [ + "number", + "null" + ] + }, + "errors": { + "items": { + "type": "string" + }, + "type": "array" + }, + "peersIncoming": { + "description": "Incoming peer connection count.", + "type": [ + "number", + "null" + ] + }, + "peersOutgoing": { + "description": "Outgoing peer connection count.", + "type": [ + "number", + "null" + ] + }, + "slotNum": { + "description": "Absolute slot number observed by the node.", + "type": [ + "number", + "null" + ] + }, + "type": { + "const": "apex-fusion" + } + }, + "required": [ + "type", + "errors" + ], + "title": "Apex Fusion Relay Metrics", + "type": "object" + }, + "metricsCollection": { + "container": "apex-fusion", + "command": [ + "/opt/metis/bin/metrics.sh" + ] + }, + "outputs": [ + { + "name": "n2n", + "description": "Apex Fusion node-to-node networking endpoint for relay peer connectivity.", + "portName": "n2n", + "protocol": "TCP" + }, + { + "name": "n2c", + "description": "Apex Fusion node-to-client endpoint for local clients through the chart proxy.", + "portName": "n2c", + "protocol": "TCP" + } + ], + "chart": "oci://oci.supernode.store/extensions/apex-fusion-relay" + }, + { + "id": "cardano-block-producer", + "name": "Cardano Block Producer", + "description": "A private Cardano block producer workload with optional managed relays and Vault-synced runtime producer material.", + "versions": [ + "0.1.0" + ], + "defaultVersion": "0.1.0", + "configuration": { + "$schema": "https://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "resourceList": { + "additionalProperties": false, + "properties": { + "cpu": { + "description": "Kubernetes CPU quantity. Use 500m for requests; use 4 for mainnet limits and 2 for preview or preprod limits.", + "type": "string" + }, + "memory": { + "description": "Kubernetes memory quantity. Set requests.memory equal to limits.memory: 8Gi for mainnet, 4Gi for preview or preprod.", + "type": "string" + } + }, + "type": "object" + }, + "stringMap": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "trustedRelay": { + "additionalProperties": false, + "properties": { + "address": { + "description": "Trusted relay hostname or IP address. Do not include the port here.", + "minLength": 1, + "type": "string" + }, + "port": { + "default": 3000, + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "valency": { + "default": 1, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "address" + ], + "type": "object" + } + }, + "description": "Supernode-opinionated Cardano block producer chart values. This chart deploys one private block producer and can also deploy zero, one, or many managed relays in the same release. This schema is the public configuration interface for LLMs and MCP clients.", + "properties": { + "affinity": { + "additionalProperties": true, + "default": {}, + "description": "Advanced, usually not modified. Kubernetes affinity rules for producer pod placement.", + "type": "object", + "x-supernodeCategory": "advanced" + }, + "blockProducer": { + "additionalProperties": false, + "description": "Required. Block producer runtime settings. debug=true mounts producer material and enables schedule metrics, but does not pass forging flags to cardano-node. debug=false runs as an active block producer.", + "properties": { + "debug": { + "default": false, + "description": "Optional. When true, do not pass KES/VRF/op-cert runtime flags, but still mount producer material and expose block producer schedule metrics.", + "type": "boolean" + }, + "kesKeyFile": { + "default": "kes.skey", + "description": "Advanced, usually not modified. KES signing key filename in the Vault-synced secret.", + "type": "string" + }, + "mountPath": { + "default": "/block-producer", + "description": "Advanced, usually not modified. Mount path for synced producer runtime material.", + "type": "string" + }, + "operationalCertificateFile": { + "default": "op.cert", + "description": "Advanced, usually not modified. Operational certificate filename in the Vault-synced secret.", + "type": "string" + }, + "poolId": { + "description": "Required. Stake pool ID used by metrics to compute leadership schedule.", + "minLength": 1, + "type": "string" + }, + "vaultStaticSecret": { + "additionalProperties": false, + "description": "Required. Vault KV v2 location containing kes.skey, vrf.skey, and op.cert. Only runtime block producer material should be stored here; cold keys and counters must stay outside the producer-mounted path.", + "properties": { + "mount": { + "default": "kv", + "description": "Vault KV v2 mount name.", + "type": "string" + }, + "path": { + "description": "Required. Runtime Vault path, for example runtime/cardano-block-producer/mainnet-pool/block-producer.", + "minLength": 1, + "type": "string" + }, + "refreshAfter": { + "default": "1m", + "description": "Advanced, usually not modified. Vault Secrets Operator refresh interval.", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + }, + "vrfKeyFile": { + "default": "vrf.skey", + "description": "Advanced, usually not modified. VRF signing key filename in the Vault-synced secret.", + "type": "string" + } + }, + "required": [ + "poolId", + "vaultStaticSecret" + ], + "type": "object" + }, + "displayName": { + "default": "", + "description": "Optional. Human-readable workload name shown by Supernode tooling. MCP normally sets this from releaseName.", + "type": "string", + "x-supernodeCategory": "optional" + }, + "fullnameOverride": { + "default": "", + "description": "Advanced, usually not modified. Overrides the full Kubernetes resource name. Leave empty so the release name controls resource names.", + "type": "string", + "x-supernodeCategory": "advanced" + }, + "image": { + "additionalProperties": false, + "description": "Optional. Cardano node image settings shared by the producer and managed relays.", + "properties": { + "repository": { + "default": "ghcr.io/blinklabs-io/cardano-node", + "description": "Advanced, usually not modified. Container image repository for cardano-node.", + "type": "string" + }, + "tag": { + "default": "11.0.1", + "description": "Optional. Cardano node image tag to deploy.", + "type": "string" + } + }, + "type": "object" + }, + "nameOverride": { + "default": "", + "description": "Advanced, usually not modified. Overrides the Kubernetes app name used by the chart.", + "type": "string", + "x-supernodeCategory": "advanced" + }, + "node": { + "additionalProperties": false, + "description": "Required. Shared Cardano node runtime settings for the producer and managed relays.", + "properties": { + "metricsPort": { + "default": 12798, + "description": "Advanced, usually not modified. Internal cardano-node metrics port.", + "type": "integer" + }, + "n2nPort": { + "default": 3000, + "description": "Advanced, usually not modified. Internal cardano-node node-to-node port.", + "type": "integer" + }, + "network": { + "default": "preview", + "description": "Required. Cardano network to join. The chart derives network magic internally for preview and preprod.", + "enum": [ + "mainnet", + "preprod", + "preview" + ], + "type": "string", + "x-supernodeCategory": "required" + }, + "socketPath": { + "default": "/ipc/node.socket", + "description": "Advanced, usually not modified. Cardano node socket path shared with the proxy sidecar.", + "type": "string" + } + }, + "required": [ + "network" + ], + "type": "object" + }, + "nodeSelector": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "description": "Advanced, usually not modified. Kubernetes nodeSelector for producer scheduling.", + "type": "object", + "x-supernodeCategory": "advanced" + }, + "persistence": { + "additionalProperties": false, + "description": "Required. Persistent storage for the block producer chain database. Persistence is always enabled.", + "properties": { + "accessModes": { + "default": [ + "ReadWriteOnce" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "annotations": { + "$ref": "#/definitions/stringMap" + }, + "existingClaim": { + "default": "", + "description": "Advanced, usually not modified. Existing PVC name to mount for the producer instead of creating a StatefulSet volumeClaimTemplate.", + "type": "string" + }, + "size": { + "default": "80Gi", + "description": "Optional. Requested producer chain database PVC size. Use roughly 250Gi for mainnet and 80Gi to 120Gi for preview or preprod.", + "pattern": "^[0-9]+(Mi|Gi|Ti)$", + "type": "string" + }, + "storageClass": { + "description": "Required. Kubernetes StorageClass used for the producer chain-data PVC.", + "minLength": 1, + "type": "string", + "x-supernodeCategory": "required" + } + }, + "required": [ + "storageClass" + ], + "type": "object" + }, + "proxy": { + "additionalProperties": false, + "description": "Advanced, usually not modified. Node-to-client TCP proxy settings. The proxy is always enabled and uses nginx with fixed chart-owned configuration.", + "properties": { + "n2cPort": { + "default": 3307, + "type": "integer" + } + }, + "type": "object" + }, + "relayPersistence": { + "additionalProperties": false, + "description": "Required when relays.count is greater than zero. Persistent storage settings for each managed relay.", + "properties": { + "accessModes": { + "default": [ + "ReadWriteOnce" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "annotations": { + "$ref": "#/definitions/stringMap" + }, + "size": { + "default": "80Gi", + "description": "Requested chain database PVC size for each managed relay.", + "pattern": "^[0-9]+(Mi|Gi|Ti)$", + "type": "string" + }, + "storageClass": { + "description": "StorageClass used for managed relay PVCs. Set this when relays.count > 0. Leave empty when relays.count=0 because no managed relay PVCs are created.", + "type": "string" + } + }, + "type": "object" + }, + "relayResources": { + "additionalProperties": false, + "description": "Optional. Kubernetes CPU and memory requests and limits for each managed relay. Use the same guidance as resources unless the relays have a different workload profile.", + "properties": { + "limits": { + "$ref": "#/definitions/resourceList" + }, + "requests": { + "$ref": "#/definitions/resourceList" + } + }, + "type": "object" + }, + "relays": { + "additionalProperties": false, + "description": "Required. Managed relay topology for the producer. If count is greater than zero, the chart creates that many relay StatefulSets and points the producer topology at them. If count is zero, trusted must contain at least one trusted relay address. MCP does not auto-resolve trusted relays; run workloads.list and suggest same-network cardano-relay workloads to the user when available.", + "properties": { + "count": { + "default": 1, + "description": "Number of managed relays to create in this release. Use 1 or more for the easiest private producer setup. Use 0 only when connecting to existing trusted relays.", + "minimum": 0, + "type": "integer" + }, + "trusted": { + "default": [], + "description": "Trusted relay targets used when count=0. Each address should be host-only, with port separate. If unknown, run workloads.list and inspect same-network cardano-relay services; a typical address is RELEASE.NAMESPACE.svc.cluster.local with port 3000.", + "items": { + "$ref": "#/definitions/trustedRelay" + }, + "type": "array" + }, + "useLedgerAfterSlot": { + "default": -1, + "description": "Producer topology useLedgerAfterSlot. Private producers should normally use -1 so they only trust configured relays.", + "type": "integer" + } + }, + "required": [ + "count" + ], + "type": "object" + }, + "resources": { + "additionalProperties": false, + "description": "Optional. Kubernetes CPU and memory requests and limits for the block producer container. Recommended limits are 4 CPU cores and 8Gi memory for mainnet, and 2 CPU cores and 4Gi memory for preview or preprod. Request little CPU, usually 500m, and set memory requests equal to memory limits.", + "properties": { + "limits": { + "$ref": "#/definitions/resourceList" + }, + "requests": { + "$ref": "#/definitions/resourceList" + } + }, + "type": "object" + }, + "service": { + "additionalProperties": false, + "description": "Optional. Producer Service settings and default ports used by managed relay Services. Keep ClusterIP for private producer deployments.", + "properties": { + "annotations": { + "$ref": "#/definitions/stringMap" + }, + "clusterIP": { + "default": "", + "type": "string" + }, + "externalTrafficPolicy": { + "default": "", + "enum": [ + "", + "Cluster", + "Local" + ], + "type": "string" + }, + "labels": { + "$ref": "#/definitions/stringMap" + }, + "loadBalancerIP": { + "default": "", + "type": "string" + }, + "metricsNodePort": { + "default": null, + "type": [ + "integer", + "null" + ] + }, + "metricsPort": { + "default": 12798, + "description": "Metrics Service port.", + "type": "integer" + }, + "n2cNodePort": { + "default": null, + "type": [ + "integer", + "null" + ] + }, + "n2cPort": { + "default": 3307, + "description": "Node-to-client Service port exposed through the proxy sidecar.", + "type": "integer" + }, + "n2nNodePort": { + "default": null, + "type": [ + "integer", + "null" + ] + }, + "n2nPort": { + "default": 3000, + "description": "Node-to-node Service port.", + "type": "integer" + }, + "type": { + "default": "ClusterIP", + "enum": [ + "ClusterIP", + "NodePort", + "LoadBalancer" + ], + "type": "string" + } + }, + "type": "object" + }, + "tolerations": { + "default": [], + "description": "Advanced, usually not modified. Kubernetes tolerations for producer scheduling.", + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "x-supernodeCategory": "advanced" + }, + "topologySpreadConstraints": { + "default": [], + "description": "Advanced, usually not modified. Kubernetes topology spread constraints for producer pod placement.", + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "x-supernodeCategory": "advanced" + }, + "vaultAuth": { + "additionalProperties": false, + "description": "Advanced, usually not modified. Shared Vault auth created by the control-plane extension.", + "properties": { + "ref": { + "default": "control-plane/default", + "description": "VaultAuth reference used by VaultStaticSecret.", + "type": "string" + }, + "serviceAccount": { + "additionalProperties": false, + "properties": { + "create": { + "default": true, + "type": "boolean" + }, + "name": { + "default": "vault-auth", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "node", + "persistence", + "blockProducer", + "relays" + ], + "title": "Cardano Block Producer Helm Values", + "type": "object" + }, + "secrets": [ + { + "name": "blockProducerRuntime", + "description": "Runtime producer material synced from Vault: kes.skey, vrf.skey, and op.cert. Cold keys and counters must not be mounted into the producer pod.", + "required": true, + "requiredWhen": null, + "scope": "runtime", + "material": "cardano-block-producer-runtime", + "writeOnly": true, + "acceptedSources": [ + "vaultStaticSecret" + ] + } + ], + "dependencies": [], + "metrics": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "aboutToLeadCount": { + "description": "Times the node was about to lead a slot since startup, for block-producing nodes.", + "type": [ + "number", + "null" + ] + }, + "adoptedCount": { + "description": "Forged blocks adopted by the chain since startup, for block-producing nodes.", + "type": [ + "number", + "null" + ] + }, + "blockHeight": { + "description": "Latest block number observed by the node.", + "type": [ + "number", + "null" + ] + }, + "blocksLate": { + "description": "Blocks observed later than five seconds by the block fetch client.", + "type": [ + "number", + "null" + ] + }, + "blocksServed": { + "description": "Blocks served to peers by this node since startup.", + "type": [ + "number", + "null" + ] + }, + "blocksWithin1s": { + "description": "Percentage of observed blocks arriving within 1 second.", + "type": [ + "number", + "null" + ] + }, + "blocksWithin3s": { + "description": "Percentage of observed blocks arriving within 3 seconds.", + "type": [ + "number", + "null" + ] + }, + "blocksWithin5s": { + "description": "Percentage of observed blocks arriving within 5 seconds.", + "type": [ + "number", + "null" + ] + }, + "connectionBiDir": { + "description": "Current bidirectional connection count.", + "type": [ + "number", + "null" + ] + }, + "connectionDuplex": { + "description": "Current full duplex connection count.", + "type": [ + "number", + "null" + ] + }, + "connectionUniDir": { + "description": "Current unidirectional connection count.", + "type": [ + "number", + "null" + ] + }, + "density": { + "description": "Recent chain density reported by the node, expressed as a percentage.", + "type": [ + "number", + "null" + ] + }, + "epoch": { + "description": "Current epoch observed by the node.", + "type": [ + "number", + "null" + ] + }, + "epochLength": { + "description": "Number of slots in the current Cardano epoch from Shelley genesis.", + "type": [ + "number", + "null" + ] + }, + "epochProgressPercent": { + "description": "Percentage of the current epoch completed from slot-in-epoch and Shelley genesis epoch length.", + "type": [ + "number", + "null" + ] + }, + "epochTimeRemainingSeconds": { + "description": "Approximate time remaining in the current epoch derived from Shelley genesis timing.", + "type": [ + "number", + "null" + ] + }, + "errors": { + "description": "Warnings or collection errors emitted by the metrics script.", + "items": { + "type": "string" + }, + "type": "array" + }, + "forgedCount": { + "description": "Blocks forged by the node since startup, for block-producing nodes.", + "type": [ + "number", + "null" + ] + }, + "forgingEnabled": { + "description": "Whether this node currently has forging enabled.", + "type": [ + "boolean", + "null" + ] + }, + "forks": { + "description": "Number of chain forks the node has observed since startup.", + "type": [ + "number", + "null" + ] + }, + "gcMajorCount": { + "description": "Number of major garbage collections since startup.", + "type": [ + "number", + "null" + ] + }, + "gcMinorCount": { + "description": "Number of minor garbage collections since startup.", + "type": [ + "number", + "null" + ] + }, + "inboundGovernorHot": { + "description": "Inbound governor hot connection count reported by the node.", + "type": [ + "number", + "null" + ] + }, + "inboundGovernorWarm": { + "description": "Inbound governor warm connection count reported by the node.", + "type": [ + "number", + "null" + ] + }, + "invalidCount": { + "description": "Derived count of forged blocks that were not adopted, clamped at zero.", + "type": [ + "number", + "null" + ] + }, + "kesExpirationSeconds": { + "description": "Approximate seconds until KES key expiry, when available.", + "type": [ + "number", + "null" + ] + }, + "kesExpirationTime": { + "description": "Estimated KES key expiry time as an ISO-8601 timestamp, when available.", + "type": [ + "string", + "null" + ] + }, + "kesPeriod": { + "description": "Current KES period reported by the node, when available.", + "type": [ + "number", + "null" + ] + }, + "kesRemaining": { + "description": "Remaining KES periods before key expiry, when available.", + "type": [ + "number", + "null" + ] + }, + "lastBlockDelaySeconds": { + "description": "Latest observed block propagation delay.", + "type": [ + "number", + "null" + ] + }, + "leaderCount": { + "description": "Slots where the node was leader since startup, for block-producing nodes.", + "type": [ + "number", + "null" + ] + }, + "memHeapBytes": { + "description": "Heap memory currently reserved by the node RTS.", + "type": [ + "number", + "null" + ] + }, + "memLiveBytes": { + "description": "Live RTS memory currently retained by the node process.", + "type": [ + "number", + "null" + ] + }, + "missedSlots": { + "description": "Slots missed by the node since startup, when available.", + "type": [ + "number", + "null" + ] + }, + "nextLeaderSlot": { + "description": "Next scheduled leadership slot number, for block-producing nodes.", + "type": [ + "number", + "null" + ] + }, + "nextLeaderTime": { + "description": "Next scheduled leadership slot time as an ISO-8601 timestamp.", + "type": [ + "string", + "null" + ] + }, + "nextLeaderTimeRemainingSeconds": { + "description": "Approximate seconds until the next scheduled leadership slot.", + "type": [ + "number", + "null" + ] + }, + "nodeRevision": { + "description": "Cardano node build revision reported by the metrics endpoint.", + "type": [ + "string", + "null" + ] + }, + "nodeVersion": { + "description": "Cardano node build version reported by the metrics endpoint.", + "type": [ + "string", + "null" + ] + }, + "opCertOnChain": { + "description": "Operational certificate counter observed on chain, when available.", + "type": [ + "number", + "null" + ] + }, + "opCertOnDisk": { + "description": "Operational certificate counter found on disk, when available.", + "type": [ + "number", + "null" + ] + }, + "peerSelectionCold": { + "description": "Peer selection cold state count for outbound connections.", + "type": [ + "number", + "null" + ] + }, + "peerSelectionHot": { + "description": "Peer selection hot state count for outbound connections.", + "type": [ + "number", + "null" + ] + }, + "peerSelectionWarm": { + "description": "Peer selection warm state count for outbound connections.", + "type": [ + "number", + "null" + ] + }, + "peersIncoming": { + "description": "Active inbound node connections.", + "type": [ + "number", + "null" + ] + }, + "peersOutgoing": { + "description": "Active outbound node connections.", + "type": [ + "number", + "null" + ] + }, + "pendingTx": { + "description": "Transactions currently in the mempool.", + "type": [ + "number", + "null" + ] + }, + "pendingTxBytes": { + "description": "Buffered mempool transaction size when available.", + "type": [ + "number", + "null" + ] + }, + "role": { + "const": "block-producer" + }, + "scheduledIdealCount": { + "description": "Expected leadership slots for the current epoch based on active stake.", + "type": [ + "number", + "null" + ] + }, + "scheduledLeaderCount": { + "description": "Leadership slots scheduled for the current epoch, for block-producing nodes.", + "type": [ + "number", + "null" + ] + }, + "scheduledLuckPercent": { + "description": "Scheduled leader slots as a percentage of ideal expected slots.", + "type": [ + "number", + "null" + ] + }, + "slotInEpoch": { + "description": "Current slot within the active epoch observed by the node.", + "type": [ + "number", + "null" + ] + }, + "slotLength": { + "description": "Slot duration in seconds from Shelley genesis.", + "type": [ + "number", + "null" + ] + }, + "slotNum": { + "description": "Absolute slot number observed by the node across the chain timeline.", + "type": [ + "number", + "null" + ] + }, + "syncPercent": { + "description": "Estimated sync percentage against the computed reference tip.", + "type": [ + "number", + "null" + ] + }, + "systemStartUnix": { + "description": "Shelley system start timestamp as Unix seconds.", + "type": [ + "number", + "null" + ] + }, + "tipDiffSlots": { + "description": "Difference between the computed reference tip and the node tip.", + "type": [ + "number", + "null" + ] + }, + "tipRefSlot": { + "description": "Reference chain tip computed from the Shelley genesis system start and slot length.", + "type": [ + "number", + "null" + ] + }, + "txProcessed": { + "description": "Total transactions processed by the node since startup.", + "type": [ + "number", + "null" + ] + }, + "type": { + "const": "cardano-node" + } + }, + "required": [ + "type", + "role", + "errors" + ], + "title": "Cardano Block Producer Metrics", + "type": "object" + }, + "metricsCollection": { + "container": "cardano-node", + "command": [ + "/opt/metis/bin/metrics.sh" + ] + }, + "outputs": [ + { + "name": "n2n", + "description": "Cardano node-to-node networking endpoint for relay peer connectivity.", + "portName": "n2n", + "protocol": "TCP" + }, + { + "name": "n2c", + "description": "Cardano node-to-client endpoint for local clients through the chart proxy.", + "portName": "n2c", + "protocol": "TCP" + } + ], + "chart": "oci://oci.supernode.store/extensions/cardano-block-producer" + }, + { + "id": "cardano-relay", + "name": "Cardano Relay", + "description": "A Cardano relay node workload for participating in Cardano network topology without block-producing keys.", + "versions": [ + "0.1.0" + ], + "defaultVersion": "0.1.0", + "configuration": { + "$schema": "https://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "relayTarget": { + "additionalProperties": false, + "properties": { + "chart": { + "default": "cardano-relay", + "minLength": 1, + "type": "string" + }, + "namespace": { + "minLength": 1, + "type": "string" + }, + "port": { + "default": 3000, + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "releaseName": { + "minLength": 1, + "type": "string" + }, + "valency": { + "default": 1, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "releaseName" + ], + "type": "object" + }, + "resourceList": { + "additionalProperties": false, + "properties": { + "cpu": { + "description": "Kubernetes CPU quantity. Use 500m for requests; use 4 for mainnet limits and 2 for preview or preprod limits.", + "type": "string" + }, + "memory": { + "description": "Kubernetes memory quantity. Set requests.memory equal to limits.memory: 8Gi for mainnet, 4Gi for preview or preprod.", + "type": "string" + } + }, + "type": "object" + }, + "stringMap": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "topology": { + "additionalProperties": false, + "description": "Optional. Relay topology configuration. Leave mode=image-default for normal public relay behavior. Use relay-service to peer with specific in-cluster relay Services or custom for explicit topology.json roots.", + "properties": { + "localRoots": { + "default": [], + "description": "Custom topology localRoots when mode=custom.", + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + "mode": { + "default": "image-default", + "enum": [ + "image-default", + "relay-service", + "custom" + ], + "type": "string" + }, + "publicRoots": { + "default": [], + "description": "Custom topology publicRoots when mode=custom.", + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + "relayTargets": { + "default": [], + "description": "Internal relay services to use when mode=relay-service.", + "items": { + "$ref": "#/definitions/relayTarget" + }, + "type": "array" + }, + "useLedgerAfterSlot": { + "default": 0, + "description": "Topology useLedgerAfterSlot value. Public relays typically use 0.", + "type": "integer" + } + }, + "type": "object" + } + }, + "description": "Supernode-opinionated Cardano relay chart values. This schema is the public configuration interface for LLMs and MCP clients; pass values in this shape directly to Helm.", + "properties": { + "affinity": { + "additionalProperties": true, + "default": {}, + "description": "Advanced, usually not modified. Kubernetes affinity rules for relay pod placement.", + "type": "object", + "x-supernodeCategory": "advanced" + }, + "displayName": { + "default": "", + "description": "Optional. Human-readable workload name shown by Supernode tooling. MCP normally sets this from releaseName.", + "type": "string", + "x-supernodeCategory": "optional" + }, + "fullnameOverride": { + "default": "", + "description": "Advanced, usually not modified. Overrides the full Kubernetes resource name. Leave empty so the release name controls resource names.", + "type": "string", + "x-supernodeCategory": "advanced" + }, + "image": { + "additionalProperties": false, + "description": "Optional. Cardano node container image settings. Most installs only change image.tag when pinning a specific Cardano node release.", + "properties": { + "repository": { + "default": "ghcr.io/blinklabs-io/cardano-node", + "description": "Advanced, usually not modified. Container image repository for cardano-node.", + "type": "string" + }, + "tag": { + "default": "11.0.1", + "description": "Optional. Cardano node image tag to deploy.", + "type": "string" + } + }, + "type": "object", + "x-supernodeCategory": "optional" + }, + "nameOverride": { + "default": "", + "description": "Advanced, usually not modified. Overrides the Kubernetes app name used by the chart.", + "type": "string", + "x-supernodeCategory": "advanced" + }, + "node": { + "additionalProperties": false, + "description": "Required. Cardano relay runtime settings. Set node.network and usually leave the low-level ports and topology defaults unchanged.", + "properties": { + "metricsPort": { + "default": 12798, + "description": "Advanced, usually not modified. Internal cardano-node metrics port.", + "type": "integer" + }, + "n2nPort": { + "default": 3000, + "description": "Advanced, usually not modified. Internal cardano-node node-to-node port.", + "type": "integer" + }, + "network": { + "default": "preview", + "description": "Required. Cardano network to join. The chart derives network magic internally for preview and preprod.", + "enum": [ + "mainnet", + "preprod", + "preview" + ], + "type": "string", + "x-supernodeCategory": "required" + }, + "replicas": { + "default": 1, + "description": "Advanced, usually not modified. Number of relay pods.", + "minimum": 1, + "type": "integer" + }, + "socketPath": { + "default": "/ipc/node.socket", + "description": "Advanced, usually not modified. Cardano node socket path shared with the proxy sidecar.", + "type": "string" + }, + "topology": { + "$ref": "#/definitions/topology" + } + }, + "required": [ + "network" + ], + "type": "object", + "x-supernodeCategory": "required" + }, + "nodeSelector": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "description": "Advanced, usually not modified. Kubernetes nodeSelector for relay scheduling.", + "type": "object", + "x-supernodeCategory": "advanced" + }, + "persistence": { + "additionalProperties": false, + "description": "Required. Persistent storage for the Cardano chain database. Persistence is always enabled for this chart.", + "properties": { + "accessModes": { + "default": [ + "ReadWriteOnce" + ], + "description": "Advanced, usually not modified. PVC access modes.", + "items": { + "type": "string" + }, + "type": "array" + }, + "annotations": { + "$ref": "#/definitions/stringMap" + }, + "existingClaim": { + "default": "", + "description": "Advanced, usually not modified. Existing PVC name to mount instead of creating a StatefulSet volumeClaimTemplate.", + "type": "string" + }, + "size": { + "default": "80Gi", + "description": "Optional. Requested chain database PVC size. Use roughly 250Gi for mainnet and 80Gi to 120Gi for preview or preprod.", + "pattern": "^[0-9]+(Mi|Gi|Ti)$", + "type": "string" + }, + "storageClass": { + "description": "Required. Kubernetes StorageClass used for the chain-data PVC.", + "minLength": 1, + "type": "string", + "x-supernodeCategory": "required" + } + }, + "required": [ + "storageClass" + ], + "type": "object", + "x-supernodeCategory": "required" + }, + "proxy": { + "additionalProperties": false, + "description": "Advanced, usually not modified. Node-to-client TCP proxy settings. The proxy is always enabled and uses nginx with fixed chart-owned configuration.", + "properties": { + "n2cPort": { + "default": 3307, + "description": "Internal proxy listener port for node-to-client traffic.", + "type": "integer" + } + }, + "type": "object", + "x-supernodeCategory": "advanced" + }, + "resources": { + "additionalProperties": false, + "description": "Optional. Kubernetes CPU and memory requests and limits for the relay container. Recommended limits are 4 CPU cores and 8Gi memory for mainnet, and 2 CPU cores and 4Gi memory for preview or preprod. Request little CPU, usually 500m, and set memory requests equal to memory limits.", + "properties": { + "limits": { + "$ref": "#/definitions/resourceList" + }, + "requests": { + "$ref": "#/definitions/resourceList" + } + }, + "type": "object", + "x-supernodeCategory": "optional" + }, + "service": { + "additionalProperties": false, + "description": "Optional. Kubernetes Service settings. Keep ClusterIP for in-cluster use; use LoadBalancer only when external node-to-node access is required.", + "properties": { + "annotations": { + "$ref": "#/definitions/stringMap" + }, + "clusterIP": { + "default": "", + "type": "string" + }, + "externalTrafficPolicy": { + "default": "", + "enum": [ + "", + "Cluster", + "Local" + ], + "type": "string" + }, + "labels": { + "$ref": "#/definitions/stringMap" + }, + "loadBalancerIP": { + "default": "", + "type": "string" + }, + "metricsNodePort": { + "default": null, + "type": [ + "integer", + "null" + ] + }, + "metricsPort": { + "default": 12798, + "description": "Advanced, usually not modified. Metrics Service port.", + "type": "integer" + }, + "n2cNodePort": { + "default": null, + "type": [ + "integer", + "null" + ] + }, + "n2cPort": { + "default": 3307, + "description": "Advanced, usually not modified. Node-to-client Service port exposed through the proxy sidecar.", + "type": "integer" + }, + "n2nNodePort": { + "default": null, + "type": [ + "integer", + "null" + ] + }, + "n2nPort": { + "default": 3000, + "description": "Advanced, usually not modified. Node-to-node Service port.", + "type": "integer" + }, + "type": { + "default": "ClusterIP", + "enum": [ + "ClusterIP", + "NodePort", + "LoadBalancer" + ], + "type": "string" + } + }, + "type": "object", + "x-supernodeCategory": "optional" + }, + "tolerations": { + "default": [], + "description": "Advanced, usually not modified. Kubernetes tolerations for relay scheduling.", + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "x-supernodeCategory": "advanced" + }, + "topologySpreadConstraints": { + "default": [], + "description": "Advanced, usually not modified. Kubernetes topology spread constraints.", + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "x-supernodeCategory": "advanced" + } + }, + "required": [ + "node", + "persistence" + ], + "title": "Cardano Relay Helm Values", + "type": "object" + }, + "secrets": [], + "dependencies": [], + "metrics": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "aboutToLeadCount": { + "description": "Times the node was about to lead a slot since startup, for block-producing nodes.", + "type": [ + "number", + "null" + ] + }, + "adoptedCount": { + "description": "Forged blocks adopted by the chain since startup, for block-producing nodes.", + "type": [ + "number", + "null" + ] + }, + "blockHeight": { + "description": "Latest block number observed by the node.", + "type": [ + "number", + "null" + ] + }, + "blocksLate": { + "description": "Blocks observed later than five seconds by the block fetch client.", + "type": [ + "number", + "null" + ] + }, + "blocksServed": { + "description": "Blocks served to peers by this node since startup.", + "type": [ + "number", + "null" + ] + }, + "blocksWithin1s": { + "description": "Percentage of observed blocks arriving within 1 second.", + "type": [ + "number", + "null" + ] + }, + "blocksWithin3s": { + "description": "Percentage of observed blocks arriving within 3 seconds.", + "type": [ + "number", + "null" + ] + }, + "blocksWithin5s": { + "description": "Percentage of observed blocks arriving within 5 seconds.", + "type": [ + "number", + "null" + ] + }, + "connectionBiDir": { + "description": "Current bidirectional connection count.", + "type": [ + "number", + "null" + ] + }, + "connectionDuplex": { + "description": "Current full duplex connection count.", + "type": [ + "number", + "null" + ] + }, + "connectionUniDir": { + "description": "Current unidirectional connection count.", + "type": [ + "number", + "null" + ] + }, + "density": { + "description": "Recent chain density reported by the node, expressed as a percentage.", + "type": [ + "number", + "null" + ] + }, + "epoch": { + "description": "Current epoch observed by the node.", + "type": [ + "number", + "null" + ] + }, + "epochLength": { + "description": "Number of slots in the current Cardano epoch from Shelley genesis.", + "type": [ + "number", + "null" + ] + }, + "epochProgressPercent": { + "description": "Percentage of the current epoch completed from slot-in-epoch and Shelley genesis epoch length.", + "type": [ + "number", + "null" + ] + }, + "epochTimeRemainingSeconds": { + "description": "Approximate time remaining in the current epoch derived from Shelley genesis timing.", + "type": [ + "number", + "null" + ] + }, + "errors": { + "description": "Warnings or collection errors emitted by the metrics script.", + "items": { + "type": "string" + }, + "type": "array" + }, + "forgedCount": { + "description": "Blocks forged by the node since startup, for block-producing nodes.", + "type": [ + "number", + "null" + ] + }, + "forgingEnabled": { + "description": "Whether this node currently has forging enabled.", + "type": [ + "boolean", + "null" + ] + }, + "forks": { + "description": "Number of chain forks the node has observed since startup.", + "type": [ + "number", + "null" + ] + }, + "gcMajorCount": { + "description": "Number of major garbage collections since startup.", + "type": [ + "number", + "null" + ] + }, + "gcMinorCount": { + "description": "Number of minor garbage collections since startup.", + "type": [ + "number", + "null" + ] + }, + "inboundGovernorHot": { + "description": "Inbound governor hot connection count reported by the node.", + "type": [ + "number", + "null" + ] + }, + "inboundGovernorWarm": { + "description": "Inbound governor warm connection count reported by the node.", + "type": [ + "number", + "null" + ] + }, + "invalidCount": { + "description": "Derived count of forged blocks that were not adopted, clamped at zero.", + "type": [ + "number", + "null" + ] + }, + "kesExpirationSeconds": { + "description": "Approximate seconds until KES key expiry, when available.", + "type": [ + "number", + "null" + ] + }, + "kesExpirationTime": { + "description": "Estimated KES key expiry time as an ISO-8601 timestamp, when available.", + "type": [ + "string", + "null" + ] + }, + "kesPeriod": { + "description": "Current KES period reported by the node, when available.", + "type": [ + "number", + "null" + ] + }, + "kesRemaining": { + "description": "Remaining KES periods before key expiry, when available.", + "type": [ + "number", + "null" + ] + }, + "lastBlockDelaySeconds": { + "description": "Latest observed block propagation delay.", + "type": [ + "number", + "null" + ] + }, + "leaderCount": { + "description": "Slots where the node was leader since startup, for block-producing nodes.", + "type": [ + "number", + "null" + ] + }, + "memHeapBytes": { + "description": "Heap memory currently reserved by the node RTS.", + "type": [ + "number", + "null" + ] + }, + "memLiveBytes": { + "description": "Live RTS memory currently retained by the node process.", + "type": [ + "number", + "null" + ] + }, + "missedSlots": { + "description": "Slots missed by the node since startup, when available.", + "type": [ + "number", + "null" + ] + }, + "nextLeaderSlot": { + "description": "Next scheduled leadership slot number, for block-producing nodes.", + "type": [ + "number", + "null" + ] + }, + "nextLeaderTime": { + "description": "Next scheduled leadership slot time as an ISO-8601 timestamp.", + "type": [ + "string", + "null" + ] + }, + "nextLeaderTimeRemainingSeconds": { + "description": "Approximate seconds until the next scheduled leadership slot.", + "type": [ + "number", + "null" + ] + }, + "nodeRevision": { + "description": "Cardano node build revision reported by the metrics endpoint.", + "type": [ + "string", + "null" + ] + }, + "nodeVersion": { + "description": "Cardano node build version reported by the metrics endpoint.", + "type": [ + "string", + "null" + ] + }, + "opCertOnChain": { + "description": "Operational certificate counter observed on chain, when available.", + "type": [ + "number", + "null" + ] + }, + "opCertOnDisk": { + "description": "Operational certificate counter found on disk, when available.", + "type": [ + "number", + "null" + ] + }, + "peerSelectionCold": { + "description": "Peer selection cold state count for outbound connections.", + "type": [ + "number", + "null" + ] + }, + "peerSelectionHot": { + "description": "Peer selection hot state count for outbound connections.", + "type": [ + "number", + "null" + ] + }, + "peerSelectionWarm": { + "description": "Peer selection warm state count for outbound connections.", + "type": [ + "number", + "null" + ] + }, + "peersIncoming": { + "description": "Active inbound node connections.", + "type": [ + "number", + "null" + ] + }, + "peersOutgoing": { + "description": "Active outbound node connections.", + "type": [ + "number", + "null" + ] + }, + "pendingTx": { + "description": "Transactions currently in the mempool.", + "type": [ + "number", + "null" + ] + }, + "pendingTxBytes": { + "description": "Buffered mempool transaction size when available.", + "type": [ + "number", + "null" + ] + }, + "role": { + "const": "relay" + }, + "scheduledIdealCount": { + "description": "Expected leadership slots for the current epoch based on active stake.", + "type": [ + "number", + "null" + ] + }, + "scheduledLeaderCount": { + "description": "Leadership slots scheduled for the current epoch, for block-producing nodes.", + "type": [ + "number", + "null" + ] + }, + "scheduledLuckPercent": { + "description": "Scheduled leader slots as a percentage of ideal expected slots.", + "type": [ + "number", + "null" + ] + }, + "slotInEpoch": { + "description": "Current slot within the active epoch observed by the node.", + "type": [ + "number", + "null" + ] + }, + "slotLength": { + "description": "Slot duration in seconds from Shelley genesis.", + "type": [ + "number", + "null" + ] + }, + "slotNum": { + "description": "Absolute slot number observed by the node across the chain timeline.", + "type": [ + "number", + "null" + ] + }, + "syncPercent": { + "description": "Estimated sync percentage against the computed reference tip.", + "type": [ + "number", + "null" + ] + }, + "systemStartUnix": { + "description": "Shelley system start timestamp as Unix seconds.", + "type": [ + "number", + "null" + ] + }, + "tipDiffSlots": { + "description": "Difference between the computed reference tip and the node tip.", + "type": [ + "number", + "null" + ] + }, + "tipRefSlot": { + "description": "Reference chain tip computed from the Shelley genesis system start and slot length.", + "type": [ + "number", + "null" + ] + }, + "txProcessed": { + "description": "Total transactions processed by the node since startup.", + "type": [ + "number", + "null" + ] + }, + "type": { + "const": "cardano-node" + } + }, + "required": [ + "type", + "role", + "errors" + ], + "title": "Cardano Relay Metrics", + "type": "object" + }, + "metricsCollection": { + "container": "cardano-node", + "command": [ + "/opt/metis/bin/metrics.sh" + ] + }, + "outputs": [ + { + "name": "n2n", + "description": "Cardano node-to-node networking endpoint for relay peer connectivity.", + "portName": "n2n", + "protocol": "TCP" + }, + { + "name": "n2c", + "description": "Cardano node-to-client endpoint for local clients through the chart proxy.", + "portName": "n2c", + "protocol": "TCP" + } + ], + "chart": "oci://oci.supernode.store/extensions/cardano-relay" + }, + { + "id": "dolos", + "name": "Dolos", + "description": "A Dolos chain data service workload for serving Cardano chain data APIs from the supernode cluster.", + "versions": [ + "0.1.0" + ], + "defaultVersion": "0.1.0", + "configuration": { + "$schema": "https://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "resourceList": { + "additionalProperties": false, + "properties": { + "cpu": { + "description": "Kubernetes CPU quantity. For Dolos requests use 500m. For Dolos limits use 4 on cardano-mainnet and 2 on cardano-preview or cardano-preprod.", + "type": "string" + }, + "memory": { + "description": "Kubernetes memory quantity. For Dolos, requests.memory should match limits.memory: 8Gi on cardano-mainnet and 4Gi on cardano-preview or cardano-preprod.", + "type": "string" + } + }, + "type": "object" + }, + "stringMap": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + }, + "description": "Supernode-opinionated Dolos chart values. This schema is the public configuration interface for LLMs and MCP clients; pass values in this shape directly to Helm instead of translating from a separate extension-specific object.", + "properties": { + "affinity": { + "additionalProperties": true, + "default": {}, + "description": "Advanced, usually not modified. Kubernetes affinity rules for Dolos pod placement. Leave empty unless there is a cluster-specific scheduling requirement.", + "type": "object", + "x-supernodeCategory": "advanced" + }, + "config": { + "additionalProperties": false, + "description": "Required. Dolos TOML configuration input. The chart always creates a ConfigMap mounted at /etc/config/dolos.toml.", + "properties": { + "customConfig": { + "default": "", + "description": "Optional. Full Dolos TOML configuration. Leave empty to use the chart preset selected by dolos.network. If set, the custom content is templated by Helm and written to /etc/config/dolos.toml.", + "type": "string" + }, + "extraFiles": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "description": "Advanced, usually not modified. Additional templated files to include in the Dolos config ConfigMap.", + "type": "object" + }, + "upstreamAddress": { + "description": "Required. Trusted Cardano relay address Dolos uses for syncing, in host:port form. MCP does not auto-resolve this value. If the address is unknown, run workloads.list and inspect same-network Cardano relay workloads; a typical candidate is RELEASE_FULLNAME.NAMESPACE.svc.cluster.local:PORT, where PORT is usually the relay n2n port such as 3000.", + "minLength": 1, + "type": "string", + "x-supernodeCategory": "required" + } + }, + "required": [ + "upstreamAddress" + ], + "type": "object", + "x-supernodeCategory": "required" + }, + "displayName": { + "default": "", + "description": "Optional. Human-readable workload name shown by Supernode tooling. MCP normally sets this from releaseName.", + "type": "string", + "x-supernodeCategory": "optional" + }, + "dolos": { + "additionalProperties": false, + "description": "Required. Dolos runtime settings. For normal Supernode installs, set dolos.network and leave command, args, and bootstrap defaults unchanged.", + "properties": { + "args": { + "default": [ + "-c", + "/etc/config/dolos.toml", + "daemon" + ], + "description": "Advanced, usually not modified. Dolos daemon arguments. The default runs `dolos -c /etc/config/dolos.toml daemon`.", + "items": { + "type": "string" + }, + "type": "array" + }, + "bootstrap": { + "additionalProperties": false, + "description": "Optional. Bootstrap init container settings. Keep enabled for normal installs so Dolos initializes data before the daemon starts.", + "properties": { + "args": { + "default": [], + "description": "Advanced, usually not modified. Bootstrap arguments. Leave empty to use the chart default full snapshot bootstrap command.", + "items": { + "type": "string" + }, + "type": "array" + }, + "command": { + "default": [], + "description": "Advanced, usually not modified. Overrides the bootstrap container command. Leave empty to use the image entrypoint.", + "items": { + "type": "string" + }, + "type": "array" + }, + "enabled": { + "default": true, + "description": "Optional. Enables the bootstrap init container. Disable only when reusing an already initialized data volume.", + "type": "boolean" + }, + "name": { + "default": "bootstrap", + "description": "Advanced, usually not modified. Name of the bootstrap init container.", + "type": "string" + } + }, + "type": "object" + }, + "command": { + "default": [], + "description": "Advanced, usually not modified. Overrides the Dolos container command. Leave empty to use the image entrypoint.", + "items": { + "type": "string" + }, + "type": "array" + }, + "network": { + "default": "cardano-preview", + "description": "Required. Cardano network Dolos should index. This selects the built-in Dolos configuration preset unless config.customConfig is provided.", + "enum": [ + "cardano-mainnet", + "cardano-preprod", + "cardano-preview" + ], + "type": "string", + "x-supernodeCategory": "required" + }, + "replicas": { + "default": 1, + "description": "Advanced, usually not modified. Number of Dolos StatefulSet replicas. Keep 1 because the chart provisions a single writable data volume per pod and Supernode expects one Dolos instance per release.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "network" + ], + "type": "object", + "x-supernodeCategory": "required" + }, + "fullnameOverride": { + "default": "", + "description": "Advanced, usually not modified. Overrides the full Kubernetes resource name. Leave empty so the release name controls resource names.", + "type": "string", + "x-supernodeCategory": "advanced" + }, + "image": { + "additionalProperties": false, + "description": "Optional. Dolos container image settings. Most installs only change image.tag when pinning or testing a specific Dolos release.", + "properties": { + "repository": { + "default": "ghcr.io/txpipe/dolos", + "description": "Advanced, usually not modified. Container image repository for Dolos.", + "type": "string" + }, + "tag": { + "default": "v1.1.1", + "description": "Optional. Dolos image tag to deploy. Keep the chart default unless a specific Dolos version is required.", + "type": "string" + } + }, + "type": "object", + "x-supernodeCategory": "optional" + }, + "nameOverride": { + "default": "", + "description": "Advanced, usually not modified. Overrides the Kubernetes app name used by the chart. Leave empty unless there is a naming collision.", + "type": "string", + "x-supernodeCategory": "advanced" + }, + "nodeSelector": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "description": "Advanced, usually not modified. Kubernetes nodeSelector for scheduling Dolos onto specific nodes. Use only when the cluster has known node labels for storage or workload placement.", + "type": "object", + "x-supernodeCategory": "advanced" + }, + "persistence": { + "additionalProperties": false, + "description": "Required. Persistent storage for the Dolos chain database. At minimum set persistence.storageClass for Supernode installs.", + "properties": { + "accessModes": { + "default": [ + "ReadWriteOnce" + ], + "description": "Advanced, usually not modified. PVC access modes. ReadWriteOnce is appropriate for the single-replica StatefulSet.", + "items": { + "type": "string" + }, + "type": "array" + }, + "annotations": { + "$ref": "#/definitions/stringMap", + "description": "Advanced, usually not modified. Annotations added to the generated Dolos data PVC." + }, + "existingClaim": { + "default": "", + "description": "Advanced, usually not modified. Existing PVC name to mount instead of creating a StatefulSet volumeClaimTemplate. Leave empty for normal installs.", + "type": "string" + }, + "size": { + "default": "20Gi", + "description": "Optional. Requested PVC size for the Dolos chain database. Use larger values for mainnet; 50Gi is suitable for preview and preprod defaults.", + "pattern": "^[0-9]+(Mi|Gi|Ti)$", + "type": "string" + }, + "storageClass": { + "description": "Required. Kubernetes StorageClass used for the Dolos data PVC. Choose a StorageClass available in the target cluster.", + "minLength": 1, + "type": "string", + "x-supernodeCategory": "required" + } + }, + "required": [ + "storageClass" + ], + "type": "object", + "x-supernodeCategory": "required" + }, + "resources": { + "additionalProperties": false, + "description": "Optional. Kubernetes CPU and memory requests and limits for both the Dolos container and bootstrap init container. Recommended limits are 4 CPU cores and 8Gi memory for cardano-mainnet, and 2 CPU cores and 4Gi memory for cardano-preview or cardano-preprod. Request little CPU, usually 500m, so scheduling stays flexible while allowing bursts up to the CPU limit. Memory requests should match memory limits because Dolos needs predictable memory and Kubernetes may evict pods whose memory request is lower than actual steady-state use.", + "properties": { + "limits": { + "$ref": "#/definitions/resourceList", + "description": "Optional. Maximum CPU and memory available to Dolos. Use cpu 4 and memory 8Gi for cardano-mainnet; use cpu 2 and memory 4Gi for cardano-preview or cardano-preprod." + }, + "requests": { + "$ref": "#/definitions/resourceList", + "description": "Optional. Scheduler reservation for Dolos CPU and memory. Use cpu 500m for all networks. Set memory equal to limits.memory: 8Gi for cardano-mainnet and 4Gi for cardano-preview or cardano-preprod." + } + }, + "type": "object", + "x-supernodeCategory": "optional" + }, + "service": { + "additionalProperties": false, + "description": "Optional. Kubernetes Service settings for Dolos API exposure. Use ClusterIP for in-cluster access; use LoadBalancer only when external access is required.", + "properties": { + "annotations": { + "$ref": "#/definitions/stringMap", + "description": "Advanced, usually not modified. Additional annotations for cloud load balancer integrations." + }, + "clusterIP": { + "default": "", + "description": "Advanced, usually not modified. Explicit ClusterIP value. Leave empty for Kubernetes to allocate one.", + "type": "string" + }, + "externalTrafficPolicy": { + "default": "", + "description": "Advanced, usually not modified. External traffic policy for NodePort or LoadBalancer services. Leave empty unless the cluster networking policy requires Local or Cluster.", + "enum": [ + "", + "Cluster", + "Local" + ], + "type": "string" + }, + "grpcNodePort": { + "default": null, + "description": "Advanced, usually not modified. Explicit NodePort for grpc when service.type is NodePort or LoadBalancer. Leave null for Kubernetes allocation.", + "type": [ + "integer", + "null" + ] + }, + "grpcPort": { + "default": 50051, + "description": "Advanced, usually not modified. Service and container port for UTxO RPC gRPC.", + "type": "integer" + }, + "labels": { + "$ref": "#/definitions/stringMap", + "description": "Advanced, usually not modified. Additional labels on the Service resource." + }, + "loadBalancerIP": { + "default": "", + "description": "Advanced, usually not modified. Requested static load balancer IP, if supported by the cluster provider.", + "type": "string" + }, + "minibfNodePort": { + "default": null, + "description": "Advanced, usually not modified. Explicit NodePort for minibf when service.type is NodePort or LoadBalancer. Leave null for Kubernetes allocation.", + "type": [ + "integer", + "null" + ] + }, + "minibfPort": { + "default": 3001, + "description": "Advanced, usually not modified. Service and container port for the Blockfrost-compatible minibf HTTP API.", + "type": "integer" + }, + "minikupoNodePort": { + "default": null, + "description": "Advanced, usually not modified. Explicit NodePort for minikupo when service.type is NodePort or LoadBalancer. Leave null for Kubernetes allocation.", + "type": [ + "integer", + "null" + ] + }, + "minikupoPort": { + "default": 1442, + "description": "Advanced, usually not modified. Service and container port for the Kupo-compatible minikupo HTTP API.", + "type": "integer" + }, + "ouroborosNodePort": { + "default": null, + "description": "Advanced, usually not modified. Reserved for compatibility with values consumers; the opinionated Service does not expose ouroboros.", + "type": [ + "integer", + "null" + ] + }, + "ouroborosPort": { + "default": 30013, + "description": "Advanced, usually not modified. Internal Dolos ouroboros container port. This port is not exposed on the Service.", + "type": "integer" + }, + "trpNodePort": { + "default": null, + "description": "Advanced, usually not modified. Explicit NodePort for trp when service.type is NodePort or LoadBalancer. Leave null for Kubernetes allocation.", + "type": [ + "integer", + "null" + ] + }, + "trpPort": { + "default": 8164, + "description": "Advanced, usually not modified. Service and container port for the Dolos TRP HTTP API.", + "type": "integer" + }, + "type": { + "default": "ClusterIP", + "description": "Optional. Kubernetes Service type. ClusterIP keeps Dolos internal to the cluster. LoadBalancer exposes the service externally when the cluster supports it.", + "enum": [ + "ClusterIP", + "NodePort", + "LoadBalancer" + ], + "type": "string" + } + }, + "type": "object", + "x-supernodeCategory": "optional" + }, + "tolerations": { + "default": [], + "description": "Advanced, usually not modified. Kubernetes tolerations for scheduling Dolos onto tainted nodes. Only set when the target cluster requires specific taints.", + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "x-supernodeCategory": "advanced" + }, + "topologySpreadConstraints": { + "default": [], + "description": "Advanced, usually not modified. Kubernetes topology spread constraints. Mostly useful for cluster operators with explicit zone or node spreading policies.", + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "x-supernodeCategory": "advanced" + } + }, + "required": [ + "dolos", + "config", + "persistence" + ], + "title": "Dolos Helm Values", + "type": "object" + }, + "secrets": [], + "dependencies": [], + "metrics": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "blockHeight": { + "description": "Latest block height served by Dolos.", + "type": [ + "number", + "null" + ] + }, + "epoch": { + "description": "Current epoch served by Dolos.", + "type": [ + "number", + "null" + ] + }, + "errors": { + "description": "Warnings or collection errors emitted by the metrics script.", + "items": { + "type": "string" + }, + "type": "array" + }, + "slotNum": { + "description": "Latest block slot served by Dolos.", + "type": [ + "number", + "null" + ] + }, + "type": { + "const": "dolos" + } + }, + "required": [ + "type", + "errors" + ], + "title": "Dolos Metrics", + "type": "object" + }, + "metricsCollection": { + "container": "dolos", + "command": [ + "/opt/metis/bin/metrics.sh" + ] + }, + "outputs": [ + { + "name": "trp", + "description": "Dolos TRP HTTP endpoint.", + "portName": "trp", + "protocol": "HTTP" + }, + { + "name": "blockfrost", + "description": "Blockfrost-compatible minibf HTTP endpoint.", + "portName": "minibf", + "protocol": "HTTP" + }, + { + "name": "kupo", + "description": "Kupo-compatible minikupo HTTP endpoint.", + "portName": "minikupo", + "protocol": "HTTP" + }, + { + "name": "utxorpc", + "description": "UTxO RPC gRPC endpoint.", + "portName": "grpc", + "protocol": "gRPC" + } + ], + "chart": "oci://oci.supernode.store/extensions/dolos" + }, + { + "id": "hydra-node", + "name": "Hydra Node", + "description": "A Hydra Head protocol node for operating Cardano L2 state channels with low-latency off-chain transactions and L1 settlement.", + "versions": [ + "0.2.0" + ], + "defaultVersion": "0.2.0", + "configuration": { + "$schema": "https://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "cardanoKeys": { + "additionalProperties": false, + "properties": { + "enabled": { + "default": false, + "description": "Set true for online Hydra mode. When true, also provide Cardano signing and verification keys and socket access.", + "type": "boolean" + }, + "signing": { + "additionalProperties": false, + "properties": { + "filename": { + "default": "cardano.sk", + "description": "Secret key and mounted filename for the Cardano signing key.", + "type": "string" + }, + "vaultStaticSecret": { + "$ref": "#/definitions/vaultStaticSecret" + } + }, + "type": "object" + }, + "socketPath": { + "default": "/ipc/node.socket", + "description": "Cardano node socket path used by hydra-node. With node.cardanoSocketProxy enabled, the proxy creates this socket inside the pod.", + "type": "string" + }, + "verification": { + "additionalProperties": false, + "properties": { + "existingConfigMap": { + "additionalProperties": false, + "properties": { + "key": { + "default": "", + "description": "Reserved for compatibility; the chart mounts the ConfigMap and uses filename for the mounted file name.", + "type": "string" + }, + "name": { + "default": "", + "description": "Existing ConfigMap containing the Cardano verification key.", + "type": "string" + } + }, + "type": "object" + }, + "filename": { + "default": "cardano.vk", + "description": "Mounted filename for the public Cardano verification key.", + "type": "string" + }, + "value": { + "description": "Public Cardano verification key payload. This is not secret material.", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "cardanoSocketProxy": { + "additionalProperties": false, + "description": "Online mode socket proxy. Enable this to project a remote Cardano node's TCP endpoint into the Hydra pod as a Unix socket. MCP does not auto-select upstreams; use workloads.list to inspect Cardano relay outputs and set targetHost/targetPort explicitly.", + "properties": { + "enabled": { + "default": false, + "description": "Set true for online mode when the Cardano socket is reached through a remote relay TCP endpoint.", + "type": "boolean" + }, + "image": { + "additionalProperties": false, + "properties": { + "repository": { + "default": "alpine/socat", + "description": "Advanced, usually not modified. Container image repository for the socat proxy sidecar.", + "type": "string" + }, + "tag": { + "default": "1.7.4.4-r0", + "description": "Advanced, usually not modified. Container image tag for the socat proxy sidecar.", + "type": "string" + } + }, + "type": "object" + }, + "listenOptions": { + "default": "reuseaddr,fork,unlink-early", + "description": "Advanced, usually not modified. socat UNIX-LISTEN options.", + "type": "string" + }, + "resources": { + "additionalProperties": true, + "default": {}, + "description": "Advanced, usually not modified. Kubernetes resources for the socat proxy sidecar.", + "type": "object" + }, + "socketPath": { + "default": "", + "description": "Unix socket path created by the proxy inside the Hydra pod. Leave empty to use keys.cardano.socketPath.", + "type": "string" + }, + "targetHost": { + "default": "", + "description": "Required when enabled. Cardano upstream relay host. Use workloads.list to find same-network relay service endpoints.", + "type": "string" + }, + "targetOptions": { + "default": "verify=0", + "description": "Advanced, usually not modified. socat target options, for example verify=0 for test relays.", + "type": "string" + }, + "targetPort": { + "default": 0, + "description": "Required when enabled. Cardano upstream relay port, usually 3000 for node-to-node TCP endpoints.", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "targetScheme": { + "default": "OPENSSL", + "description": "Advanced, usually not modified. socat target scheme for the upstream Cardano relay.", + "type": "string" + } + }, + "type": "object" + }, + "configPayload": { + "additionalProperties": false, + "properties": { + "create": { + "default": true, + "description": "Create a chart-managed ConfigMap when name is empty.", + "type": "boolean" + }, + "data": { + "description": "Inline JSON payload used when the chart creates the ConfigMap.", + "type": "string" + }, + "filename": { + "default": "protocol-parameters.json", + "description": "File name mounted into ledger.mountPath.", + "type": "string" + }, + "name": { + "default": "", + "description": "Existing ConfigMap name. Set create=false and provide this to reuse pre-created ledger data.", + "type": "string" + } + }, + "type": "object" + }, + "hydraSigning": { + "additionalProperties": false, + "properties": { + "filename": { + "default": "hydra.sk", + "description": "Secret key and mounted filename for this node's Hydra signing key.", + "type": "string" + }, + "vaultStaticSecret": { + "additionalProperties": false, + "properties": { + "mount": { + "default": "kv", + "description": "Vault KV v2 mount name.", + "type": "string" + }, + "path": { + "description": "Required. Runtime Vault path without kv/data prefix, for example runtime/hydra/demo/hydra-signing.", + "minLength": 1, + "type": "string" + }, + "refreshAfter": { + "default": "1m", + "description": "VaultStaticSecret refresh interval.", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + } + }, + "required": [ + "vaultStaticSecret" + ], + "type": "object" + }, + "hydraVerification": { + "additionalProperties": false, + "properties": { + "existingConfigMap": { + "additionalProperties": false, + "properties": { + "name": { + "default": "", + "description": "Existing ConfigMap containing public Hydra verification key files.", + "type": "string" + } + }, + "type": "object" + }, + "items": { + "description": "Required. Hydra verification key files for all parties, including this node. Include value for chart-created ConfigMaps or filename only when using existingConfigMap.name.", + "items": { + "$ref": "#/definitions/verificationItem" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "peer": { + "additionalProperties": false, + "properties": { + "host": { + "minLength": 1, + "type": "string" + }, + "port": { + "maximum": 65535, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "host", + "port" + ], + "type": "object" + }, + "resourceList": { + "additionalProperties": false, + "properties": { + "cpu": { + "description": "Kubernetes CPU quantity.", + "type": "string" + }, + "memory": { + "description": "Kubernetes memory quantity.", + "type": "string" + } + }, + "type": "object" + }, + "stringMap": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "vaultAuth": { + "additionalProperties": false, + "properties": { + "ref": { + "default": "control-plane/default", + "description": "VaultAuth reference used by chart-managed VaultStaticSecret resources.", + "type": "string" + }, + "serviceAccount": { + "additionalProperties": false, + "properties": { + "create": { + "default": true, + "description": "Create the service account referenced by VaultAuth.", + "type": "boolean" + }, + "name": { + "default": "vault-auth", + "description": "ServiceAccount name expected by the VaultAuth resource.", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "vaultStaticSecret": { + "additionalProperties": false, + "properties": { + "mount": { + "default": "kv", + "description": "Vault KV v2 mount name.", + "type": "string" + }, + "path": { + "description": "Runtime Vault path without kv/data prefix, for example runtime/hydra/demo/hydra-signing.", + "type": "string" + }, + "refreshAfter": { + "default": "1m", + "description": "VaultStaticSecret refresh interval.", + "type": "string" + } + }, + "type": "object" + }, + "verificationItem": { + "additionalProperties": false, + "properties": { + "filename": { + "minLength": 1, + "type": "string" + }, + "value": { + "description": "Public verification key payload. This is not secret material.", + "type": "string" + } + }, + "required": [ + "filename" + ], + "type": "object" + } + }, + "description": "Supernode-opinionated Hydra node chart values. This schema is the public configuration interface for LLMs and MCP clients; pass values in this shape directly to Helm. Do not use arbitrary manual volume mounts for Cardano sockets; configure node.cardanoSocketProxy for online mode.", + "properties": { + "affinity": { + "additionalProperties": true, + "default": {}, + "description": "Advanced, usually not modified. Kubernetes affinity rules for Hydra pod placement.", + "type": "object", + "x-supernodeCategory": "advanced" + }, + "displayName": { + "default": "", + "description": "Optional. Human-readable workload name shown by Supernode tooling. MCP normally sets this from releaseName.", + "type": "string", + "x-supernodeCategory": "optional" + }, + "fullnameOverride": { + "default": "", + "description": "Advanced, usually not modified. Overrides the full Kubernetes resource name. Leave empty so the release name controls resource names.", + "type": "string", + "x-supernodeCategory": "advanced" + }, + "image": { + "additionalProperties": false, + "description": "Optional. Hydra node container image settings. Most installs only change image.tag when pinning a specific Hydra release.", + "properties": { + "repository": { + "default": "ghcr.io/cardano-scaling/hydra-node", + "description": "Advanced, usually not modified. Container image repository for hydra-node.", + "type": "string" + }, + "tag": { + "default": "2.1.0", + "description": "Optional. Hydra node image tag to deploy.", + "type": "string" + } + }, + "type": "object", + "x-supernodeCategory": "optional" + }, + "keys": { + "additionalProperties": false, + "description": "Required. Hydra and optional Cardano key references. Signing keys must come from VaultStaticSecret runtime paths; public verification keys may be inline or in ConfigMaps.", + "properties": { + "cardano": { + "$ref": "#/definitions/cardanoKeys" + }, + "hydraSigning": { + "$ref": "#/definitions/hydraSigning" + }, + "hydraVerification": { + "$ref": "#/definitions/hydraVerification" + }, + "mountPath": { + "default": "/etc/keys", + "description": "Advanced, usually not modified. Mount path for projected key material.", + "type": "string" + }, + "vaultAuth": { + "$ref": "#/definitions/vaultAuth" + } + }, + "required": [ + "hydraSigning", + "hydraVerification" + ], + "type": "object", + "x-supernodeCategory": "required" + }, + "ledger": { + "additionalProperties": false, + "description": "Optional. Offline ledger configuration. Offline mode needs protocol parameters and initial UTxO from inline data or existing ConfigMaps.", + "properties": { + "initialUtxo": { + "$ref": "#/definitions/configPayload", + "description": "Offline initial UTxO JSON or existing ConfigMap reference." + }, + "mountPath": { + "default": "/etc/hydra", + "description": "Advanced, usually not modified. Mount path for offline ledger ConfigMaps.", + "type": "string" + }, + "protocolParameters": { + "$ref": "#/definitions/configPayload", + "description": "Offline ledger protocol parameters JSON or existing ConfigMap reference." + } + }, + "type": "object", + "x-supernodeCategory": "optional" + }, + "nameOverride": { + "default": "", + "description": "Advanced, usually not modified. Overrides the Kubernetes app name used by the chart.", + "type": "string", + "x-supernodeCategory": "advanced" + }, + "node": { + "additionalProperties": false, + "description": "Optional. Hydra runtime settings. Offline mode is the default for local experiments. For online mode set offlineMode=false, enable keys.cardano, set hydraScriptsTxId, and configure node.cardanoSocketProxy to reach a Cardano relay. MCP does not auto-discover Hydra peers or Cardano upstreams; use workloads.list to inspect candidate workloads.", + "properties": { + "apiHost": { + "default": "0.0.0.0", + "description": "Advanced, usually not modified. API listen host.", + "type": "string" + }, + "cardanoSocketProxy": { + "$ref": "#/definitions/cardanoSocketProxy" + }, + "contestationPeriod": { + "default": "", + "description": "Optional. Hydra contestation period, for example 43200s. Mainnet should use at least 12 hours.", + "type": "string" + }, + "depositPeriod": { + "default": "", + "description": "Optional. Deposit period used for incremental commits, for example 7200s.", + "type": "string" + }, + "host": { + "default": "0.0.0.0", + "description": "Advanced, usually not modified. Fallback listen host.", + "type": "string" + }, + "hydraScriptsTxId": { + "default": "", + "description": "Required for online mode. Transaction ID of the deployed Hydra scripts.", + "type": "string" + }, + "listenHost": { + "default": "0.0.0.0", + "description": "Advanced, usually not modified. Peer listener host.", + "type": "string" + }, + "network": { + "default": "", + "description": "Optional. Cardano network passed to hydra-node in online mode.", + "enum": [ + "", + "mainnet", + "preprod", + "preview" + ], + "type": "string" + }, + "nodeId": { + "default": "hydra-node-1", + "description": "Required for online mode. Unique Hydra node identifier; each participant must use a distinct node ID.", + "type": "string" + }, + "offlineHeadSeed": { + "default": "0001", + "description": "Required for offline mode. Hexadecimal offline head seed shared by offline participants.", + "pattern": "^[0-9a-fA-F]+$", + "type": "string" + }, + "offlineMode": { + "default": true, + "description": "Optional. true runs without Cardano L1 connectivity using offline ledger state; false runs online against Cardano.", + "type": "boolean" + }, + "peers": { + "default": [], + "description": "Optional. Static Hydra peer endpoints. Use workloads.list to inspect deployed Hydra outputs, then provide peers as host:port strings or host/port objects.", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/peer" + } + ] + }, + "type": "array" + }, + "persistenceSubPath": { + "default": "", + "description": "Advanced, usually not modified. SubPath within the Hydra data PVC.", + "type": "string" + }, + "quiet": { + "default": true, + "description": "Optional. Pass --quiet to hydra-node.", + "type": "boolean" + }, + "replicas": { + "default": 1, + "description": "Advanced, usually not modified. Number of Hydra node pods.", + "minimum": 1, + "type": "integer" + }, + "startChainFrom": { + "default": "", + "description": "Optional. Online mode chain point for starting chain synchronization.", + "type": "string" + }, + "unsyncedPeriod": { + "default": null, + "description": "Optional. Seconds after which the node considers itself out of sync with L1.", + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object", + "x-supernodeCategory": "optional" + }, + "nodeSelector": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "description": "Advanced, usually not modified. Kubernetes nodeSelector for Hydra scheduling.", + "type": "object", + "x-supernodeCategory": "advanced" + }, + "persistence": { + "additionalProperties": false, + "description": "Required. Persistent storage for the Hydra persistence directory. Persistence is always enabled for this chart.", + "properties": { + "accessModes": { + "default": [ + "ReadWriteOnce" + ], + "description": "Advanced, usually not modified. PVC access modes.", + "items": { + "type": "string" + }, + "type": "array" + }, + "annotations": { + "$ref": "#/definitions/stringMap" + }, + "existingClaim": { + "default": "", + "description": "Advanced, usually not modified. Existing PVC name to mount instead of creating a StatefulSet volumeClaimTemplate.", + "type": "string" + }, + "size": { + "default": "5Gi", + "description": "Optional. Requested Hydra persistence PVC size.", + "pattern": "^[0-9]+(Mi|Gi|Ti)$", + "type": "string" + }, + "storageClass": { + "description": "Required. Kubernetes StorageClass used for the Hydra state PVC.", + "minLength": 1, + "type": "string", + "x-supernodeCategory": "required" + } + }, + "required": [ + "storageClass" + ], + "type": "object", + "x-supernodeCategory": "required" + }, + "resources": { + "additionalProperties": false, + "description": "Optional. Kubernetes CPU and memory requests and limits for the Hydra node container. Defaults reserve 500m CPU and 1Gi memory, with a 2 CPU limit.", + "properties": { + "limits": { + "$ref": "#/definitions/resourceList" + }, + "requests": { + "$ref": "#/definitions/resourceList" + } + }, + "type": "object", + "x-supernodeCategory": "optional" + }, + "service": { + "additionalProperties": false, + "description": "Optional. Kubernetes Service settings for Hydra API, peer, and monitoring ports. Keep ClusterIP for in-cluster access; use LoadBalancer only when external clients or peers need direct access.", + "properties": { + "annotations": { + "$ref": "#/definitions/stringMap" + }, + "apiNodePort": { + "default": null, + "type": [ + "integer", + "null" + ] + }, + "apiPort": { + "default": 4001, + "description": "Advanced, usually not modified. Hydra HTTP/WebSocket API Service and container port.", + "type": "integer" + }, + "clusterIP": { + "default": "", + "type": "string" + }, + "externalTrafficPolicy": { + "default": "", + "enum": [ + "", + "Cluster", + "Local" + ], + "type": "string" + }, + "labels": { + "$ref": "#/definitions/stringMap" + }, + "loadBalancerIP": { + "default": "", + "type": "string" + }, + "monitoringNodePort": { + "default": null, + "type": [ + "integer", + "null" + ] + }, + "monitoringPort": { + "default": 6001, + "description": "Advanced, usually not modified. Hydra Prometheus metrics Service and container port.", + "type": "integer" + }, + "p2pNodePort": { + "default": null, + "type": [ + "integer", + "null" + ] + }, + "p2pPort": { + "default": 5001, + "description": "Advanced, usually not modified. Hydra peer networking Service and container port.", + "type": "integer" + }, + "type": { + "default": "ClusterIP", + "enum": [ + "ClusterIP", + "NodePort", + "LoadBalancer" + ], + "type": "string" + } + }, + "type": "object", + "x-supernodeCategory": "optional" + }, + "tolerations": { + "default": [], + "description": "Advanced, usually not modified. Kubernetes tolerations for Hydra scheduling.", + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "x-supernodeCategory": "advanced" + }, + "topologySpreadConstraints": { + "default": [], + "description": "Advanced, usually not modified. Kubernetes topology spread constraints.", + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "x-supernodeCategory": "advanced" + } + }, + "required": [ + "persistence", + "keys" + ], + "title": "Hydra Node Helm Values", + "type": "object" + }, + "secrets": [ + { + "name": "hydraSigningKey", + "description": "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.", + "required": true, + "requiredWhen": null, + "scope": "runtime", + "material": "hydra-signing-key", + "writeOnly": true, + "acceptedSources": [ + "vaultStaticSecret" + ] + }, + { + "name": "cardanoSigningKey", + "description": "Cardano signing key used by online Hydra nodes to pay L1 fuel and drive head lifecycle transactions.", + "required": false, + "requiredWhen": "keys.cardano.enabled == true", + "scope": "runtime", + "material": "cardano-signing-key", + "writeOnly": true, + "acceptedSources": [ + "vaultStaticSecret" + ] + } + ], + "dependencies": [], + "metrics": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "chainSyncedStatus": { + "description": "Hydra chain sync status when connected to L1.", + "type": [ + "string", + "null" + ] + }, + "confirmedLovelace": { + "description": "Sum of lovelace in the latest confirmed snapshot UTxO.", + "type": [ + "number", + "null" + ] + }, + "confirmedTx": { + "description": "Total confirmed L2 transactions from hydra_head_confirmed_tx.", + "type": [ + "number", + "null" + ] + }, + "confirmedUtxoCount": { + "description": "Number of entries in the latest confirmed snapshot UTxO.", + "type": [ + "number", + "null" + ] + }, + "currentSlot": { + "description": "Current chain slot reported by the Hydra API when available.", + "type": [ + "number", + "null" + ] + }, + "errors": { + "description": "Warnings or collection errors emitted by the metrics script.", + "items": { + "type": "string" + }, + "type": "array" + }, + "headId": { + "description": "Current Hydra head identifier when a head is known.", + "type": [ + "string", + "null" + ] + }, + "headStatus": { + "description": "Latest head state tag reported by the Hydra HTTP API.", + "type": [ + "string", + "null" + ] + }, + "hydraNodeVersion": { + "description": "Hydra node version reported by the HTTP API.", + "type": [ + "string", + "null" + ] + }, + "inputs": { + "description": "Total processed head inputs from hydra_head_inputs.", + "type": [ + "number", + "null" + ] + }, + "lastSeenSnapshotTag": { + "description": "Tag returned by /snapshot/last-seen for diagnosing in-flight snapshot consensus.", + "type": [ + "string", + "null" + ] + }, + "mode": { + "description": "Hydra node mode as configured by the chart: offline or online.", + "type": [ + "string", + "null" + ] + }, + "peersConnected": { + "description": "Connected Hydra peers from the Prometheus metrics endpoint.", + "type": [ + "number", + "null" + ] + }, + "pendingDeposits": { + "description": "Number of pending deposit transaction IDs returned by /commits.", + "type": [ + "number", + "null" + ] + }, + "requestedTx": { + "description": "Total requested L2 transactions from hydra_head_requested_tx.", + "type": [ + "number", + "null" + ] + }, + "snapshotNumber": { + "description": "Latest confirmed snapshot number when available.", + "type": [ + "number", + "null" + ] + }, + "snapshotVersion": { + "description": "Latest confirmed snapshot version when available.", + "type": [ + "number", + "null" + ] + }, + "txConfirmationTimeMsAvg": { + "description": "Derived average confirmation time in milliseconds.", + "type": [ + "number", + "null" + ] + }, + "txConfirmationTimeMsCount": { + "description": "Confirmation-time histogram sample count.", + "type": [ + "number", + "null" + ] + }, + "txConfirmationTimeMsSum": { + "description": "Confirmation-time histogram sample sum in milliseconds.", + "type": [ + "number", + "null" + ] + }, + "type": { + "const": "hydra-node" + } + }, + "required": [ + "type", + "errors" + ], + "title": "Hydra Node Metrics", + "type": "object" + }, + "metricsCollection": { + "container": "hydra-node", + "command": [ + "/opt/metis/bin/metrics.sh" + ] + }, + "outputs": [ + { + "name": "api", + "description": "Hydra HTTP API endpoint.", + "portName": "api", + "protocol": "HTTP" + }, + { + "name": "ws", + "description": "Hydra WebSocket client-input and server-output endpoint.", + "portName": "api", + "protocol": "WebSocket" + }, + { + "name": "p2p", + "description": "Hydra node-to-node peer networking endpoint.", + "portName": "p2p", + "protocol": "TCP" + }, + { + "name": "monitoring", + "description": "Hydra Prometheus metrics endpoint.", + "portName": "monitoring", + "protocol": "HTTP" + } + ], + "chart": "oci://oci.supernode.store/extensions/hydra-node" + } + ] +} diff --git a/extensions/control-plane/README.md b/extensions/control-plane/README.md index f1a3b5f..0c050e1 100644 --- a/extensions/control-plane/README.md +++ b/extensions/control-plane/README.md @@ -349,6 +349,10 @@ 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.extensionCatalog.source` | MCP extension catalog source (`oci` or `bundled`) | `oci` | +| `supernodeMcp.extensionCatalog.ociRef` | OCI artifact reference for the MCP extension catalog JSON | `oci://oci.supernode.store/extension-catalog:0.1.0` | +| `supernodeMcp.extensionCatalog.maxBytes` | Maximum accepted catalog JSON blob size in bytes | `1048576` | +| `supernodeMcp.extensionCatalog.allowUntrusted` | Allows non-`oci.supernode.store` catalog and chart refs. Unsafe; development only | `false` | | `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` | diff --git a/extensions/control-plane/templates/stage4-04-statefulset-supernode-mcp.yaml b/extensions/control-plane/templates/stage4-04-statefulset-supernode-mcp.yaml index 99a2583..42a8120 100644 --- a/extensions/control-plane/templates/stage4-04-statefulset-supernode-mcp.yaml +++ b/extensions/control-plane/templates/stage4-04-statefulset-supernode-mcp.yaml @@ -44,6 +44,14 @@ spec: value: {{ .Values.supernodeMcp.auth.mode | quote }} - name: MCP_LOG_LEVEL value: {{ .Values.supernodeMcp.logLevel | quote }} + - name: MCP_EXTENSION_CATALOG_SOURCE + value: {{ .Values.supernodeMcp.extensionCatalog.source | quote }} + - name: MCP_EXTENSION_CATALOG_OCI_REF + value: {{ .Values.supernodeMcp.extensionCatalog.ociRef | quote }} + - name: MCP_EXTENSION_CATALOG_MAX_BYTES + value: {{ .Values.supernodeMcp.extensionCatalog.maxBytes | int | quote }} + - name: MCP_EXTENSION_CATALOG_ALLOW_UNTRUSTED + value: {{ .Values.supernodeMcp.extensionCatalog.allowUntrusted | quote }} - name: MCP_SESSION_STORE value: {{ .Values.supernodeMcp.sessionStore.type | quote }} - name: MCP_SESSION_SQLITE_PATH diff --git a/extensions/control-plane/values.yaml b/extensions/control-plane/values.yaml index 9d7d1d6..3229917 100644 --- a/extensions/control-plane/values.yaml +++ b/extensions/control-plane/values.yaml @@ -173,6 +173,11 @@ supernodeMcp: mode: trusted logLevel: info bindAddr: 0.0.0.0:8443 + extensionCatalog: + source: oci + ociRef: oci://oci.supernode.store/extension-catalog:0.1.0 + maxBytes: 1048576 + allowUntrusted: false sessionStore: type: sqlite sqlitePath: /var/lib/supernode-mcp/sessions.sqlite3 diff --git a/mcp-server/src/catalog/apex_fusion_block_producer.rs b/mcp-server/src/catalog/apex_fusion_block_producer.rs deleted file mode 100644 index 67c5930..0000000 --- a/mcp-server/src/catalog/apex_fusion_block_producer.rs +++ /dev/null @@ -1,45 +0,0 @@ -use super::{ - ExtensionConfiguration, ExtensionDefinition, ExtensionMetricsCollection, - ExtensionOutputDefinition, ExtensionSecretDefinition, -}; - -pub(super) fn definition() -> ExtensionDefinition { - ExtensionDefinition::new( - "apex-fusion-block-producer", - "Apex Fusion Block Producer", - "A private Apex Fusion block producer workload with optional managed relays and Vault-synced runtime producer material.", - vec!["0.1.0"], - "0.1.0", - configuration_schema(), - secrets(), - vec![], - super::apex_fusion_relay::metrics_schema(), - outputs(), - "oci://oci.supernode.store/extensions/apex-fusion-block-producer".to_string(), - ) - .with_metrics_collection(ExtensionMetricsCollection::metrics_script("apex-fusion")) -} - -fn configuration_schema() -> ExtensionConfiguration { - serde_json::from_str(include_str!( - "../../../extensions/apex-fusion-block-producer/values.schema.json" - )) - .expect("embedded Apex Fusion block producer values schema must be valid JSON") -} - -fn secrets() -> Vec { - vec![ExtensionSecretDefinition::new( - "blockProducerRuntime", - "Runtime Apex Fusion producer material synced from Vault: kes.skey, vrf.skey, and op.cert. Cold keys and counters must not be mounted into the producer pod.", - true, - None, - "runtime", - "apex-fusion-block-producer-runtime", - true, - vec!["vaultStaticSecret"], - )] -} - -fn outputs() -> Vec { - super::apex_fusion_relay::outputs() -} diff --git a/mcp-server/src/catalog/apex_fusion_relay.rs b/mcp-server/src/catalog/apex_fusion_relay.rs deleted file mode 100644 index ac2c467..0000000 --- a/mcp-server/src/catalog/apex_fusion_relay.rs +++ /dev/null @@ -1,67 +0,0 @@ -use serde_json::json; - -use super::{ - ExtensionConfiguration, ExtensionDefinition, ExtensionMetrics, ExtensionMetricsCollection, - ExtensionOutputDefinition, -}; -use crate::catalog::schema::nullable_number; - -pub(super) fn definition() -> ExtensionDefinition { - ExtensionDefinition::new( - "apex-fusion-relay", - "Apex Fusion Relay", - "An Apex Fusion relay node workload for participating in Vector and Prime 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/apex-fusion-relay".to_string(), - ) - .with_metrics_collection(ExtensionMetricsCollection::metrics_script("apex-fusion")) -} - -fn configuration_schema() -> ExtensionConfiguration { - serde_json::from_str(include_str!( - "../../../extensions/apex-fusion-relay/values.schema.json" - )) - .expect("embedded Apex Fusion relay values schema must be valid JSON") -} - -pub(super) fn outputs() -> Vec { - vec![ - ExtensionOutputDefinition::new( - "n2n", - "Apex Fusion node-to-node networking endpoint for relay peer connectivity.", - "n2n", - "TCP", - ), - ExtensionOutputDefinition::new( - "n2c", - "Apex Fusion node-to-client endpoint for local clients through the chart proxy.", - "n2c", - "TCP", - ), - ] -} - -pub(super) fn metrics_schema() -> ExtensionMetrics { - json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Apex Fusion Relay Metrics", - "type": "object", - "required": ["type", "errors"], - "properties": { - "type": { "const": "apex-fusion" }, - "blockHeight": nullable_number("Latest block number observed by the node."), - "epoch": nullable_number("Current epoch observed by the node."), - "slotNum": nullable_number("Absolute slot number observed by the node."), - "peersIncoming": nullable_number("Incoming peer connection count."), - "peersOutgoing": nullable_number("Outgoing peer connection count."), - "errors": { "type": "array", "items": { "type": "string" } } - }, - "additionalProperties": true - }) -} diff --git a/mcp-server/src/catalog/cardano_block_producer.rs b/mcp-server/src/catalog/cardano_block_producer.rs deleted file mode 100644 index e989c97..0000000 --- a/mcp-server/src/catalog/cardano_block_producer.rs +++ /dev/null @@ -1,45 +0,0 @@ -use super::{ - ExtensionConfiguration, ExtensionDefinition, ExtensionMetricsCollection, - ExtensionOutputDefinition, ExtensionSecretDefinition, -}; - -pub(super) fn definition() -> ExtensionDefinition { - ExtensionDefinition::new( - "cardano-block-producer", - "Cardano Block Producer", - "A private Cardano block producer workload with optional managed relays and Vault-synced runtime producer material.", - vec!["0.1.0"], - "0.1.0", - configuration_schema(), - secrets(), - vec![], - super::cardano_relay::block_producer_metrics_schema(), - outputs(), - "oci://oci.supernode.store/extensions/cardano-block-producer".to_string(), - ) - .with_metrics_collection(ExtensionMetricsCollection::metrics_script("cardano-node")) -} - -fn configuration_schema() -> ExtensionConfiguration { - serde_json::from_str(include_str!( - "../../../extensions/cardano-block-producer/values.schema.json" - )) - .expect("embedded Cardano block producer values schema must be valid JSON") -} - -fn secrets() -> Vec { - vec![ExtensionSecretDefinition::new( - "blockProducerRuntime", - "Runtime producer material synced from Vault: kes.skey, vrf.skey, and op.cert. Cold keys and counters must not be mounted into the producer pod.", - true, - None, - "runtime", - "cardano-block-producer-runtime", - true, - vec!["vaultStaticSecret"], - )] -} - -fn outputs() -> Vec { - super::cardano_relay::outputs() -} diff --git a/mcp-server/src/catalog/cardano_relay.rs b/mcp-server/src/catalog/cardano_relay.rs deleted file mode 100644 index d4abe20..0000000 --- a/mcp-server/src/catalog/cardano_relay.rs +++ /dev/null @@ -1,323 +0,0 @@ -use serde_json::json; - -use super::{ - ExtensionConfiguration, ExtensionDefinition, ExtensionMetrics, ExtensionMetricsCollection, - ExtensionOutputDefinition, -}; -use crate::catalog::schema::{nullable_boolean, nullable_number, nullable_string}; - -pub(super) fn definition() -> ExtensionDefinition { - ExtensionDefinition::new( - "cardano-relay", - "Cardano 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-relay".to_string(), - ) - .with_metrics_collection(ExtensionMetricsCollection::metrics_script("cardano-node")) -} - -pub(super) 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", - ), - ] -} - -pub(super) fn metrics_schema() -> ExtensionMetrics { - metrics_schema_for("Cardano Relay Metrics", "relay") -} - -pub(super) fn block_producer_metrics_schema() -> ExtensionMetrics { - metrics_schema_for("Cardano Block Producer Metrics", "block-producer") -} - -fn metrics_schema_for(title: &str, role: &str) -> ExtensionMetrics { - let mut properties = serde_json::Map::new(); - properties.insert("type".to_string(), json!({ "const": "cardano-node" })); - properties.insert("role".to_string(), json!({ "const": role })); - 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": title, - "type": "object", - "required": ["type", "role", "errors"], - "properties": properties, - "additionalProperties": false - }) -} - -fn configuration_schema() -> ExtensionConfiguration { - serde_json::from_str(include_str!( - "../../../extensions/cardano-relay/values.schema.json" - )) - .expect("embedded Cardano relay values schema must be valid JSON") -} diff --git a/mcp-server/src/catalog/dolos.rs b/mcp-server/src/catalog/dolos.rs deleted file mode 100644 index 0e269cf..0000000 --- a/mcp-server/src/catalog/dolos.rs +++ /dev/null @@ -1,69 +0,0 @@ -use serde_json::json; - -use super::{ - ExtensionConfiguration, ExtensionDefinition, ExtensionMetrics, ExtensionMetricsCollection, - 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(), - ) - .with_metrics_collection(ExtensionMetricsCollection::metrics_script("dolos")) -} - -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 { - serde_json::from_str(include_str!("../../../extensions/dolos/values.schema.json")) - .expect("embedded Dolos values schema must be valid JSON") -} - -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 index d39cd1b..c0b11e4 100644 --- a/mcp-server/src/catalog/extension.rs +++ b/mcp-server/src/catalog/extension.rs @@ -1,31 +1,18 @@ -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; pub type ExtensionId = String; pub type ExtensionConfiguration = Value; pub type ExtensionMetrics = Value; -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExtensionMetricsCollection { pub container: String, pub command: Vec, } -impl ExtensionMetricsCollection { - pub fn new(container: &str, command: Vec<&str>) -> Self { - Self { - container: container.to_string(), - command: command.into_iter().map(str::to_string).collect(), - } - } - - pub fn metrics_script(container: &str) -> Self { - Self::new(container, vec!["/opt/metis/bin/metrics.sh"]) - } -} - -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExtensionSecretDefinition { pub name: String, @@ -38,32 +25,7 @@ pub struct ExtensionSecretDefinition { 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)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExtensionOutputDefinition { pub name: String, @@ -72,18 +34,7 @@ pub struct ExtensionOutputDefinition { 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)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExtensionDefinition { pub id: ExtensionId, @@ -100,40 +51,3 @@ pub struct ExtensionDefinition { 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, - metrics_collection: None, - outputs, - chart, - } - } - - pub fn with_metrics_collection(mut self, collection: ExtensionMetricsCollection) -> Self { - self.metrics_collection = Some(collection); - self - } -} diff --git a/mcp-server/src/catalog/hydra_node.rs b/mcp-server/src/catalog/hydra_node.rs deleted file mode 100644 index 72868a7..0000000 --- a/mcp-server/src/catalog/hydra_node.rs +++ /dev/null @@ -1,117 +0,0 @@ -use serde_json::json; - -use super::{ - ExtensionConfiguration, ExtensionDefinition, ExtensionMetrics, ExtensionMetricsCollection, - 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(), - ) - .with_metrics_collection(ExtensionMetricsCollection::metrics_script("hydra-node")) -} - -fn configuration_schema() -> ExtensionConfiguration { - serde_json::from_str(include_str!( - "../../../extensions/hydra-node/values.schema.json" - )) - .expect("embedded Hydra node values schema must be valid JSON") -} - -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("keys.cardano.enabled == true"), - "runtime", - "cardano-signing-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 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 index bef774e..3087b22 100644 --- a/mcp-server/src/catalog/mod.rs +++ b/mcp-server/src/catalog/mod.rs @@ -1,39 +1,61 @@ -mod apex_fusion_block_producer; -mod apex_fusion_relay; -mod cardano_block_producer; -mod cardano_relay; -mod dolos; pub mod extension; -mod hydra_node; -mod schema; +mod oci; +pub mod source; use std::collections::BTreeMap; +use std::collections::BTreeSet; -use serde::Serialize; +use serde::{Deserialize, Serialize}; -pub use extension::ExtensionConfiguration; pub use extension::ExtensionDefinition; pub use extension::ExtensionId; -pub use extension::ExtensionMetrics; -pub use extension::ExtensionMetricsCollection; pub use extension::ExtensionOutputDefinition; -pub use extension::ExtensionSecretDefinition; + +const CATALOG_SCHEMA_VERSION: &str = "supernode.extensionCatalog/v1"; +const TRUSTED_OCI_REGISTRY: &str = "oci.supernode.store"; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionCatalogDocument { + pub schema_version: String, + pub extensions: Vec, +} #[derive(Debug, Clone, Serialize)] pub struct ExtensionCatalog { extensions: BTreeMap, } +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionSummary<'a> { + pub id: &'a str, + pub name: &'a str, + pub description: &'a str, + pub versions: &'a [String], + pub default_version: &'a str, + pub dependencies: &'a [ExtensionId], + pub outputs: &'a [ExtensionOutputDefinition], + pub chart: &'a str, +} + +pub fn extension_summary(extension: &ExtensionDefinition) -> ExtensionSummary<'_> { + ExtensionSummary { + id: &extension.id, + name: &extension.name, + description: &extension.description, + versions: &extension.versions, + default_version: &extension.default_version, + dependencies: &extension.dependencies, + outputs: &extension.outputs, + chart: &extension.chart, + } +} + impl ExtensionCatalog { - pub fn embedded() -> Self { - Self::from_extensions([ - apex_fusion_block_producer::definition(), - apex_fusion_relay::definition(), - cardano_block_producer::definition(), - cardano_relay::definition(), - dolos::definition(), - hydra_node::definition(), - ]) + pub fn bundled() -> Self { + Self::from_json_str(include_str!("../../../catalog/extension-catalog.json")) + .expect("bundled extension catalog must be valid") } pub fn from_extensions(extensions: impl IntoIterator) -> Self { @@ -44,6 +66,37 @@ impl ExtensionCatalog { Self { extensions } } + pub fn from_json_str(payload: &str) -> Result { + Self::from_json_str_with_trust(payload, false) + } + + pub fn from_json_str_with_trust( + payload: &str, + allow_untrusted: bool, + ) -> Result { + let document = serde_json::from_str::(payload)?; + Self::from_document_with_trust(document, allow_untrusted) + } + + pub fn from_document_with_trust( + document: ExtensionCatalogDocument, + allow_untrusted: bool, + ) -> Result { + if document.schema_version != CATALOG_SCHEMA_VERSION { + return Err(CatalogLoadError::UnsupportedSchemaVersion( + document.schema_version, + )); + } + + validate_extensions(&document.extensions, allow_untrusted)?; + Ok(Self::from_extensions(document.extensions)) + } + + #[cfg(test)] + pub fn testing() -> Self { + Self::bundled() + } + pub fn list(&self) -> impl Iterator { self.extensions.values() } @@ -57,14 +110,176 @@ impl ExtensionCatalog { } } +#[derive(Debug, thiserror::Error)] +pub enum CatalogLoadError { + #[error("extension catalog JSON is invalid: {0}")] + InvalidJson(#[from] serde_json::Error), + #[error("extension catalog JSON is not valid UTF-8: {0}")] + InvalidUtf8(#[from] std::str::Utf8Error), + #[error("unsupported extension catalog schema version: {0}")] + UnsupportedSchemaVersion(String), + #[error("invalid extension catalog: {0}")] + InvalidCatalog(String), + #[error("missing extension catalog OCI reference")] + MissingOciReference, + #[error("untrusted extension catalog OCI reference: {0}")] + UntrustedCatalogReference(String), + #[error("untrusted extension chart OCI reference: {0}")] + UntrustedChartReference(String), + #[error("failed to load extension catalog from OCI: {0}")] + Oci(#[from] oci::OciCatalogError), +} + +fn validate_extensions( + extensions: &[ExtensionDefinition], + allow_untrusted: bool, +) -> Result<(), CatalogLoadError> { + let mut ids = BTreeSet::new(); + for extension in extensions { + if extension.id.trim().is_empty() { + return invalid_catalog("extension id must not be empty"); + } + if !ids.insert(extension.id.as_str()) { + return invalid_catalog(format!("duplicate extension id: {}", extension.id)); + } + if extension.name.trim().is_empty() { + return invalid_catalog(format!( + "extension name must not be empty: {}", + extension.id + )); + } + if extension.versions.is_empty() { + return invalid_catalog(format!( + "extension versions must not be empty: {}", + extension.id + )); + } + if !extension + .versions + .iter() + .any(|version| version == &extension.default_version) + { + return invalid_catalog(format!( + "extension defaultVersion must be listed in versions: {}", + extension.id + )); + } + if !extension.configuration.is_object() { + return invalid_catalog(format!( + "extension configuration schema must be an object: {}", + extension.id + )); + } + if !extension.metrics.is_object() { + return invalid_catalog(format!( + "extension metrics schema must be an object: {}", + extension.id + )); + } + if !extension.chart.starts_with("oci://") { + return invalid_catalog(format!( + "extension chart must be an OCI reference: {}", + extension.id + )); + } + if chart_basename(&extension.chart) != Some(extension.id.as_str()) { + return invalid_catalog(format!( + "extension id must match OCI chart basename: {}", + extension.id + )); + } + if !allow_untrusted && !is_trusted_extension_chart(&extension.chart, &extension.id) { + return Err(CatalogLoadError::UntrustedChartReference( + extension.chart.clone(), + )); + } + if let Some(metrics_collection) = &extension.metrics_collection { + if metrics_collection.container.trim().is_empty() { + return invalid_catalog(format!( + "extension metrics collection container must not be empty: {}", + extension.id + )); + } + if metrics_collection.command.is_empty() { + return invalid_catalog(format!( + "extension metrics collection command must not be empty: {}", + extension.id + )); + } + } + for output in &extension.outputs { + if output.name.trim().is_empty() || output.port_name.trim().is_empty() { + return invalid_catalog(format!( + "extension output names must not be empty: {}", + extension.id + )); + } + } + } + + for extension in extensions { + for dependency in &extension.dependencies { + if !ids.contains(dependency.as_str()) { + return invalid_catalog(format!( + "extension dependency is not in catalog: {} -> {}", + extension.id, dependency + )); + } + } + } + + Ok(()) +} + +fn is_trusted_extension_chart(chart: &str, extension_id: &str) -> bool { + let Some(rest) = chart.strip_prefix("oci://") else { + return false; + }; + let Some((registry, repository)) = rest.split_once('/') else { + return false; + }; + + registry == TRUSTED_OCI_REGISTRY + && strip_oci_reference_suffix(repository) == format!("extensions/{extension_id}") +} + +fn strip_oci_reference_suffix(repository: &str) -> &str { + let without_digest = repository + .split_once('@') + .map_or(repository, |(repo, _)| repo); + let last_slash = without_digest.rfind('/'); + if let Some(index) = without_digest.rfind(':') + && last_slash + .map(|last_slash| index > last_slash) + .unwrap_or(true) + { + return &without_digest[..index]; + } + + without_digest +} + +fn chart_basename(chart: &str) -> Option<&str> { + chart + .strip_prefix("oci://")? + .trim_end_matches('/') + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) +} + +fn invalid_catalog(message: impl Into) -> Result<(), CatalogLoadError> { + Err(CatalogLoadError::InvalidCatalog(message.into())) +} + #[cfg(test)] mod tests { use super::*; use serde_json::{Value, json}; #[test] - fn embedded_catalog_contains_cardano_extensions_dolos_and_hydra() { - let catalog = ExtensionCatalog::embedded(); + fn bundled_catalog_contains_cardano_extensions_dolos_and_hydra() { + let catalog = ExtensionCatalog::testing(); assert_eq!(catalog.len(), 6); assert!(catalog.get("apex-fusion-relay").is_some()); @@ -76,9 +291,81 @@ mod tests { assert!(catalog.get("hydra-node").is_some()); } + #[test] + fn catalog_json_rejects_duplicate_extension_ids() { + let extension = ExtensionCatalog::testing().get("dolos").unwrap().clone(); + let document = ExtensionCatalogDocument { + schema_version: CATALOG_SCHEMA_VERSION.to_string(), + extensions: vec![extension.clone(), extension], + }; + + let error = ExtensionCatalog::from_document_with_trust(document, false).unwrap_err(); + + assert!(matches!(error, CatalogLoadError::InvalidCatalog(_))); + } + + #[test] + fn catalog_json_requires_default_version_to_be_listed() { + let mut extension = ExtensionCatalog::testing().get("dolos").unwrap().clone(); + extension.default_version = "9.9.9".to_string(); + let document = ExtensionCatalogDocument { + schema_version: CATALOG_SCHEMA_VERSION.to_string(), + extensions: vec![extension], + }; + + let error = ExtensionCatalog::from_document_with_trust(document, false).unwrap_err(); + + assert!(matches!(error, CatalogLoadError::InvalidCatalog(_))); + } + + #[test] + fn catalog_json_requires_extension_id_to_match_chart_basename() { + let mut extension = ExtensionCatalog::testing().get("dolos").unwrap().clone(); + extension.chart = "oci://oci.supernode.store/extensions/not-dolos".to_string(); + let document = ExtensionCatalogDocument { + schema_version: CATALOG_SCHEMA_VERSION.to_string(), + extensions: vec![extension], + }; + + let error = ExtensionCatalog::from_document_with_trust(document, false).unwrap_err(); + + assert!(matches!(error, CatalogLoadError::InvalidCatalog(_))); + } + + #[test] + fn catalog_json_rejects_untrusted_chart_references_by_default() { + let mut extension = ExtensionCatalog::testing().get("dolos").unwrap().clone(); + extension.chart = "oci://evil.example/extensions/dolos".to_string(); + let document = ExtensionCatalogDocument { + schema_version: CATALOG_SCHEMA_VERSION.to_string(), + extensions: vec![extension], + }; + + let error = ExtensionCatalog::from_document_with_trust(document, false).unwrap_err(); + + assert!(matches!( + error, + CatalogLoadError::UntrustedChartReference(_) + )); + } + + #[test] + fn catalog_json_allows_untrusted_chart_references_when_explicit() { + let mut extension = ExtensionCatalog::testing().get("dolos").unwrap().clone(); + extension.chart = "oci://evil.example/extensions/dolos".to_string(); + let document = ExtensionCatalogDocument { + schema_version: CATALOG_SCHEMA_VERSION.to_string(), + extensions: vec![extension], + }; + + let catalog = ExtensionCatalog::from_document_with_trust(document, true).unwrap(); + + assert!(catalog.get("dolos").is_some()); + } + #[test] fn relay_extension_exposes_domain_contract() { - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let extension = catalog.get("cardano-relay").unwrap(); assert_eq!(extension.name, "Cardano Relay"); @@ -93,7 +380,7 @@ mod tests { #[test] fn relay_configuration_does_not_expose_power_user_config_override() { - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let configuration = &catalog.get("cardano-relay").unwrap().configuration; let properties = configuration .get("properties") @@ -124,7 +411,7 @@ mod tests { #[test] fn block_producer_configuration_exposes_debug_and_relays() { - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let configuration = &catalog.get("cardano-block-producer").unwrap().configuration; assert_eq!( @@ -144,7 +431,7 @@ mod tests { #[test] fn relay_metrics_schema_describes_script_output_fields() { - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let metrics = &catalog.get("cardano-relay").unwrap().metrics; let properties = metrics .get("properties") @@ -162,7 +449,7 @@ mod tests { #[test] fn extensions_define_metrics_collection_metadata() { - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let cases = [ ("cardano-relay", "cardano-node"), ("cardano-block-producer", "cardano-node"), @@ -190,7 +477,7 @@ mod tests { #[test] fn dolos_extension_exposes_domain_contract() { - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let extension = catalog.get("dolos").unwrap(); assert_eq!(extension.name, "Dolos"); @@ -205,7 +492,7 @@ mod tests { #[test] fn dolos_configuration_only_exposes_safe_cardano_fields() { - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let configuration = &catalog.get("dolos").unwrap().configuration; let properties = configuration .get("properties") @@ -258,7 +545,7 @@ mod tests { #[test] fn dolos_metrics_schema_describes_basic_minibf_fields() { - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let metrics = &catalog.get("dolos").unwrap().metrics; let properties = metrics .get("properties") @@ -275,7 +562,7 @@ mod tests { #[test] fn hydra_extension_exposes_domain_contract() { - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let extension = catalog.get("hydra-node").unwrap(); assert_eq!(extension.name, "Hydra Node"); @@ -290,7 +577,7 @@ mod tests { #[test] fn hydra_extension_describes_runtime_secret_metadata() { - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let extension = catalog.get("hydra-node").unwrap(); let hydra_signing = extension @@ -312,7 +599,7 @@ mod tests { #[test] fn hydra_metrics_schema_describes_api_and_prometheus_fields() { - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let metrics = &catalog.get("hydra-node").unwrap().metrics; let properties = metrics .get("properties") @@ -327,7 +614,7 @@ mod tests { #[test] fn extension_outputs_describe_exposed_endpoints_for_llms() { - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let relay = catalog.get("cardano-relay").unwrap(); let dolos = catalog.get("dolos").unwrap(); let hydra = catalog.get("hydra-node").unwrap(); diff --git a/mcp-server/src/catalog/oci.rs b/mcp-server/src/catalog/oci.rs new file mode 100644 index 0000000..2314b93 --- /dev/null +++ b/mcp-server/src/catalog/oci.rs @@ -0,0 +1,299 @@ +use reqwest::header::ACCEPT; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +const CATALOG_LAYER_MEDIA_TYPE: &str = "application/vnd.supernode.extension-catalog.v1+json"; +const JSON_MEDIA_TYPE: &str = "application/json"; +const MANIFEST_ACCEPT: &str = "application/vnd.oci.image.manifest.v1+json, application/vnd.oci.artifact.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json"; +const TRUSTED_CATALOG_REGISTRY: &str = "oci.supernode.store"; + +pub(super) fn is_trusted_catalog_reference(reference: &str) -> Result { + Ok(OciReference::parse(reference)?.registry == TRUSTED_CATALOG_REGISTRY) +} + +pub(super) async fn fetch_catalog_json( + reference: &str, + max_bytes: usize, +) -> Result, OciCatalogError> { + let reference = OciReference::parse(reference)?; + let client = reqwest::Client::new(); + let manifest = fetch_manifest(&client, &reference).await?; + let descriptor = select_catalog_descriptor(&manifest) + .ok_or_else(|| OciCatalogError::MissingCatalogLayer(reference.original.clone()))?; + + if descriptor.size.is_some_and(|size| size > max_bytes) { + return Err(OciCatalogError::CatalogTooLarge { + actual: descriptor.size.unwrap_or_default(), + max: max_bytes, + }); + } + + let payload = fetch_blob(&client, &reference, &descriptor.digest).await?; + if payload.len() > max_bytes { + return Err(OciCatalogError::CatalogTooLarge { + actual: payload.len(), + max: max_bytes, + }); + } + verify_sha256_digest(&descriptor.digest, &payload)?; + + Ok(payload) +} + +async fn fetch_manifest( + client: &reqwest::Client, + reference: &OciReference, +) -> Result { + let response = client + .get(reference.manifest_url()) + .header(ACCEPT, MANIFEST_ACCEPT) + .send() + .await? + .error_for_status() + .map_err(OciCatalogError::HttpStatus)?; + + response.json::().await.map_err(Into::into) +} + +async fn fetch_blob( + client: &reqwest::Client, + reference: &OciReference, + digest: &str, +) -> Result, OciCatalogError> { + let response = client + .get(reference.blob_url(digest)) + .send() + .await? + .error_for_status() + .map_err(OciCatalogError::HttpStatus)?; + + Ok(response.bytes().await?.to_vec()) +} + +fn select_catalog_descriptor(manifest: &OciManifest) -> Option { + manifest + .layers + .iter() + .chain(manifest.blobs.iter()) + .find(|descriptor| descriptor.media_type == CATALOG_LAYER_MEDIA_TYPE) + .or_else(|| { + manifest + .layers + .iter() + .chain(manifest.blobs.iter()) + .find(|descriptor| descriptor.media_type == JSON_MEDIA_TYPE) + }) + .cloned() +} + +fn verify_sha256_digest(expected: &str, payload: &[u8]) -> Result<(), OciCatalogError> { + let Some(expected) = expected.strip_prefix("sha256:") else { + return Err(OciCatalogError::UnsupportedDigest(expected.to_string())); + }; + let actual = hex_lower(&Sha256::digest(payload)); + if actual != expected.to_ascii_lowercase() { + return Err(OciCatalogError::DigestMismatch { + expected: expected.to_string(), + actual, + }); + } + + Ok(()) +} + +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 +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct OciReference { + original: String, + registry: String, + repository: String, + reference: String, +} + +impl OciReference { + fn parse(value: &str) -> Result { + let value = value.trim(); + let Some(rest) = value.strip_prefix("oci://") else { + return Err(OciCatalogError::InvalidReference(value.to_string())); + }; + let Some((registry, path)) = rest.split_once('/') else { + return Err(OciCatalogError::InvalidReference(value.to_string())); + }; + if registry.is_empty() || path.is_empty() { + return Err(OciCatalogError::InvalidReference(value.to_string())); + } + + let last_slash = path.rfind('/'); + let tag_separator = path.rfind(':').filter(|index| { + last_slash + .map(|last_slash| *index > last_slash) + .unwrap_or(true) + }); + + let (repository, reference) = if let Some((repository, digest)) = path.split_once('@') { + (repository, digest) + } else if let Some(index) = tag_separator { + (&path[..index], &path[index + 1..]) + } else { + return Err(OciCatalogError::MissingReference(value.to_string())); + }; + + if repository.is_empty() || reference.is_empty() { + return Err(OciCatalogError::InvalidReference(value.to_string())); + } + + Ok(Self { + original: value.to_string(), + registry: registry.to_string(), + repository: repository.to_string(), + reference: reference.to_string(), + }) + } + + fn manifest_url(&self) -> String { + format!( + "https://{}/v2/{}/manifests/{}", + self.registry, self.repository, self.reference + ) + } + + fn blob_url(&self, digest: &str) -> String { + format!( + "https://{}/v2/{}/blobs/{}", + self.registry, self.repository, digest + ) + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct OciManifest { + #[serde(default)] + layers: Vec, + #[serde(default)] + blobs: Vec, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct OciDescriptor { + media_type: String, + digest: String, + size: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum OciCatalogError { + #[error("invalid OCI reference: {0}")] + InvalidReference(String), + #[error("OCI reference must include a tag or digest: {0}")] + MissingReference(String), + #[error("OCI registry request failed: {0}")] + Request(#[from] reqwest::Error), + #[error("OCI registry returned an unsuccessful status: {0}")] + HttpStatus(reqwest::Error), + #[error("OCI catalog artifact does not contain a catalog JSON layer: {0}")] + MissingCatalogLayer(String), + #[error("OCI catalog blob is too large: {actual} bytes exceeds {max} bytes")] + CatalogTooLarge { actual: usize, max: usize }, + #[error("unsupported OCI catalog digest: {0}")] + UnsupportedDigest(String), + #[error("OCI catalog digest mismatch: expected {expected}, got {actual}")] + DigestMismatch { expected: String, actual: String }, +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn parses_tagged_oci_references() { + let reference = + OciReference::parse("oci://oci.supernode.store/extension-catalog:0.1.0").unwrap(); + + assert_eq!(reference.registry, "oci.supernode.store"); + assert_eq!(reference.repository, "extension-catalog"); + assert_eq!(reference.reference, "0.1.0"); + assert_eq!( + reference.manifest_url(), + "https://oci.supernode.store/v2/extension-catalog/manifests/0.1.0" + ); + } + + #[test] + fn parses_digest_oci_references() { + let reference = + OciReference::parse("oci://oci.supernode.store/extension-catalog@sha256:abc123") + .unwrap(); + + assert_eq!(reference.repository, "extension-catalog"); + assert_eq!(reference.reference, "sha256:abc123"); + } + + #[test] + fn rejects_oci_references_without_tag_or_digest() { + let error = OciReference::parse("oci://oci.supernode.store/extension-catalog").unwrap_err(); + + assert!(matches!(error, OciCatalogError::MissingReference(_))); + } + + #[test] + fn identifies_trusted_catalog_references() { + assert!( + is_trusted_catalog_reference("oci://oci.supernode.store/extension-catalog:0.1.0") + .unwrap() + ); + assert!( + !is_trusted_catalog_reference("oci://evil.example/extension-catalog:0.1.0").unwrap() + ); + } + + #[test] + fn selects_catalog_media_type_before_plain_json() { + let manifest = serde_json::from_value::(json!({ + "layers": [ + { + "mediaType": "application/json", + "digest": "sha256:plain", + "size": 1 + }, + { + "mediaType": CATALOG_LAYER_MEDIA_TYPE, + "digest": "sha256:catalog", + "size": 1 + } + ] + })) + .unwrap(); + + let descriptor = select_catalog_descriptor(&manifest).unwrap(); + + assert_eq!(descriptor.digest, "sha256:catalog"); + } + + #[test] + fn verifies_sha256_digest() { + let payload = b"catalog"; + let digest = format!("sha256:{}", hex_lower(&Sha256::digest(payload))); + + verify_sha256_digest(&digest, payload).unwrap(); + } + + #[test] + fn rejects_sha256_digest_mismatch() { + let error = verify_sha256_digest("sha256:0000", b"catalog").unwrap_err(); + + assert!(matches!(error, OciCatalogError::DigestMismatch { .. })); + } +} diff --git a/mcp-server/src/catalog/schema.rs b/mcp-server/src/catalog/schema.rs deleted file mode 100644 index 9931e07..0000000 --- a/mcp-server/src/catalog/schema.rs +++ /dev/null @@ -1,14 +0,0 @@ -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/catalog/source.rs b/mcp-server/src/catalog/source.rs new file mode 100644 index 0000000..1aab140 --- /dev/null +++ b/mcp-server/src/catalog/source.rs @@ -0,0 +1,26 @@ +use crate::config::{ExtensionCatalogConfig, ExtensionCatalogSource}; + +use super::{CatalogLoadError, ExtensionCatalog, oci}; + +pub async fn load_catalog( + config: &ExtensionCatalogConfig, +) -> Result { + match config.source { + ExtensionCatalogSource::Bundled => Ok(ExtensionCatalog::bundled()), + ExtensionCatalogSource::Oci => { + let oci_ref = config + .oci_ref + .as_deref() + .ok_or(CatalogLoadError::MissingOciReference)?; + if !config.allow_untrusted && !oci::is_trusted_catalog_reference(oci_ref)? { + return Err(CatalogLoadError::UntrustedCatalogReference( + oci_ref.to_string(), + )); + } + let payload = oci::fetch_catalog_json(oci_ref, config.max_bytes).await?; + let payload = std::str::from_utf8(&payload)?; + + ExtensionCatalog::from_json_str_with_trust(payload, config.allow_untrusted) + } + } +} diff --git a/mcp-server/src/config.rs b/mcp-server/src/config.rs index 49fa466..8d78de5 100644 --- a/mcp-server/src/config.rs +++ b/mcp-server/src/config.rs @@ -12,6 +12,7 @@ pub struct Config { pub auth_mode: AuthMode, pub log_level: String, pub session_store: SessionStoreConfig, + pub extension_catalog: ExtensionCatalogConfig, } #[derive(Debug, Clone, Eq, PartialEq)] @@ -23,6 +24,34 @@ pub enum SessionStoreConfig { }, } +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct ExtensionCatalogConfig { + pub source: ExtensionCatalogSource, + pub oci_ref: Option, + pub max_bytes: usize, + pub allow_untrusted: bool, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum ExtensionCatalogSource { + Bundled, + Oci, +} + +impl FromStr for ExtensionCatalogSource { + type Err = ConfigError; + + fn from_str(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "" | "bundled" => Ok(Self::Bundled), + "oci" => Ok(Self::Oci), + other => Err(ConfigError::InvalidExtensionCatalogSource( + other.to_string(), + )), + } + } +} + impl FromStr for SessionStoreConfig { type Err = ConfigError; @@ -53,16 +82,37 @@ impl Config { let session_store = env::var("MCP_SESSION_STORE") .unwrap_or_else(|_| "memory".to_string()) .parse()?; + let extension_catalog = extension_catalog_config()?; Ok(Self { bind_addr, auth_mode, log_level, session_store, + extension_catalog, }) } } +fn extension_catalog_config() -> Result { + let source = env::var("MCP_EXTENSION_CATALOG_SOURCE") + .unwrap_or_else(|_| "bundled".to_string()) + .parse()?; + let oci_ref = env::var("MCP_EXTENSION_CATALOG_OCI_REF") + .ok() + .filter(|value| !value.trim().is_empty()); + if source == ExtensionCatalogSource::Oci && oci_ref.is_none() { + return Err(ConfigError::MissingExtensionCatalogOciRef); + } + + Ok(ExtensionCatalogConfig { + source, + oci_ref, + max_bytes: extension_catalog_max_bytes()?, + allow_untrusted: extension_catalog_allow_untrusted()?, + }) +} + fn session_sqlite_path() -> PathBuf { env::var("MCP_SESSION_SQLITE_PATH") .map(PathBuf::from) @@ -77,6 +127,31 @@ fn session_ttl_seconds() -> Result, ConfigError> { .transpose() } +fn extension_catalog_max_bytes() -> Result { + env::var("MCP_EXTENSION_CATALOG_MAX_BYTES") + .ok() + .filter(|value| !value.trim().is_empty()) + .map(|value| { + value + .parse() + .map_err(ConfigError::InvalidExtensionCatalogMaxBytes) + }) + .transpose() + .map(|value| value.unwrap_or(1_048_576)) +} + +fn extension_catalog_allow_untrusted() -> Result { + match env::var("MCP_EXTENSION_CATALOG_ALLOW_UNTRUSTED") { + Ok(value) if value.trim().is_empty() => Ok(false), + Ok(value) => match value.trim().to_ascii_lowercase().as_str() { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(ConfigError::InvalidExtensionCatalogAllowUntrusted(value)), + }, + Err(_) => Ok(false), + } +} + #[cfg(test)] mod tests { use super::*; @@ -95,4 +170,26 @@ mod tests { assert!(matches!(error, ConfigError::InvalidSessionStore(_))); } + + #[test] + fn parses_catalog_sources() { + assert_eq!( + "bundled".parse::().unwrap(), + ExtensionCatalogSource::Bundled + ); + assert_eq!( + "oci".parse::().unwrap(), + ExtensionCatalogSource::Oci + ); + } + + #[test] + fn rejects_unknown_catalog_source() { + let error = "file".parse::().unwrap_err(); + + assert!(matches!( + error, + ConfigError::InvalidExtensionCatalogSource(_) + )); + } } diff --git a/mcp-server/src/errors.rs b/mcp-server/src/errors.rs index 57f6dad..07037e5 100644 --- a/mcp-server/src/errors.rs +++ b/mcp-server/src/errors.rs @@ -12,4 +12,12 @@ pub enum ConfigError { InvalidSessionStore(String), #[error("invalid MCP_SESSION_TTL_SECONDS: {0}")] InvalidSessionTtl(ParseIntError), + #[error("invalid MCP_EXTENSION_CATALOG_SOURCE '{0}', expected 'bundled' or 'oci'")] + InvalidExtensionCatalogSource(String), + #[error("MCP_EXTENSION_CATALOG_OCI_REF is required when MCP_EXTENSION_CATALOG_SOURCE=oci")] + MissingExtensionCatalogOciRef, + #[error("invalid MCP_EXTENSION_CATALOG_MAX_BYTES: {0}")] + InvalidExtensionCatalogMaxBytes(ParseIntError), + #[error("invalid MCP_EXTENSION_CATALOG_ALLOW_UNTRUSTED '{0}', expected 'true' or 'false'")] + InvalidExtensionCatalogAllowUntrusted(String), } diff --git a/mcp-server/src/mcp.rs b/mcp-server/src/mcp.rs index a45aa1e..b7c9231 100644 --- a/mcp-server/src/mcp.rs +++ b/mcp-server/src/mcp.rs @@ -57,6 +57,7 @@ impl SupernodeMcpServer { catalog: Arc, ) -> Self { let resources = ResourceRouter::new(catalog.clone()); + let dynamic_tools = DynamicToolState::new(catalog.clone()); Self { auth, @@ -66,7 +67,7 @@ impl SupernodeMcpServer { resources, prompts: PromptCatalog, tools: ToolRouter::new(), - dynamic_tools: DynamicToolState::default(), + dynamic_tools, } } @@ -343,7 +344,7 @@ mod tests { AuthContext::trusted(), Policy, Arc::new(TracingAuditSink), - Arc::new(ExtensionCatalog::embedded()), + Arc::new(ExtensionCatalog::testing()), ); let info = server.server_info(); diff --git a/mcp-server/src/prompts/catalog.rs b/mcp-server/src/prompts/catalog.rs index 7ba38b1..49a2849 100644 --- a/mcp-server/src/prompts/catalog.rs +++ b/mcp-server/src/prompts/catalog.rs @@ -45,7 +45,7 @@ const PROMPTS: &[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.", + text: "Inspect the Supernode through read-only resources and discovery tools first. Use supernode://status, supernode://control-plane/status, and supernode://extensions/catalog for extension summaries and outputs. Read a specific supernode://extensions/catalog/{extensionId} entry only when full configuration or metrics schemas are needed. 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", diff --git a/mcp-server/src/resources/router.rs b/mcp-server/src/resources/router.rs index ad98616..8320f0f 100644 --- a/mcp-server/src/resources/router.rs +++ b/mcp-server/src/resources/router.rs @@ -10,7 +10,7 @@ use serde::Serialize; use serde_json::json; use crate::auth::AuthContext; -use crate::catalog::ExtensionCatalog; +use crate::catalog::{ExtensionCatalog, extension_summary}; use super::uri::CONTROL_PLANE_STATUS_URI; use super::uri::EXTENSION_CATALOG_URI; @@ -48,7 +48,7 @@ impl ResourceRouter { EXTENSION_CATALOG_URI, "extensions-catalog", "Extension Catalog", - "Embedded catalog of extensions supported by this MCP server.", + "Summary catalog of extensions supported by this MCP server.", ), ]; @@ -57,7 +57,7 @@ impl ResourceRouter { extension_catalog_entry_uri(&extension.id), format!("extension-catalog-{}", extension.id), extension.name.clone(), - format!("Embedded catalog entry for {}.", extension.name), + format!("Full catalog entry for {}.", extension.name), ) })); @@ -82,7 +82,7 @@ impl ResourceRouter { "reason": "kubernetes-discovery-not-implemented", }), SupernodeResourceUri::ExtensionCatalog => json!({ - "extensions": self.catalog.list().collect::>(), + "extensions": self.catalog.list().map(extension_summary).collect::>(), }), SupernodeResourceUri::ExtensionCatalogEntry { extension_id } => serde_json::to_value( self.catalog @@ -130,11 +130,13 @@ fn text_resource( #[cfg(test)] mod tests { + use serde_json::Value; + use super::*; #[test] fn lists_static_and_extension_catalog_resources() { - let router = ResourceRouter::new(Arc::new(ExtensionCatalog::embedded())); + let router = ResourceRouter::new(Arc::new(ExtensionCatalog::testing())); let resources = router.list().resources; @@ -153,7 +155,7 @@ mod tests { #[test] fn reads_catalog_resource_as_json() { - let router = ResourceRouter::new(Arc::new(ExtensionCatalog::embedded())); + let router = ResourceRouter::new(Arc::new(ExtensionCatalog::testing())); let result = router .read(EXTENSION_CATALOG_URI, &AuthContext::trusted()) @@ -168,12 +170,32 @@ mod tests { }; assert_eq!(mime_type.as_deref(), Some(JSON_MIME_TYPE)); assert!(text.contains("cardano-relay")); + let value = serde_json::from_str::(text).unwrap(); + let relay = value + .pointer("/extensions") + .and_then(Value::as_array) + .unwrap() + .iter() + .find(|extension| { + extension.pointer("/id") == Some(&Value::String("cardano-relay".to_string())) + }) + .unwrap(); + assert!(relay.get("configuration").is_none()); + assert!(relay.get("metrics").is_none()); + assert!(relay.get("metricsCollection").is_none()); + assert!(relay.get("secrets").is_none()); + assert!( + relay + .pointer("/outputs") + .and_then(Value::as_array) + .is_some_and(|outputs| !outputs.is_empty()) + ); assert!(!text.contains("secret-value")); } #[test] fn reads_one_catalog_entry() { - let router = ResourceRouter::new(Arc::new(ExtensionCatalog::embedded())); + let router = ResourceRouter::new(Arc::new(ExtensionCatalog::testing())); let result = router .read( @@ -191,7 +213,7 @@ mod tests { #[test] fn unknown_resource_returns_not_found() { - let router = ResourceRouter::new(Arc::new(ExtensionCatalog::embedded())); + let router = ResourceRouter::new(Arc::new(ExtensionCatalog::testing())); let error = router .read( diff --git a/mcp-server/src/server.rs b/mcp-server/src/server.rs index aa80681..2150d1a 100644 --- a/mcp-server/src/server.rs +++ b/mcp-server/src/server.rs @@ -16,7 +16,7 @@ use tracing::info; use crate::audit::TracingAuditSink; use crate::auth::AuthContext; use crate::auth::AuthMode; -use crate::catalog::ExtensionCatalog; +use crate::catalog::source::load_catalog; use crate::config::Config; use crate::config::SessionStoreConfig; use crate::mcp::SupernodeMcpServer; @@ -30,7 +30,7 @@ struct HealthResponse { pub async fn run(config: Config) -> anyhow::Result<()> { let cancellation_token = CancellationToken::new(); - let app = router(config.clone(), cancellation_token.child_token())?; + let app = router(config.clone(), cancellation_token.child_token()).await?; let listener = TcpListener::bind(config.bind_addr).await?; info!( @@ -47,19 +47,20 @@ pub async fn run(config: Config) -> anyhow::Result<()> { Ok(()) } -fn router(config: Config, cancellation_token: CancellationToken) -> anyhow::Result { +async 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 catalog = load_catalog(&config.extension_catalog).await?; let mcp_state = SupernodeMcpServer::new( auth_context.clone(), Policy, Arc::new(TracingAuditSink), - Arc::new(ExtensionCatalog::embedded()), + Arc::new(catalog), ); let mut mcp_config = StreamableHttpServerConfig::default() .with_cancellation_token(cancellation_token) diff --git a/mcp-server/src/tools/dynamic.rs b/mcp-server/src/tools/dynamic.rs index 31df13c..af851de 100644 --- a/mcp-server/src/tools/dynamic.rs +++ b/mcp-server/src/tools/dynamic.rs @@ -2,6 +2,7 @@ use std::collections::BTreeSet; use std::sync::Arc; use tokio::sync::RwLock; +use crate::catalog::ExtensionCatalog; use crate::k8s::HelmReleaseDiscovery; use crate::k8s::KubernetesClient; @@ -16,12 +17,20 @@ pub(crate) enum DynamicToolError { HelmRelease(#[from] crate::k8s::HelmReleaseError), } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub(crate) struct DynamicToolState { + catalog: Arc, definitions: Arc>>>, } impl DynamicToolState { + pub(crate) fn new(catalog: Arc) -> Self { + Self { + catalog, + definitions: Arc::new(RwLock::new(Arc::new(Vec::new()))), + } + } + pub(crate) async fn definitions(&self) -> Arc> { self.definitions.read().await.clone() } @@ -31,7 +40,7 @@ impl DynamicToolState { } pub(crate) async fn refresh(&self) -> bool { - let definitions = match discover_definitions().await { + let definitions = match discover_definitions(&self.catalog).await { Ok(definitions) => definitions, Err(error) => { tracing::debug!(%error, "failed to refresh dynamic MCP tools"); @@ -48,12 +57,14 @@ impl DynamicToolState { } } -async fn discover_definitions() -> Result, DynamicToolError> { +async fn discover_definitions( + catalog: &ExtensionCatalog, +) -> 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); + let installed_extension_ids = workloads::registry::installed_extension_ids(&releases, catalog); Ok(workloads::dynamic_definitions(&installed_extension_ids)) } @@ -67,6 +78,7 @@ fn tool_signature(definitions: &[ToolDefinition]) -> BTreeSet<&'static str> { #[cfg(test)] mod tests { + use crate::catalog::ExtensionCatalog; use crate::k8s::HelmChartSummary; use crate::k8s::HelmReleaseSummary; @@ -89,7 +101,9 @@ mod tests { secret_name: None, config: None, }]; - let installed_extension_ids = workloads::registry::installed_extension_ids(&releases); + let catalog = ExtensionCatalog::testing(); + let installed_extension_ids = + workloads::registry::installed_extension_ids(&releases, &catalog); let definitions = workloads::dynamic_definitions(&installed_extension_ids); diff --git a/mcp-server/src/tools/k8s_summaries.rs b/mcp-server/src/tools/k8s_summaries.rs index dbcd066..1514061 100644 --- a/mcp-server/src/tools/k8s_summaries.rs +++ b/mcp-server/src/tools/k8s_summaries.rs @@ -13,18 +13,6 @@ 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", @@ -35,18 +23,6 @@ pub(crate) fn deployment_summary(deployment: &Deployment) -> Value { }) } -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", @@ -57,14 +33,6 @@ pub(crate) fn stateful_set_summary(stateful_set: &StatefulSet) -> Value { }) } -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", diff --git a/mcp-server/src/tools/router.rs b/mcp-server/src/tools/router.rs index b8e9f73..37f3348 100644 --- a/mcp-server/src/tools/router.rs +++ b/mcp-server/src/tools/router.rs @@ -4,7 +4,7 @@ use rmcp::model::{CallToolResult, JsonObject, ListToolsResult, Meta, Tool, ToolA use serde_json::{Value, json}; use crate::{ - catalog::ExtensionCatalog, + catalog::{ExtensionCatalog, extension_summary}, helm::{self, HelmChartRef, HelmInstallPlan}, k8s::{HelmReleaseDiscovery, KubernetesClient, ResourceListParams}, vault::{SecretObject, VaultClient, VaultError, VaultPath, WriteMode}, @@ -214,7 +214,7 @@ async fn events_list(arguments: Option<&JsonObject>) -> CallToolResult { fn catalog_list(catalog: &ExtensionCatalog) -> CallToolResult { success(json!({ - "extensions": catalog.list().collect::>(), + "extensions": catalog.list().map(extension_summary).collect::>(), })) } @@ -399,7 +399,6 @@ async fn workloads_list( ) -> 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), @@ -418,60 +417,34 @@ async fn 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 + let workloads = 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()) - }) + .map(|release| workload_summary(release, catalog)) .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, + "workloads": workloads, })) } +fn workload_summary(release: &crate::k8s::HelmReleaseSummary, catalog: &ExtensionCatalog) -> Value { + let catalog_extension = + registry::extension_for_release(release, catalog).map(extension_summary); + + json!({ + "namespace": release.namespace, + "name": release.name, + "status": release.status, + "revision": release.revision, + "chart": release.chart, + "appVersion": release.app_version, + "updated": release.updated, + "catalogExtension": catalog_extension, + }) +} + async fn workloads_get( arguments: Option<&JsonObject>, catalog: &ExtensionCatalog, @@ -702,7 +675,6 @@ async fn workloads_metrics_get( }, "helmRelease": helm_release, "metrics": metrics, - "metricsSchema": extension.metrics, "stderr": if output.stderr.trim().is_empty() { Value::Null } else { Value::String(output.stderr) }, })) } @@ -1102,6 +1074,7 @@ impl Default for ToolRouter { #[cfg(test)] mod tests { use crate::catalog::ExtensionCatalog; + use crate::k8s::{HelmChartSummary, HelmReleaseSummary}; use super::*; @@ -1193,7 +1166,7 @@ mod tests { #[tokio::test] async fn executes_catalog_get_tool() { let router = ToolRouter::new(); - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let definition = router .get_with_dynamic("extensions.catalog.get", &[]) .unwrap(); @@ -1213,12 +1186,92 @@ mod tests { .and_then(|value| value.pointer("/extension/id")), Some(&Value::String("cardano-relay".to_string())) ); + let content = result.structured_content.as_ref().unwrap(); + assert!(content.pointer("/extension/configuration").is_some()); + assert!(content.pointer("/extension/metrics").is_some()); + } + + #[tokio::test] + async fn catalog_list_returns_summaries_without_large_schemas() { + let router = ToolRouter::new(); + let catalog = ExtensionCatalog::testing(); + let definition = router + .get_with_dynamic("extensions.catalog.list", &[]) + .unwrap(); + + let result = router.call(definition, None, &catalog).await; + + assert_eq!(result.is_error, Some(false)); + let content = result.structured_content.as_ref().unwrap(); + let dolos = content + .pointer("/extensions") + .and_then(Value::as_array) + .unwrap() + .iter() + .find(|extension| extension.pointer("/id") == Some(&json!("dolos"))) + .unwrap(); + assert_eq!(dolos.pointer("/name"), Some(&json!("Dolos"))); + assert!(dolos.get("configuration").is_none()); + assert!(dolos.get("metrics").is_none()); + assert!(dolos.get("metricsCollection").is_none()); + assert!(dolos.get("secrets").is_none()); + assert!( + dolos + .pointer("/outputs") + .and_then(Value::as_array) + .is_some_and(|outputs| outputs + .iter() + .any(|output| { output.pointer("/name") == Some(&json!("kupo")) })) + ); + } + + #[test] + fn workload_summary_uses_catalog_extension_summary() { + let catalog = ExtensionCatalog::testing(); + let release = 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: Some("1.1.1".to_string()), + description: None, + updated: None, + secret_name: None, + config: None, + }; + + let summary = workload_summary(&release, &catalog); + + assert_eq!(summary.pointer("/name"), Some(&json!("dolos-preview"))); + assert_eq!( + summary.pointer("/catalogExtension/id"), + Some(&json!("dolos")) + ); + assert!(summary.pointer("/catalogExtension/configuration").is_none()); + assert!(summary.pointer("/catalogExtension/metrics").is_none()); + assert!( + summary + .pointer("/catalogExtension/metricsCollection") + .is_none() + ); + assert!( + summary + .pointer("/catalogExtension/outputs") + .and_then(Value::as_array) + .is_some_and(|outputs| outputs + .iter() + .any(|output| { output.pointer("/name") == Some(&json!("kupo")) })) + ); } #[tokio::test] async fn missing_required_argument_returns_tool_error() { let router = ToolRouter::new(); - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let definition = router .get_with_dynamic("extensions.catalog.get", &[]) .unwrap(); @@ -1238,7 +1291,7 @@ mod tests { #[tokio::test] async fn workloads_metrics_get_dispatches_to_argument_validation() { let router = ToolRouter::new(); - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let definition = router .get_with_dynamic("workloads.metrics.get", &[]) .unwrap(); @@ -1258,7 +1311,7 @@ mod tests { #[tokio::test] async fn workloads_upgrade_dispatches_to_argument_validation() { let router = ToolRouter::new(); - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let definition = router.get_with_dynamic("workloads.upgrade", &[]).unwrap(); let result = router.call(definition, None, &catalog).await; @@ -1276,7 +1329,7 @@ mod tests { #[tokio::test] async fn workloads_install_dry_run_returns_validated_plan() { let router = ToolRouter::new(); - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let definition = router.get_with_dynamic("workloads.install", &[]).unwrap(); let mut arguments = JsonObject::new(); arguments.insert( @@ -1333,7 +1386,7 @@ mod tests { #[tokio::test] async fn workloads_install_rejects_unknown_raw_helm_values() { let router = ToolRouter::new(); - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let definition = router.get_with_dynamic("workloads.install", &[]).unwrap(); let mut arguments = JsonObject::new(); arguments.insert( @@ -1378,7 +1431,7 @@ mod tests { #[tokio::test] async fn dolos_install_dry_run_returns_validated_plan() { let router = ToolRouter::new(); - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let definition = router.get_with_dynamic("workloads.install", &[]).unwrap(); let mut arguments = JsonObject::new(); arguments.insert( @@ -1443,7 +1496,7 @@ mod tests { #[tokio::test] async fn hydra_install_dry_run_passes_chart_values_directly() { let router = ToolRouter::new(); - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let definition = router.get_with_dynamic("workloads.install", &[]).unwrap(); let mut arguments = JsonObject::new(); arguments.insert( @@ -1550,7 +1603,7 @@ mod tests { #[tokio::test] async fn vault_runtime_tool_rejects_non_runtime_path_before_client_setup() { let router = ToolRouter::new(); - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let definition = router.get_with_dynamic("vault.runtime.write", &[]).unwrap(); let mut arguments = JsonObject::new(); arguments.insert( diff --git a/mcp-server/src/tools/supernode.rs b/mcp-server/src/tools/supernode.rs index 5b124d5..2f883c9 100644 --- a/mcp-server/src/tools/supernode.rs +++ b/mcp-server/src/tools/supernode.rs @@ -38,7 +38,7 @@ pub fn definitions() -> &'static [ToolDefinition] { ToolDefinition { name: "extensions.catalog.list", title: "List Extension Catalog", - description: "List supported embedded extension catalog entries.", + description: "List supported extension summaries, including outputs. Does not include configuration or metrics schemas.", required_scope: Scope::Discover, approval_class: ApprovalClass::Discovery, read_only: true, @@ -48,7 +48,7 @@ pub fn definitions() -> &'static [ToolDefinition] { ToolDefinition { name: "extensions.catalog.get", title: "Get Extension Catalog Entry", - description: "Get one extension's configuration and metrics schemas.", + description: "Get one extension's full catalog entry, including configuration and metrics schemas.", required_scope: Scope::Discover, approval_class: ApprovalClass::Discovery, read_only: true, diff --git a/mcp-server/src/tools/workloads/dolos.rs b/mcp-server/src/tools/workloads/dolos.rs index 7c5234e..6b38488 100644 --- a/mcp-server/src/tools/workloads/dolos.rs +++ b/mcp-server/src/tools/workloads/dolos.rs @@ -71,7 +71,7 @@ pub(crate) async fn snapshot_refresh(arguments: Option<&JsonObject>) -> CallTool } }; - if release.chart.name.as_deref() != Some(registry::DOLOS_CHART_NAME) { + if release.chart.name.as_deref() != Some(registry::DOLOS_EXTENSION_ID) { return tool_error( "unsupported_workload", "snapshot refresh is only supported for Dolos workloads", diff --git a/mcp-server/src/tools/workloads/mod.rs b/mcp-server/src/tools/workloads/mod.rs index eb81780..4d07660 100644 --- a/mcp-server/src/tools/workloads/mod.rs +++ b/mcp-server/src/tools/workloads/mod.rs @@ -17,7 +17,7 @@ pub fn definitions() -> &'static [ToolDefinition] { ToolDefinition { name: "workloads.list", title: "List Workloads", - description: "List installed Helm workloads, namespaces, charts, versions, and status.", + description: "List installed workload summaries with catalog extension outputs when available.", required_scope: Scope::Discover, approval_class: ApprovalClass::Discovery, read_only: true, @@ -27,7 +27,7 @@ pub fn definitions() -> &'static [ToolDefinition] { ToolDefinition { name: "workloads.get", title: "Get Workload", - description: "Inspect one workload release and related Kubernetes objects.", + description: "Inspect one workload release and related Kubernetes objects in detail.", required_scope: Scope::Discover, approval_class: ApprovalClass::Discovery, read_only: true, diff --git a/mcp-server/src/tools/workloads/outputs.rs b/mcp-server/src/tools/workloads/outputs.rs index ed3d0ce..b5dc2dd 100644 --- a/mcp-server/src/tools/workloads/outputs.rs +++ b/mcp-server/src/tools/workloads/outputs.rs @@ -171,7 +171,7 @@ mod tests { #[test] fn relay_outputs_include_internal_n2n_and_n2c() { - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let release = helm_release(Some("cardano-relay")); let service = service_with_ports( "relay-preview-cardano-relay", @@ -204,7 +204,7 @@ mod tests { #[test] fn dolos_outputs_include_internal_endpoints() { - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let release = helm_release(Some("dolos")); let service = service_with_ports( "dolos-preview", @@ -240,7 +240,7 @@ mod tests { #[test] fn hydra_outputs_include_api_websocket_p2p_and_monitoring() { - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let release = helm_release(Some("hydra-node")); let service = service_with_ports( "hydra-preview-hydra-node", @@ -274,7 +274,7 @@ mod tests { #[test] fn outputs_filter_by_namespace() { - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let release = helm_release(Some("dolos")); let wrong_namespace = service_with_ports( "dolos-preview", @@ -303,7 +303,7 @@ mod tests { #[test] fn load_balancer_service_adds_external_outputs() { - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let release = helm_release(Some("dolos")); let service = service_with_ports( "dolos-preview", @@ -336,7 +336,7 @@ mod tests { #[test] fn load_balancer_hostname_is_used_for_external_outputs() { - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let release = helm_release(Some("cardano-relay")); let service = service_with_ports( "relay-preview-cardano-relay", @@ -364,7 +364,7 @@ mod tests { #[test] fn headless_services_are_not_reported_as_outputs() { - let catalog = ExtensionCatalog::embedded(); + let catalog = ExtensionCatalog::testing(); let release = helm_release(Some("dolos")); let mut service = service_with_ports( "dolos-preview-headless", diff --git a/mcp-server/src/tools/workloads/registry.rs b/mcp-server/src/tools/workloads/registry.rs index 101b50f..0078676 100644 --- a/mcp-server/src/tools/workloads/registry.rs +++ b/mcp-server/src/tools/workloads/registry.rs @@ -4,45 +4,86 @@ use crate::catalog::ExtensionCatalog; use crate::catalog::ExtensionDefinition; use crate::k8s::HelmReleaseSummary; -pub(crate) const APEX_FUSION_RELAY_CHART_NAME: &str = "apex-fusion-relay"; pub(crate) const APEX_FUSION_RELAY_EXTENSION_ID: &str = "apex-fusion-relay"; -pub(crate) const APEX_FUSION_BLOCK_PRODUCER_CHART_NAME: &str = "apex-fusion-block-producer"; pub(crate) const APEX_FUSION_BLOCK_PRODUCER_EXTENSION_ID: &str = "apex-fusion-block-producer"; -pub(crate) const CARDANO_RELAY_CHART_NAME: &str = "cardano-relay"; pub(crate) const CARDANO_RELAY_EXTENSION_ID: &str = "cardano-relay"; -pub(crate) const CARDANO_BLOCK_PRODUCER_CHART_NAME: &str = "cardano-block-producer"; pub(crate) const CARDANO_BLOCK_PRODUCER_EXTENSION_ID: &str = "cardano-block-producer"; -pub(crate) const DOLOS_CHART_NAME: &str = "dolos"; pub(crate) const DOLOS_EXTENSION_ID: &str = "dolos"; -pub(crate) const HYDRA_NODE_CHART_NAME: &str = "hydra-node"; pub(crate) const HYDRA_NODE_EXTENSION_ID: &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())?) + catalog.get(release.chart.name.as_deref()?) } -pub(crate) fn extension_id_for_chart(chart_name: Option<&str>) -> Option<&'static str> { - match chart_name { - Some(APEX_FUSION_RELAY_CHART_NAME) => Some(APEX_FUSION_RELAY_EXTENSION_ID), - Some(APEX_FUSION_BLOCK_PRODUCER_CHART_NAME) => { - Some(APEX_FUSION_BLOCK_PRODUCER_EXTENSION_ID) - } - Some(CARDANO_RELAY_CHART_NAME) => Some(CARDANO_RELAY_EXTENSION_ID), - Some(CARDANO_BLOCK_PRODUCER_CHART_NAME) => Some(CARDANO_BLOCK_PRODUCER_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 { +pub(crate) fn installed_extension_ids( + releases: &[HelmReleaseSummary], + catalog: &ExtensionCatalog, +) -> 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) + .filter_map(|release| release.chart.name.as_deref()) + .filter(|extension_id| catalog.get(extension_id).is_some()) + .map(ToString::to_string) .collect() } + +#[cfg(test)] +mod tests { + use crate::k8s::HelmChartSummary; + + use super::*; + + #[test] + fn release_chart_name_is_the_extension_id() { + let catalog = ExtensionCatalog::testing(); + let release = release("cardano-relay", "deployed"); + + let extension = extension_for_release(&release, &catalog).unwrap(); + + assert_eq!(extension.id, "cardano-relay"); + } + + #[test] + fn unknown_chart_names_are_not_catalog_managed() { + let catalog = ExtensionCatalog::testing(); + let release = release("not-in-catalog", "deployed"); + + assert!(extension_for_release(&release, &catalog).is_none()); + } + + #[test] + fn installed_extension_ids_are_catalog_backed_chart_names() { + let catalog = ExtensionCatalog::testing(); + let releases = vec![ + release("dolos", "deployed"), + release("not-in-catalog", "deployed"), + release("hydra-node", "failed"), + ]; + + let installed = installed_extension_ids(&releases, &catalog); + + assert_eq!(installed, BTreeSet::from(["dolos".to_string()])); + } + + fn release(chart_name: &str, status: &str) -> HelmReleaseSummary { + HelmReleaseSummary { + name: format!("{chart_name}-preview"), + namespace: "preview".to_string(), + revision: 1, + status: Some(status.to_string()), + chart: HelmChartSummary { + name: Some(chart_name.to_string()), + version: Some("0.1.0".to_string()), + }, + app_version: None, + description: None, + updated: None, + secret_name: None, + config: None, + } + } +} From 5e7c35e7983b35c608b928a21ffd9fdded2417bf Mon Sep 17 00:00:00 2001 From: Felipe Gonzalez Date: Thu, 21 May 2026 13:37:19 -0300 Subject: [PATCH 2/4] Rebase --- extensions/control-plane/values.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/control-plane/values.yaml b/extensions/control-plane/values.yaml index 3229917..2272d1d 100644 --- a/extensions/control-plane/values.yaml +++ b/extensions/control-plane/values.yaml @@ -166,8 +166,8 @@ supernodeMcp: enabled: true replicaCount: 1 image: - repository: ghcr.io/txpipe/metis-supernode-mcp - tag: "0.1.0" + repository: ghcr.io/txpipe/metis-mcp + tag: 8892dfba4267bc7159dfd7644c203496ffbf3d90 pullPolicy: IfNotPresent auth: mode: trusted From 50cb95603c0228eebda27f7e7440178a338f76eb Mon Sep 17 00:00:00 2001 From: Felipe Gonzalez Date: Thu, 21 May 2026 15:42:11 -0300 Subject: [PATCH 3/4] review fixes --- mcp-server/src/catalog/mod.rs | 33 ++++++++++++++++++++++++++++++--- mcp-server/src/catalog/oci.rs | 6 +++++- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/mcp-server/src/catalog/mod.rs b/mcp-server/src/catalog/mod.rs index 3087b22..45d7169 100644 --- a/mcp-server/src/catalog/mod.rs +++ b/mcp-server/src/catalog/mod.rs @@ -260,9 +260,7 @@ fn strip_oci_reference_suffix(repository: &str) -> &str { } fn chart_basename(chart: &str) -> Option<&str> { - chart - .strip_prefix("oci://")? - .trim_end_matches('/') + strip_oci_reference_suffix(chart.strip_prefix("oci://")?.trim_end_matches('/')) .rsplit('/') .next() .filter(|name| !name.is_empty()) @@ -332,6 +330,35 @@ mod tests { assert!(matches!(error, CatalogLoadError::InvalidCatalog(_))); } + #[test] + fn catalog_json_accepts_chart_basename_with_tag() { + let mut extension = ExtensionCatalog::testing().get("dolos").unwrap().clone(); + extension.chart = "oci://oci.supernode.store/extensions/dolos:1.2.3".to_string(); + let document = ExtensionCatalogDocument { + schema_version: CATALOG_SCHEMA_VERSION.to_string(), + extensions: vec![extension], + }; + + let catalog = ExtensionCatalog::from_document_with_trust(document, false).unwrap(); + + assert!(catalog.get("dolos").is_some()); + } + + #[test] + fn catalog_json_accepts_chart_basename_with_digest() { + let mut extension = ExtensionCatalog::testing().get("dolos").unwrap().clone(); + extension.chart = + "oci://oci.supernode.store/extensions/dolos@sha256:0123456789abcdef".to_string(); + let document = ExtensionCatalogDocument { + schema_version: CATALOG_SCHEMA_VERSION.to_string(), + extensions: vec![extension], + }; + + let catalog = ExtensionCatalog::from_document_with_trust(document, false).unwrap(); + + assert!(catalog.get("dolos").is_some()); + } + #[test] fn catalog_json_rejects_untrusted_chart_references_by_default() { let mut extension = ExtensionCatalog::testing().get("dolos").unwrap().clone(); diff --git a/mcp-server/src/catalog/oci.rs b/mcp-server/src/catalog/oci.rs index 2314b93..484fa6d 100644 --- a/mcp-server/src/catalog/oci.rs +++ b/mcp-server/src/catalog/oci.rs @@ -1,6 +1,7 @@ use reqwest::header::ACCEPT; use serde::Deserialize; use sha2::{Digest, Sha256}; +use std::time::Duration; const CATALOG_LAYER_MEDIA_TYPE: &str = "application/vnd.supernode.extension-catalog.v1+json"; const JSON_MEDIA_TYPE: &str = "application/json"; @@ -16,7 +17,10 @@ pub(super) async fn fetch_catalog_json( max_bytes: usize, ) -> Result, OciCatalogError> { let reference = OciReference::parse(reference)?; - let client = reqwest::Client::new(); + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .build()?; let manifest = fetch_manifest(&client, &reference).await?; let descriptor = select_catalog_descriptor(&manifest) .ok_or_else(|| OciCatalogError::MissingCatalogLayer(reference.original.clone()))?; From 0fa5fd7685d9f82d2d97a194cd16502731e44ecd Mon Sep 17 00:00:00 2001 From: Felipe Gonzalez Date: Thu, 21 May 2026 16:12:03 -0300 Subject: [PATCH 4/4] Update control plane values --- extensions/control-plane/Chart.yaml | 2 +- extensions/control-plane/values.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/control-plane/Chart.yaml b/extensions/control-plane/Chart.yaml index 7fe4ff6..fc663a9 100644 --- a/extensions/control-plane/Chart.yaml +++ b/extensions/control-plane/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: control-plane description: Observability control-plane components derived from Terraform module. type: application -version: 0.2.1-rc4 +version: 0.2.1-rc5 appVersion: "0.2.1" dependencies: - name: vault diff --git a/extensions/control-plane/values.yaml b/extensions/control-plane/values.yaml index 2272d1d..ddc425d 100644 --- a/extensions/control-plane/values.yaml +++ b/extensions/control-plane/values.yaml @@ -175,7 +175,7 @@ supernodeMcp: bindAddr: 0.0.0.0:8443 extensionCatalog: source: oci - ociRef: oci://oci.supernode.store/extension-catalog:0.1.0 + ociRef: oci://oci.supernode.store/extension-catalog@sha256:623004147b13c18ffa6fdbb701da9001304518d8fd4a1988cc667f8ebd3e0419 maxBytes: 1048576 allowUntrusted: false sessionStore: