From 066ad6fa658ad18c6e67cd45dd5ebf182a4e76e9 Mon Sep 17 00:00:00 2001 From: fullzer4 Date: Mon, 15 Jun 2026 16:47:23 -0300 Subject: [PATCH 01/24] feat: add E2E infrastructure with OCI images, Helm chart, and test crate Add complete E2E testing infrastructure: - CRD gen binary for generating NiphasWorkload CRD YAML - Per-binary Nix packages with shared cargoArtifacts - OCI images via dockerTools (operator, eval, csi, runner, mock-eval) - Full Helm chart with RBAC, ConfigMap, CSI DaemonSet, and eval Service - E2E test crate with mock eval server and 5 platform tests - CI L3 job with kind cluster, helm install, and nextest --- .github/workflows/ci.yml | 72 +- Cargo.lock | 16 + Cargo.toml | 1 + charts/niphas/Chart.yaml | 14 + charts/niphas/crds/niphasworkloads.yaml | 2565 +++++++++++++++++ charts/niphas/templates/_helpers.tpl | 46 + charts/niphas/templates/configmap.yaml | 26 + charts/niphas/templates/csi/csidriver.yaml | 12 + charts/niphas/templates/csi/daemonset.yaml | 98 + charts/niphas/templates/eval/deployment.yaml | 54 + charts/niphas/templates/eval/service.yaml | 17 + charts/niphas/templates/namespace.yaml | 6 + .../templates/operator/clusterrole.yaml | 39 + .../operator/clusterrolebinding.yaml | 15 + .../niphas/templates/operator/deployment.yaml | 63 + charts/niphas/templates/operator/role.yaml | 12 + .../templates/operator/rolebinding.yaml | 16 + .../templates/operator/serviceaccount.yaml | 8 + charts/niphas/values.yaml | 74 + crates/niphas-core/Cargo.toml | 4 + crates/niphas-core/src/bin/crd_gen.rs | 6 + crates/niphas-e2e/Cargo.toml | 24 + crates/niphas-e2e/src/bin/mock_eval.rs | 48 + crates/niphas-e2e/src/lib.rs | 2 + crates/niphas-e2e/tests/e2e_platform.rs | 175 ++ hack/kind-config.yaml | 5 + nix/devShell.nix | 12 + nix/images.nix | 67 + nix/packages.nix | 12 + 29 files changed, 3507 insertions(+), 2 deletions(-) create mode 100644 charts/niphas/Chart.yaml create mode 100644 charts/niphas/crds/niphasworkloads.yaml create mode 100644 charts/niphas/templates/_helpers.tpl create mode 100644 charts/niphas/templates/configmap.yaml create mode 100644 charts/niphas/templates/csi/csidriver.yaml create mode 100644 charts/niphas/templates/csi/daemonset.yaml create mode 100644 charts/niphas/templates/eval/deployment.yaml create mode 100644 charts/niphas/templates/eval/service.yaml create mode 100644 charts/niphas/templates/namespace.yaml create mode 100644 charts/niphas/templates/operator/clusterrole.yaml create mode 100644 charts/niphas/templates/operator/clusterrolebinding.yaml create mode 100644 charts/niphas/templates/operator/deployment.yaml create mode 100644 charts/niphas/templates/operator/role.yaml create mode 100644 charts/niphas/templates/operator/rolebinding.yaml create mode 100644 charts/niphas/templates/operator/serviceaccount.yaml create mode 100644 charts/niphas/values.yaml create mode 100644 crates/niphas-core/src/bin/crd_gen.rs create mode 100644 crates/niphas-e2e/Cargo.toml create mode 100644 crates/niphas-e2e/src/bin/mock_eval.rs create mode 100644 crates/niphas-e2e/src/lib.rs create mode 100644 crates/niphas-e2e/tests/e2e_platform.rs create mode 100644 hack/kind-config.yaml create mode 100644 nix/images.nix diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6e2579..9a07daa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,12 +72,80 @@ jobs: name: "L3 · E2E Kind" needs: [eval-http, csi-grpc] runs-on: ubuntu-latest - if: false # placeholder — enable when container images exist steps: - uses: actions/checkout@v4 - uses: DeterminateSystems/nix-installer-action@main - uses: DeterminateSystems/magic-nix-cache-action@main + + # Build OCI images + - name: Build images + run: | + nix build .#image-niphas-operator -o result-operator + nix build .#image-niphas-eval -o result-eval + nix build .#image-niphas-csi -o result-csi + nix build .#image-niphas-runner -o result-runner + nix build .#image-niphas-mock-eval -o result-mock-eval + + # Create kind cluster - uses: helm/kind-action@v1 with: cluster_name: niphas-test - - run: nix develop -c cargo nextest run --workspace --test 'e2e_*' + config: hack/kind-config.yaml + + # Load images into kind + - name: Load images into kind + run: | + kind load image-archive result-operator --name niphas-test + kind load image-archive result-eval --name niphas-test + kind load image-archive result-csi --name niphas-test + kind load image-archive result-runner --name niphas-test + kind load image-archive result-mock-eval --name niphas-test + + # Label nodes for CSI + - name: Label nodes + run: kubectl label nodes --all niphas.io/store=true + + # Verify CRD freshness + - name: Verify CRD freshness + run: | + nix develop -c cargo run --bin niphas-crd-gen 2>/dev/null | grep -v '^niphas devShell ready$' | diff charts/niphas/crds/niphasworkloads.yaml - + + # Install Helm chart + - name: Install niphas + run: | + nix develop -c helm install niphas charts/niphas \ + --namespace niphas-system \ + --create-namespace \ + --set operator.image.pullPolicy=Never \ + --set operator.image.tag=dev \ + --set eval.image.pullPolicy=Never \ + --set eval.image.tag=dev \ + --set csi.image.pullPolicy=Never \ + --set csi.image.tag=dev \ + --set runner.image.tag=dev + + # Wait for rollout + - name: Wait for deployments + run: | + kubectl -n niphas-system rollout status deployment/niphas-operator --timeout=120s + kubectl -n niphas-system rollout status deployment/niphas-eval --timeout=120s + kubectl -n niphas-system rollout status daemonset/niphas-csi --timeout=120s + + # Create e2e namespace + - name: Create e2e namespace + run: kubectl create namespace niphas-e2e + + # Port-forward + - name: Port-forward services + run: | + kubectl -n niphas-system port-forward svc/niphas-eval 8443:8443 & + kubectl -n niphas-system port-forward deployment/niphas-operator 8080:8080 & + sleep 5 + + # Run e2e tests + - name: Run E2E tests + env: + OPERATOR_HEALTH_URL: http://localhost:8080/healthz + EVAL_HEALTH_URL: http://localhost:8443/healthz + E2E_NAMESPACE: niphas-e2e + run: nix develop -c cargo nextest run -p niphas-e2e --test '*' diff --git a/Cargo.lock b/Cargo.lock index 3f5c2bf..5280fc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1560,6 +1560,22 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "niphas-e2e" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "k8s-openapi", + "kube", + "niphas-core", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "niphas-eval" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 4eee40a..ab53383 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/niphas-operator", "crates/niphas-csi", "crates/niphas-mesh", + "crates/niphas-e2e", ] [workspace.package] diff --git a/charts/niphas/Chart.yaml b/charts/niphas/Chart.yaml new file mode 100644 index 0000000..c2bd957 --- /dev/null +++ b/charts/niphas/Chart.yaml @@ -0,0 +1,14 @@ +apiVersion: v2 +name: niphas +description: Nix-native platform for Kubernetes +version: 0.1.0 +appVersion: "0.1.0" +type: application +keywords: + - nix + - kubernetes + - operator + - csi +home: https://github.com/fullzer4/niphas +sources: + - https://github.com/fullzer4/niphas diff --git a/charts/niphas/crds/niphasworkloads.yaml b/charts/niphas/crds/niphasworkloads.yaml new file mode 100644 index 0000000..9521a7b --- /dev/null +++ b/charts/niphas/crds/niphasworkloads.yaml @@ -0,0 +1,2565 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: niphasworkloads.niphas.io +spec: + group: niphas.io + names: + categories: [] + kind: NiphasWorkload + plural: niphasworkloads + shortNames: + - nw + - niphas + singular: niphasworkload + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.flakeRef + name: Flake + type: string + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .status.readyReplicas + name: Ready + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for NiphasWorkloadSpec via `CustomResource` + properties: + spec: + properties: + affinity: + description: Pod affinity/anti-affinity rules. + nullable: true + properties: + nodeAffinity: + description: Describes node affinity scheduling rules for the pod. + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: The scheduler will prefer to schedule pods to nodes that satisfy the affinity expressions specified by this field, but it may choose a node that violates one or more of the expressions. The node that is most preferred is the one with the greatest sum of weights, i.e. for each node that meets all of the scheduling requirements (resource request, requiredDuringScheduling affinity expressions, etc.), compute a sum by iterating through the elements of this field and adding "weight" to the sum if the node matches the corresponding matchExpressions; the node(s) with the highest sum are the most preferred. + items: + description: An empty preferred scheduling term matches all objects with implicit weight 0 (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). + properties: + preference: + description: A node selector term, associated with the corresponding weight. + properties: + matchExpressions: + description: A list of node selector requirements by node's labels. + items: + description: A node selector requirement is a selector that contains values, a key, and an operator that relates the key and values. + properties: + key: + description: The label key that the selector applies to. + type: string + operator: + description: Represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: An array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. If the operator is Gt or Lt, the values array must have a single element, which will be interpreted as an integer. This array is replaced during a strategic merge patch. + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchFields: + description: A list of node selector requirements by node's fields. + items: + description: A node selector requirement is a selector that contains values, a key, and an operator that relates the key and values. + properties: + key: + description: The label key that the selector applies to. + type: string + operator: + description: Represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: An array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. If the operator is Gt or Lt, the values array must have a single element, which will be interpreted as an integer. This array is replaced during a strategic merge patch. + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + type: object + weight: + description: Weight associated with matching the corresponding nodeSelectorTerm, in the range 1-100. + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + requiredDuringSchedulingIgnoredDuringExecution: + description: If the affinity requirements specified by this field are not met at scheduling time, the pod will not be scheduled onto the node. If the affinity requirements specified by this field cease to be met at some point during pod execution (e.g. due to an update), the system may or may not try to eventually evict the pod from its node. + properties: + nodeSelectorTerms: + description: Required. A list of node selector terms. The terms are ORed. + items: + description: A null or empty node selector term matches no objects. The requirements of them are ANDed. The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. + properties: + matchExpressions: + description: A list of node selector requirements by node's labels. + items: + description: A node selector requirement is a selector that contains values, a key, and an operator that relates the key and values. + properties: + key: + description: The label key that the selector applies to. + type: string + operator: + description: Represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: An array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. If the operator is Gt or Lt, the values array must have a single element, which will be interpreted as an integer. This array is replaced during a strategic merge patch. + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchFields: + description: A list of node selector requirements by node's fields. + items: + description: A node selector requirement is a selector that contains values, a key, and an operator that relates the key and values. + properties: + key: + description: The label key that the selector applies to. + type: string + operator: + description: Represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: An array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. If the operator is Gt or Lt, the values array must have a single element, which will be interpreted as an integer. This array is replaced during a strategic merge patch. + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + type: object + type: array + required: + - nodeSelectorTerms + type: object + type: object + podAffinity: + description: Describes pod affinity scheduling rules (e.g. co-locate this pod in the same node, zone, etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: The scheduler will prefer to schedule pods to nodes that satisfy the affinity expressions specified by this field, but it may choose a node that violates one or more of the expressions. The node that is most preferred is the one with the greatest sum of weights, i.e. for each node that meets all of the scheduling requirements (resource request, requiredDuringScheduling affinity expressions, etc.), compute a sum by iterating through the elements of this field and adding "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, associated with the corresponding weight. + properties: + labelSelector: + description: A label query over a set of resources, in this case pods. If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. The requirements are ANDed. + items: + description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values. + properties: + key: + description: key is the label key that the selector applies to. + type: string + operator: + description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch. + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + matchLabelKeys: + description: MatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + mismatchLabelKeys: + description: MismatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + namespaceSelector: + description: A label query over the set of namespaces that the term applies to. The term is applied to the union of the namespaces selected by this field and the ones listed in the namespaces field. null selector and null or empty namespaces list means "this pod's namespace". An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. The requirements are ANDed. + items: + description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values. + properties: + key: + description: key is the label key that the selector applies to. + type: string + operator: + description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch. + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + namespaces: + description: namespaces specifies a static list of namespace names that the term applies to. The term is applied to the union of the namespaces listed in this field and the ones selected by namespaceSelector. null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + topologyKey: + description: This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching the labelSelector in the specified namespaces, where co-located is defined as running on a node whose value of the label with key topologyKey matches that of any node on which any of the selected pods is running. Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: weight associated with matching the corresponding podAffinityTerm, in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + requiredDuringSchedulingIgnoredDuringExecution: + description: If the affinity requirements specified by this field are not met at scheduling time, the pod will not be scheduled onto the node. If the affinity requirements specified by this field cease to be met at some point during pod execution (e.g. due to a pod label update), the system may or may not try to eventually evict the pod from its node. When there are multiple elements, the lists of nodes corresponding to each podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: Defines a set of pods (namely those matching the labelSelector relative to the given namespace(s)) that this pod should be co-located (affinity) or not co-located (anti-affinity) with, where co-located is defined as running on a node whose value of the label with key matches that of any node on which a pod of the set of pods is running + properties: + labelSelector: + description: A label query over a set of resources, in this case pods. If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. The requirements are ANDed. + items: + description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values. + properties: + key: + description: key is the label key that the selector applies to. + type: string + operator: + description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch. + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + matchLabelKeys: + description: MatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + mismatchLabelKeys: + description: MismatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + namespaceSelector: + description: A label query over the set of namespaces that the term applies to. The term is applied to the union of the namespaces selected by this field and the ones listed in the namespaces field. null selector and null or empty namespaces list means "this pod's namespace". An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. The requirements are ANDed. + items: + description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values. + properties: + key: + description: key is the label key that the selector applies to. + type: string + operator: + description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch. + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + namespaces: + description: namespaces specifies a static list of namespace names that the term applies to. The term is applied to the union of the namespaces listed in this field and the ones selected by namespaceSelector. null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + topologyKey: + description: This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching the labelSelector in the specified namespaces, where co-located is defined as running on a node whose value of the label with key topologyKey matches that of any node on which any of the selected pods is running. Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + type: object + podAntiAffinity: + description: Describes pod anti-affinity scheduling rules (e.g. avoid putting this pod in the same node, zone, etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: The scheduler will prefer to schedule pods to nodes that satisfy the anti-affinity expressions specified by this field, but it may choose a node that violates one or more of the expressions. The node that is most preferred is the one with the greatest sum of weights, i.e. for each node that meets all of the scheduling requirements (resource request, requiredDuringScheduling anti-affinity expressions, etc.), compute a sum by iterating through the elements of this field and subtracting "weight" from the sum if the node has pods which matches the corresponding podAffinityTerm; the node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, associated with the corresponding weight. + properties: + labelSelector: + description: A label query over a set of resources, in this case pods. If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. The requirements are ANDed. + items: + description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values. + properties: + key: + description: key is the label key that the selector applies to. + type: string + operator: + description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch. + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + matchLabelKeys: + description: MatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + mismatchLabelKeys: + description: MismatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + namespaceSelector: + description: A label query over the set of namespaces that the term applies to. The term is applied to the union of the namespaces selected by this field and the ones listed in the namespaces field. null selector and null or empty namespaces list means "this pod's namespace". An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. The requirements are ANDed. + items: + description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values. + properties: + key: + description: key is the label key that the selector applies to. + type: string + operator: + description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch. + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + namespaces: + description: namespaces specifies a static list of namespace names that the term applies to. The term is applied to the union of the namespaces listed in this field and the ones selected by namespaceSelector. null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + topologyKey: + description: This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching the labelSelector in the specified namespaces, where co-located is defined as running on a node whose value of the label with key topologyKey matches that of any node on which any of the selected pods is running. Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: weight associated with matching the corresponding podAffinityTerm, in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + requiredDuringSchedulingIgnoredDuringExecution: + description: If the anti-affinity requirements specified by this field are not met at scheduling time, the pod will not be scheduled onto the node. If the anti-affinity requirements specified by this field cease to be met at some point during pod execution (e.g. due to a pod label update), the system may or may not try to eventually evict the pod from its node. When there are multiple elements, the lists of nodes corresponding to each podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: Defines a set of pods (namely those matching the labelSelector relative to the given namespace(s)) that this pod should be co-located (affinity) or not co-located (anti-affinity) with, where co-located is defined as running on a node whose value of the label with key matches that of any node on which a pod of the set of pods is running + properties: + labelSelector: + description: A label query over a set of resources, in this case pods. If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. The requirements are ANDed. + items: + description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values. + properties: + key: + description: key is the label key that the selector applies to. + type: string + operator: + description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch. + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + matchLabelKeys: + description: MatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + mismatchLabelKeys: + description: MismatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + namespaceSelector: + description: A label query over the set of namespaces that the term applies to. The term is applied to the union of the namespaces selected by this field and the ones listed in the namespaces field. null selector and null or empty namespaces list means "this pod's namespace". An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. The requirements are ANDed. + items: + description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values. + properties: + key: + description: key is the label key that the selector applies to. + type: string + operator: + description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch. + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + namespaces: + description: namespaces specifies a static list of namespace names that the term applies to. The term is applied to the union of the namespaces listed in this field and the ones selected by namespaceSelector. null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + topologyKey: + description: This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching the labelSelector in the specified namespaces, where co-located is defined as running on a node whose value of the label with key topologyKey matches that of any node on which any of the selected pods is running. Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + type: object + type: object + architectures: + description: If set, evaluate per-architecture. Uses `{arch}` template in `attribute`. + items: + type: string + nullable: true + type: array + args: + description: Arguments passed to the command. + items: + type: string + nullable: true + type: array + attribute: + description: Flake output attribute path. E.g. `packages.x86_64-linux.default`. + type: string + binaryCache: + description: Override binary cache URL for this workload. + nullable: true + type: string + command: + description: Override entrypoint. If omitted, resolved from `meta.mainProgram`. + items: + type: string + nullable: true + type: array + env: + description: Environment variables (same schema as K8s container.env). + items: + description: EnvVar represents an environment variable present in a Container. + properties: + name: + description: Name of the environment variable. May consist of any printable ASCII characters except '='. + type: string + value: + description: 'Variable references $(VAR_NAME) are expanded using the previously defined environment variables in the container and any service environment variables. If a variable cannot be resolved, the reference in the input string will be unchanged. Double $$ are reduced to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". Escaped references will never be expanded, regardless of whether the variable exists or not. Defaults to "".' + type: string + valueFrom: + description: Source for the environment variable's value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + description: 'Name of the referent. This field is effectively required, but due to backwards compatibility is allowed to be empty. Instances of this type with an empty value here are almost certainly wrong. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + optional: + description: Specify whether the ConfigMap or its key must be defined + type: boolean + required: + - key + - name + type: object + fieldRef: + description: 'Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['''']`, `metadata.annotations['''']`, spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs.' + properties: + apiVersion: + description: Version of the schema the FieldPath is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in the specified API version. + type: string + required: + - fieldPath + type: object + fileKeyRef: + description: FileKeyRef selects a key of the env file. Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: The key within the env file. An invalid key will prevent the pod from starting. The keys defined within a source may consist of any printable ASCII characters except '='. During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + description: |- + Specify whether the file or its key must be defined. If the file or key does not exist, then the env var is not published. If optional is set to true and the specified key does not exist, the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, an error will be returned during Pod creation. + type: boolean + path: + description: The path within the volume from which to select the file. Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount containing the env file. + type: string + required: + - key + - path + - volumeName + type: object + resourceFieldRef: + description: 'Selects a resource of the container: only resources limits and requests (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported.' + properties: + containerName: + description: 'Container name: required for volumes, optional for env vars' + type: string + divisor: + description: Specifies the output format of the exposed resources, defaults to "1" + nullable: true + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + secretKeyRef: + description: Selects a key of a secret in the pod's namespace + properties: + key: + description: The key of the secret to select from. Must be a valid secret key. + type: string + name: + description: 'Name of the referent. This field is effectively required, but due to backwards compatibility is allowed to be empty. Instances of this type with an empty value here are almost certainly wrong. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + optional: + description: Specify whether the Secret or its key must be defined + type: boolean + required: + - key + - name + type: object + type: object + required: + - name + type: object + nullable: true + type: array + extraVolumeMounts: + description: Mount points for extra volumes. + items: + description: VolumeMount describes a mounting of a Volume within a container. + properties: + mountPath: + description: Path within the container at which the volume should be mounted. Must not contain ':'. + type: string + mountPropagation: + description: mountPropagation determines how mounts are propagated from the host to container and the other way around. When not set, MountPropagationNone is used. This field is beta in 1.10. When RecursiveReadOnly is set to IfPossible or to Enabled, MountPropagation must be None or unspecified (which defaults to None). + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: Mounted read-only if true, read-write otherwise (false or unspecified). Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled recursively. + + If ReadOnly is false, this field has no meaning and must be unspecified. + + If ReadOnly is true, and this field is set to Disabled, the mount is not made recursively read-only. If this field is set to IfPossible, the mount is made recursively read-only, if it is supported by the container runtime. If this field is set to Enabled, the mount is made recursively read-only if it is supported by the container runtime, otherwise the pod will not be started and an error will be generated to indicate the reason. + + If this field is set to IfPossible or Enabled, MountPropagation must be set to None (or be unspecified, which defaults to None). + + If this field is not specified, it is treated as an equivalent of Disabled. + type: string + subPath: + description: Path within the volume from which the container's volume should be mounted. Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume from which the container's volume should be mounted. Behaves similarly to SubPath but environment variable references $(VAR_NAME) are expanded using the container's environment. Defaults to "" (volume's root). SubPathExpr and SubPath are mutually exclusive. + type: string + required: + - mountPath + - name + type: object + nullable: true + type: array + extraVolumes: + description: Additional K8s volumes (ConfigMaps, Secrets, etc.). + items: + description: Volume represents a named volume in a pod that may be accessed by any container in the pod. + properties: + awsElasticBlockStore: + description: 'awsElasticBlockStore represents an AWS Disk resource that is attached to a kubelet''s host machine and then exposed to the pod. Deprecated: AWSElasticBlockStore is deprecated. All operations for the in-tree awsElasticBlockStore type are redirected to the ebs.csi.aws.com CSI driver. More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore' + properties: + fsType: + description: 'fsType is the filesystem type of the volume that you want to mount. Tip: Ensure that the filesystem type is supported by the host operating system. Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore' + type: string + partition: + description: 'partition is the partition in the volume that you want to mount. If omitted, the default is to mount by volume name. Examples: For volume /dev/sda1, you specify the partition as "1". Similarly, the volume partition for /dev/sda is "0" (or you can leave the property empty).' + format: int32 + type: integer + readOnly: + description: 'readOnly value true will force the readOnly setting in VolumeMounts. More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore' + type: boolean + volumeID: + description: 'volumeID is unique ID of the persistent disk resource in AWS (Amazon EBS volume). More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore' + type: string + required: + - volumeID + type: object + azureDisk: + description: 'azureDisk represents an Azure Data Disk mount on the host and bind mount to the pod. Deprecated: AzureDisk is deprecated. All operations for the in-tree azureDisk type are redirected to the disk.csi.azure.com CSI driver.' + properties: + cachingMode: + description: 'cachingMode is the Host Caching mode: None, Read Only, Read Write.' + type: string + diskName: + description: diskName is the Name of the data disk in the blob storage + type: string + diskURI: + description: diskURI is the URI of data disk in the blob storage + type: string + fsType: + description: fsType is Filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + type: string + kind: + description: 'kind expected values are Shared: multiple blob disks per storage account Dedicated: single blob disk per storage account Managed: azure managed data disk (only in managed availability set). defaults to shared' + type: string + readOnly: + description: readOnly Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. + type: boolean + required: + - diskName + - diskURI + type: object + azureFile: + description: 'azureFile represents an Azure File Service mount on the host and bind mount to the pod. Deprecated: AzureFile is deprecated. All operations for the in-tree azureFile type are redirected to the file.csi.azure.com CSI driver.' + properties: + readOnly: + description: readOnly defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. + type: boolean + secretName: + description: secretName is the name of secret that contains Azure Storage Account Name and Key + type: string + shareName: + description: shareName is the azure share Name + type: string + required: + - secretName + - shareName + type: object + cephfs: + description: 'cephFS represents a Ceph FS mount on the host that shares a pod''s lifetime. Deprecated: CephFS is deprecated and the in-tree cephfs type is no longer supported.' + properties: + monitors: + description: 'monitors is Required: Monitors is a collection of Ceph monitors More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it' + items: + type: string + type: array + path: + description: 'path is Optional: Used as the mounted root, rather than the full Ceph tree, default is /' + type: string + readOnly: + description: 'readOnly is Optional: Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it' + type: boolean + secretFile: + description: 'secretFile is Optional: SecretFile is the path to key ring for User, default is /etc/ceph/user.secret More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it' + type: string + secretRef: + description: 'secretRef is Optional: SecretRef is reference to the authentication secret for User, default is empty. More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it' + properties: + name: + description: 'Name of the referent. This field is effectively required, but due to backwards compatibility is allowed to be empty. Instances of this type with an empty value here are almost certainly wrong. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + required: + - name + type: object + user: + description: 'user is optional: User is the rados user name, default is admin More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it' + type: string + required: + - monitors + type: object + cinder: + description: 'cinder represents a cinder volume attached and mounted on kubelets host machine. Deprecated: Cinder is deprecated. All operations for the in-tree cinder type are redirected to the cinder.csi.openstack.org CSI driver. More info: https://examples.k8s.io/mysql-cinder-pd/README.md' + properties: + fsType: + description: 'fsType is the filesystem type to mount. Must be a filesystem type supported by the host operating system. Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. More info: https://examples.k8s.io/mysql-cinder-pd/README.md' + type: string + readOnly: + description: 'readOnly defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. More info: https://examples.k8s.io/mysql-cinder-pd/README.md' + type: boolean + secretRef: + description: 'secretRef is optional: points to a secret object containing parameters used to connect to OpenStack.' + properties: + name: + description: 'Name of the referent. This field is effectively required, but due to backwards compatibility is allowed to be empty. Instances of this type with an empty value here are almost certainly wrong. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + required: + - name + type: object + volumeID: + description: 'volumeID used to identify the volume in cinder. More info: https://examples.k8s.io/mysql-cinder-pd/README.md' + type: string + required: + - volumeID + type: object + configMap: + description: configMap represents a configMap that should populate this volume + properties: + defaultMode: + description: 'defaultMode is optional: mode bits used to set permissions on created files by default. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. Defaults to 0644. Directories within the path are not affected by this setting. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' + format: int32 + type: integer + items: + description: items if unspecified, each key-value pair in the Data field of the referenced ConfigMap will be projected into the volume as a file whose name is the key and content is the value. If specified, the listed keys will be projected into the specified paths, and unlisted keys will not be present. If a key is specified which is not present in the ConfigMap, the volume setup will error unless it is marked optional. Paths must be relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: mode bits used to set permissions on this file. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. If not specified, the volume defaultMode will be used. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' + format: int32 + type: integer + path: + description: path is the relative path of the file to map the key to. May not be an absolute path. May not contain the path element '..'. May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + name: + description: 'Name of the referent. This field is effectively required, but due to backwards compatibility is allowed to be empty. Instances of this type with an empty value here are almost certainly wrong. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + optional: + description: optional specify whether the ConfigMap or its keys must be defined + type: boolean + required: + - name + type: object + csi: + description: csi (Container Storage Interface) represents ephemeral storage that is handled by certain external CSI drivers. + properties: + driver: + description: driver is the name of the CSI driver that handles this volume. Consult with your admin for the correct name as registered in the cluster. + type: string + fsType: + description: fsType to mount. Ex. "ext4", "xfs", "ntfs". If not provided, the empty value is passed to the associated CSI driver which will determine the default filesystem to apply. + type: string + nodePublishSecretRef: + description: nodePublishSecretRef is a reference to the secret object containing sensitive information to pass to the CSI driver to complete the CSI NodePublishVolume and NodeUnpublishVolume calls. This field is optional, and may be empty if no secret is required. If the secret object contains more than one secret, all secret references are passed. + properties: + name: + description: 'Name of the referent. This field is effectively required, but due to backwards compatibility is allowed to be empty. Instances of this type with an empty value here are almost certainly wrong. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + required: + - name + type: object + readOnly: + description: readOnly specifies a read-only configuration for the volume. Defaults to false (read/write). + type: boolean + volumeAttributes: + additionalProperties: + type: string + description: volumeAttributes stores driver-specific properties that are passed to the CSI driver. Consult your driver's documentation for supported values. + type: object + required: + - driver + type: object + downwardAPI: + description: downwardAPI represents downward API about the pod that should populate this volume + properties: + defaultMode: + description: 'Optional: mode bits to use on created files by default. Must be a Optional: mode bits used to set permissions on created files by default. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. Defaults to 0644. Directories within the path are not affected by this setting. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' + format: int32 + type: integer + items: + description: Items is a list of downward API volume file + items: + description: DownwardAPIVolumeFile represents information to create the file containing the pod field + properties: + fieldRef: + description: 'Required: Selects a field of the pod: only annotations, labels, name, namespace and uid are supported.' + properties: + apiVersion: + description: Version of the schema the FieldPath is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in the specified API version. + type: string + required: + - fieldPath + type: object + mode: + description: 'Optional: mode bits used to set permissions on this file, must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. If not specified, the volume defaultMode will be used. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' + format: int32 + type: integer + path: + description: 'Required: Path is the relative path name of the file to be created. Must not be absolute or contain the ''..'' path. Must be utf-8 encoded. The first item of the relative path must not start with ''..''' + type: string + resourceFieldRef: + description: 'Selects a resource of the container: only resources limits and requests (limits.cpu, limits.memory, requests.cpu and requests.memory) are currently supported.' + properties: + containerName: + description: 'Container name: required for volumes, optional for env vars' + type: string + divisor: + description: Specifies the output format of the exposed resources, defaults to "1" + nullable: true + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + required: + - path + type: object + type: array + type: object + emptyDir: + description: 'emptyDir represents a temporary directory that shares a pod''s lifetime. More info: https://kubernetes.io/docs/concepts/storage/volumes#emptydir' + properties: + medium: + description: 'medium represents what type of storage medium should back this directory. The default is "" which means to use the node''s default medium. Must be an empty string (default) or Memory. More info: https://kubernetes.io/docs/concepts/storage/volumes#emptydir' + type: string + sizeLimit: + description: 'sizeLimit is the total amount of local storage required for this EmptyDir volume. The size limit is also applicable for memory medium. The maximum usage on memory medium EmptyDir would be the minimum value between the SizeLimit specified here and the sum of memory limits of all containers in a pod. The default is nil which means that the limit is undefined. More info: https://kubernetes.io/docs/concepts/storage/volumes#emptydir' + nullable: true + x-kubernetes-int-or-string: true + type: object + ephemeral: + description: |- + ephemeral represents a volume that is handled by a cluster storage driver. The volume's lifecycle is tied to the pod that defines it - it will be created before the pod starts, and deleted when the pod is removed. + + Use this if: a) the volume is only needed while the pod runs, b) features of normal volumes like restoring from snapshot or capacity + tracking are needed, + c) the storage driver is specified through a storage class, and d) the storage driver supports dynamic volume provisioning through + a PersistentVolumeClaim (see EphemeralVolumeSource for more + information on the connection between this volume type + and PersistentVolumeClaim). + + Use PersistentVolumeClaim or one of the vendor-specific APIs for volumes that persist for longer than the lifecycle of an individual pod. + + Use CSI for light-weight local ephemeral volumes if the CSI driver is meant to be used that way - see the documentation of the driver for more information. + + A pod can use both types of ephemeral volumes and persistent volumes at the same time. + properties: + volumeClaimTemplate: + description: |- + Will be used to create a stand-alone PVC to provision the volume. The pod in which this EphemeralVolumeSource is embedded will be the owner of the PVC, i.e. the PVC will be deleted together with the pod. The name of the PVC will be `-` where `` is the name from the `PodSpec.Volumes` array entry. Pod validation will reject the pod if the concatenated name is not valid for a PVC (for example, too long). + + An existing PVC with that name that is not owned by the pod will *not* be used for the pod to avoid using an unrelated volume by mistake. Starting the pod is then blocked until the unrelated PVC is removed. If such a pre-created PVC is meant to be used by the pod, the PVC has to updated with an owner reference to the pod once the pod exists. Normally this should not be necessary, but it may be useful when manually reconstructing a broken cluster. + + This field is read-only and no changes will be made by Kubernetes to the PVC after it has been created. + + Required, must not be nil. + properties: + metadata: + description: May contain labels and annotations that will be copied into the PVC when creating it. No other fields are allowed and will be rejected during validation. + properties: + annotations: + additionalProperties: + type: string + description: 'Annotations is an unstructured key value map stored with a resource that may be set by external tools to store and retrieve arbitrary metadata. They are not queryable and should be preserved when modifying objects. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations' + type: object + creationTimestamp: + description: |- + CreationTimestamp is a timestamp representing the server time when this object was created. It is not guaranteed to be set in happens-before order across separate operations. Clients may not set this value. It is represented in RFC3339 form and is in UTC. + + Populated by the system. Read-only. Null for lists. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + format: date-time + type: string + deletionGracePeriodSeconds: + description: Number of seconds allowed for this object to gracefully terminate before it will be removed from the system. Only set when deletionTimestamp is also set. May only be shortened. Read-only. + format: int64 + type: integer + deletionTimestamp: + description: |- + DeletionTimestamp is RFC 3339 date and time at which this resource will be deleted. This field is set by the server when a graceful deletion is requested by the user, and is not directly settable by a client. The resource is expected to be deleted (no longer visible from resource lists, and not reachable by name) after the time in this field, once the finalizers list is empty. As long as the finalizers list contains items, deletion is blocked. Once the deletionTimestamp is set, this value may not be unset or be set further into the future, although it may be shortened or the resource may be deleted prior to this time. For example, a user may request that a pod is deleted in 30 seconds. The Kubelet will react by sending a graceful termination signal to the containers in the pod. After that 30 seconds, the Kubelet will send a hard termination signal (SIGKILL) to the container and after cleanup, remove the pod from the API. In the presence of network partitions, this object may still exist after this timestamp, until an administrator or automated process can determine the resource is fully terminated. If not set, graceful deletion of the object has not been requested. + + Populated by the system when a graceful deletion is requested. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + format: date-time + type: string + finalizers: + description: Must be empty before the object is deleted from the registry. Each entry is an identifier for the responsible component that will remove the entry from the list. If the deletionTimestamp of the object is non-nil, entries in this list can only be removed. Finalizers may be processed and removed in any order. Order is NOT enforced because it introduces significant risk of stuck finalizers. finalizers is a shared field, any actor with permission can reorder it. If the finalizer list is processed in order, then this can lead to a situation in which the component responsible for the first finalizer in the list is waiting for a signal (field value, external system, or other) produced by a component responsible for a finalizer later in the list, resulting in a deadlock. Without enforced ordering finalizers are free to order amongst themselves and are not vulnerable to ordering changes in the list. + items: + type: string + type: array + generateName: + description: |- + GenerateName is an optional prefix, used by the server, to generate a unique name ONLY IF the Name field has not been provided. If this field is used, the name returned to the client will be different than the name passed. This value will also be combined with a unique suffix. The provided value has the same validation rules as the Name field, and may be truncated by the length of the suffix required to make the value unique on the server. + + If this field is specified and the generated name exists, the server will return a 409. + + Applied only if Name is not specified. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#idempotency + type: string + generation: + description: A sequence number representing a specific generation of the desired state. Populated by the system. Read-only. + format: int64 + type: integer + labels: + additionalProperties: + type: string + description: 'Map of string keys and values that can be used to organize and categorize (scope and select) objects. May match selectors of replication controllers and services. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels' + type: object + managedFields: + description: ManagedFields maps workflow-id and version to the set of fields that are managed by that workflow. This is mostly for internal housekeeping, and users typically shouldn't need to set or understand this field. A workflow can be the user's name, a controller's name, or the name of a specific apply path like "ci-cd". The set of fields is always in the version that the workflow used when modifying the object. + items: + description: ManagedFieldsEntry is a workflow-id, a FieldSet and the group version of the resource that the fieldset applies to. + properties: + apiVersion: + description: APIVersion defines the version of this resource that this field set applies to. The format is "group/version" just like the top-level APIVersion field. It is necessary to track the version of a field set because it cannot be automatically converted. + type: string + fieldsType: + description: 'FieldsType is the discriminator for the different fields format and version. There is currently only one possible value: "FieldsV1"' + type: string + fieldsV1: + description: FieldsV1 holds the first JSON version format as described in the "FieldsV1" type. + type: object + manager: + description: Manager is an identifier of the workflow managing these fields. + type: string + operation: + description: Operation is the type of operation which lead to this ManagedFieldsEntry being created. The only valid values for this field are 'Apply' and 'Update'. + type: string + subresource: + description: Subresource is the name of the subresource used to update that object, or empty string if the object was updated through the main resource. The value of this field is used to distinguish between managers, even if they share the same name. For example, a status update will be distinct from a regular update using the same manager name. Note that the APIVersion field is not related to the Subresource field and it always corresponds to the version of the main resource. + type: string + time: + description: Time is the timestamp of when the ManagedFields entry was added. The timestamp will also be updated if a field is added, the manager changes any of the owned fields value or removes a field. The timestamp does not update when a field is removed from the entry because another manager took it over. + format: date-time + type: string + type: object + type: array + name: + description: 'Name must be unique within a namespace. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names' + type: string + namespace: + description: |- + Namespace defines the space within which each name must be unique. An empty namespace is equivalent to the "default" namespace, but "default" is the canonical representation. Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty. + + Must be a DNS_LABEL. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces + type: string + ownerReferences: + description: List of objects depended by this object. If ALL objects in the list have been deleted, this object will be garbage collected. If this object is managed by a controller, then an entry in this list will point to this controller, with the controller field set to true. There cannot be more than one managing controller. + items: + description: OwnerReference contains enough information to let you identify an owning object. An owning object must be in the same namespace as the dependent, or be cluster-scoped, so there is no namespace field. + properties: + apiVersion: + description: API version of the referent. + type: string + blockOwnerDeletion: + description: If true, AND if the owner has the "foregroundDeletion" finalizer, then the owner cannot be deleted from the key-value store until this reference is removed. See https://kubernetes.io/docs/concepts/architecture/garbage-collection/#foreground-deletion for how the garbage collector interacts with this field and enforces the foreground deletion. Defaults to false. To set this field, a user needs "delete" permission of the owner, otherwise 422 (Unprocessable Entity) will be returned. + type: boolean + controller: + description: If true, this reference points to the managing controller. + type: boolean + kind: + description: 'Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names' + type: string + uid: + description: 'UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids' + type: string + required: + - apiVersion + - kind + - name + - uid + type: object + type: array + resourceVersion: + description: |- + An opaque value that represents the internal version of this object that can be used by clients to determine when objects have changed. May be used for optimistic concurrency, change detection, and the watch operation on a resource or set of resources. Clients must treat these values as opaque and passed unmodified back to the server. They may only be valid for a particular resource or set of resources. + + Populated by the system. Read-only. Value must be treated as opaque by clients and . More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency + type: string + selfLink: + description: 'Deprecated: selfLink is a legacy read-only field that is no longer populated by the system.' + type: string + uid: + description: |- + UID is the unique in time and space value for this object. It is typically generated by the server on successful creation of a resource and is not allowed to change on PUT operations. + + Populated by the system. Read-only. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids + type: string + type: object + spec: + description: The specification for the PersistentVolumeClaim. The entire content is copied unchanged into the PVC that gets created from this template. The same fields as in a PersistentVolumeClaim are also valid here. + properties: + accessModes: + description: 'accessModes contains the desired access modes the volume should have. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1' + items: + type: string + type: array + dataSource: + description: 'dataSource field can be used to specify either: * An existing VolumeSnapshot object (snapshot.storage.k8s.io/VolumeSnapshot) * An existing PVC (PersistentVolumeClaim) If the provisioner or an external controller can support the specified data source, it will create a new volume based on the contents of the specified data source. When the AnyVolumeDataSource feature gate is enabled, dataSource contents will be copied to dataSourceRef, and dataSourceRef contents will be copied to dataSource when dataSourceRef.namespace is not specified. If the namespace is specified, then dataSourceRef will not be copied to dataSource.' + properties: + apiGroup: + description: APIGroup is the group for the resource being referenced. If APIGroup is not specified, the specified Kind must be in the core API group. For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: Name is the name of resource being referenced + type: string + required: + - kind + - name + type: object + dataSourceRef: + description: |- + dataSourceRef specifies the object from which to populate the volume with data, if a non-empty volume is desired. This may be any object from a non-empty API group (non core object) or a PersistentVolumeClaim object. When this field is specified, volume binding will only succeed if the type of the specified object matches some installed volume populator or dynamic provisioner. This field will replace the functionality of the dataSource field and as such if both fields are non-empty, they must have the same value. For backwards compatibility, when namespace isn't specified in dataSourceRef, both fields (dataSource and dataSourceRef) will be set to the same value automatically if one of them is empty and the other is non-empty. When namespace is specified in dataSourceRef, dataSource isn't set to the same value and must be empty. There are three important differences between dataSource and dataSourceRef: * While dataSource only allows two specific types of objects, dataSourceRef + allows any non-core object, as well as PersistentVolumeClaim objects. + * While dataSource ignores disallowed values (dropping them), dataSourceRef + preserves all values, and generates an error if a disallowed value is + specified. + * While dataSource only allows local objects, dataSourceRef allows objects + in any namespaces. + (Beta) Using this field requires the AnyVolumeDataSource feature gate to be enabled. (Alpha) Using the namespace field of dataSourceRef requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + properties: + apiGroup: + description: APIGroup is the group for the resource being referenced. If APIGroup is not specified, the specified Kind must be in the core API group. For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: Name is the name of resource being referenced + type: string + namespace: + description: Namespace is the namespace of resource being referenced Note that when a namespace is specified, a gateway.networking.k8s.io/ReferenceGrant object is required in the referent namespace to allow that namespace's owner to accept the reference. See the ReferenceGrant documentation for details. (Alpha) This field requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + type: string + required: + - kind + - name + type: object + resources: + description: 'resources represents the minimum resources the volume should have. Users are allowed to specify resource requirements that are lower than previous value but must still be higher than capacity recorded in the status field of the claim. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources' + properties: + limits: + additionalProperties: + description: "Quantity is a fixed-point representation of a number. It provides convenient marshaling/unmarshaling in JSON and YAML, in addition to String() and AsInt64() accessors.\n\nThe serialization format is:\n\n``` ::= \n\n\t(Note that may be empty, from the \"\" case in .)\n\n ::= 0 | 1 | ... | 9 ::= | ::= | . | . | . ::= \"+\" | \"-\" ::= | ::= | | ::= Ki | Mi | Gi | Ti | Pi | Ei\n\n\t(International System of units; See: http://physics.nist.gov/cuu/Units/binary.html)\n\n ::= m | \"\" | k | M | G | T | P | E\n\n\t(Note that 1024 = 1Ki but 1000 = 1k; I didn't choose the capitalization.)\n\n ::= \"e\" | \"E\" ```\n\nNo matter which of the three exponent forms is used, no quantity may represent a number greater than 2^63-1 in magnitude, nor may it have more than 3 decimal places. Numbers larger or more precise will be capped or rounded up. (E.g.: 0.1m will rounded up to 1m.) This may be extended in the future if we require larger or smaller quantities.\n\nWhen a Quantity is parsed from a string, it will remember the type of suffix it had, and will use the same type again when it is serialized.\n\nBefore serializing, Quantity will be put in \"canonical form\". This means that Exponent/suffix will be adjusted up or down (with a corresponding increase or decrease in Mantissa) such that:\n\n- No precision is lost - No fractional digits will be emitted - The exponent (or suffix) is as large as possible.\n\nThe sign will be omitted unless the number is negative.\n\nExamples:\n\n- 1.5 will be serialized as \"1500m\" - 1.5Gi will be serialized as \"1536Mi\"\n\nNote that the quantity will NEVER be internally represented by a floating point number. That is the whole point of this exercise.\n\nNon-canonical values will still parse as long as they are well formed, but will be re-emitted in their canonical form. (So always use canonical form, or don't diff.)\n\nThis format is intended to make it difficult to use these numbers without writing some sort of special handling code in the hopes that that will cause implementors to also use a fixed point implementation." + x-kubernetes-int-or-string: true + description: 'Limits describes the maximum amount of compute resources allowed. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/' + type: object + requests: + additionalProperties: + description: "Quantity is a fixed-point representation of a number. It provides convenient marshaling/unmarshaling in JSON and YAML, in addition to String() and AsInt64() accessors.\n\nThe serialization format is:\n\n``` ::= \n\n\t(Note that may be empty, from the \"\" case in .)\n\n ::= 0 | 1 | ... | 9 ::= | ::= | . | . | . ::= \"+\" | \"-\" ::= | ::= | | ::= Ki | Mi | Gi | Ti | Pi | Ei\n\n\t(International System of units; See: http://physics.nist.gov/cuu/Units/binary.html)\n\n ::= m | \"\" | k | M | G | T | P | E\n\n\t(Note that 1024 = 1Ki but 1000 = 1k; I didn't choose the capitalization.)\n\n ::= \"e\" | \"E\" ```\n\nNo matter which of the three exponent forms is used, no quantity may represent a number greater than 2^63-1 in magnitude, nor may it have more than 3 decimal places. Numbers larger or more precise will be capped or rounded up. (E.g.: 0.1m will rounded up to 1m.) This may be extended in the future if we require larger or smaller quantities.\n\nWhen a Quantity is parsed from a string, it will remember the type of suffix it had, and will use the same type again when it is serialized.\n\nBefore serializing, Quantity will be put in \"canonical form\". This means that Exponent/suffix will be adjusted up or down (with a corresponding increase or decrease in Mantissa) such that:\n\n- No precision is lost - No fractional digits will be emitted - The exponent (or suffix) is as large as possible.\n\nThe sign will be omitted unless the number is negative.\n\nExamples:\n\n- 1.5 will be serialized as \"1500m\" - 1.5Gi will be serialized as \"1536Mi\"\n\nNote that the quantity will NEVER be internally represented by a floating point number. That is the whole point of this exercise.\n\nNon-canonical values will still parse as long as they are well formed, but will be re-emitted in their canonical form. (So always use canonical form, or don't diff.)\n\nThis format is intended to make it difficult to use these numbers without writing some sort of special handling code in the hopes that that will cause implementors to also use a fixed point implementation." + x-kubernetes-int-or-string: true + description: 'Requests describes the minimum amount of compute resources required. If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, otherwise to an implementation-defined value. Requests cannot exceed Limits. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/' + type: object + type: object + selector: + description: selector is a label query over volumes to consider for binding. + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. The requirements are ANDed. + items: + description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values. + properties: + key: + description: key is the label key that the selector applies to. + type: string + operator: + description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch. + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + storageClassName: + description: 'storageClassName is the name of the StorageClass required by the claim. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#class-1' + type: string + volumeAttributesClassName: + description: 'volumeAttributesClassName may be used to set the VolumeAttributesClass used by this claim. If specified, the CSI driver will create or update the volume with the attributes defined in the corresponding VolumeAttributesClass. This has a different purpose than storageClassName, it can be changed after the claim is created. An empty string or nil value indicates that no VolumeAttributesClass will be applied to the claim. If the claim enters an Infeasible error state, this field can be reset to its previous value (including nil) to cancel the modification. If the resource referred to by volumeAttributesClass does not exist, this PersistentVolumeClaim will be set to a Pending state, as reflected by the modifyVolumeStatus field, until such as a resource exists. More info: https://kubernetes.io/docs/concepts/storage/volume-attributes-classes/' + type: string + volumeMode: + description: volumeMode defines what type of volume is required by the claim. Value of Filesystem is implied when not included in claim spec. + type: string + volumeName: + description: volumeName is the binding reference to the PersistentVolume backing this claim. + type: string + type: object + required: + - spec + type: object + type: object + fc: + description: fc represents a Fibre Channel resource that is attached to a kubelet's host machine and then exposed to the pod. + properties: + fsType: + description: fsType is the filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + type: string + lun: + description: 'lun is Optional: FC target lun number' + format: int32 + type: integer + readOnly: + description: 'readOnly is Optional: Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts.' + type: boolean + targetWWNs: + description: 'targetWWNs is Optional: FC target worldwide names (WWNs)' + items: + type: string + type: array + wwids: + description: 'wwids Optional: FC volume world wide identifiers (wwids) Either wwids or combination of targetWWNs and lun must be set, but not both simultaneously.' + items: + type: string + type: array + type: object + flexVolume: + description: 'flexVolume represents a generic volume resource that is provisioned/attached using an exec based plugin. Deprecated: FlexVolume is deprecated. Consider using a CSIDriver instead.' + properties: + driver: + description: driver is the name of the driver to use for this volume. + type: string + fsType: + description: fsType is the filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". The default filesystem depends on FlexVolume script. + type: string + options: + additionalProperties: + type: string + description: 'options is Optional: this field holds extra command options if any.' + type: object + readOnly: + description: 'readOnly is Optional: defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts.' + type: boolean + secretRef: + description: 'secretRef is Optional: secretRef is reference to the secret object containing sensitive information to pass to the plugin scripts. This may be empty if no secret object is specified. If the secret object contains more than one secret, all secrets are passed to the plugin scripts.' + properties: + name: + description: 'Name of the referent. This field is effectively required, but due to backwards compatibility is allowed to be empty. Instances of this type with an empty value here are almost certainly wrong. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + required: + - name + type: object + required: + - driver + type: object + flocker: + description: 'flocker represents a Flocker volume attached to a kubelet''s host machine. This depends on the Flocker control service being running. Deprecated: Flocker is deprecated and the in-tree flocker type is no longer supported.' + properties: + datasetName: + description: datasetName is Name of the dataset stored as metadata -> name on the dataset for Flocker should be considered as deprecated + type: string + datasetUUID: + description: datasetUUID is the UUID of the dataset. This is unique identifier of a Flocker dataset + type: string + type: object + gcePersistentDisk: + description: 'gcePersistentDisk represents a GCE Disk resource that is attached to a kubelet''s host machine and then exposed to the pod. Deprecated: GCEPersistentDisk is deprecated. All operations for the in-tree gcePersistentDisk type are redirected to the pd.csi.storage.gke.io CSI driver. More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk' + properties: + fsType: + description: 'fsType is filesystem type of the volume that you want to mount. Tip: Ensure that the filesystem type is supported by the host operating system. Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk' + type: string + partition: + description: 'partition is the partition in the volume that you want to mount. If omitted, the default is to mount by volume name. Examples: For volume /dev/sda1, you specify the partition as "1". Similarly, the volume partition for /dev/sda is "0" (or you can leave the property empty). More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk' + format: int32 + type: integer + pdName: + description: 'pdName is unique name of the PD resource in GCE. Used to identify the disk in GCE. More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk' + type: string + readOnly: + description: 'readOnly here will force the ReadOnly setting in VolumeMounts. Defaults to false. More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk' + type: boolean + required: + - pdName + type: object + gitRepo: + description: 'gitRepo represents a git repository at a particular revision. Deprecated: GitRepo is deprecated. To provision a container with a git repo, mount an EmptyDir into an InitContainer that clones the repo using git, then mount the EmptyDir into the Pod''s container.' + properties: + directory: + description: directory is the target directory name. Must not contain or start with '..'. If '.' is supplied, the volume directory will be the git repository. Otherwise, if specified, the volume will contain the git repository in the subdirectory with the given name. + type: string + repository: + description: repository is the URL + type: string + revision: + description: revision is the commit hash for the specified revision. + type: string + required: + - repository + type: object + glusterfs: + description: 'glusterfs represents a Glusterfs mount on the host that shares a pod''s lifetime. Deprecated: Glusterfs is deprecated and the in-tree glusterfs type is no longer supported.' + properties: + endpoints: + description: endpoints is the endpoint name that details Glusterfs topology. + type: string + path: + description: 'path is the Glusterfs volume path. More info: https://examples.k8s.io/volumes/glusterfs/README.md#create-a-pod' + type: string + readOnly: + description: 'readOnly here will force the Glusterfs volume to be mounted with read-only permissions. Defaults to false. More info: https://examples.k8s.io/volumes/glusterfs/README.md#create-a-pod' + type: boolean + required: + - endpoints + - path + type: object + hostPath: + description: 'hostPath represents a pre-existing file or directory on the host machine that is directly exposed to the container. This is generally used for system agents or other privileged things that are allowed to see the host machine. Most containers will NOT need this. More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath' + properties: + path: + description: 'path of the directory on the host. If the path is a symlink, it will follow the link to the real path. More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath' + type: string + type: + description: 'type for HostPath Volume Defaults to "" More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath' + type: string + required: + - path + type: object + image: + description: |- + image represents an OCI object (a container image or artifact) pulled and mounted on the kubelet's host machine. The volume is resolved at pod startup depending on which PullPolicy value is provided: + + - Always: the kubelet always attempts to pull the reference. Container creation will fail If the pull fails. - Never: the kubelet never pulls the reference and only uses a local image or artifact. Container creation will fail if the reference isn't present. - IfNotPresent: the kubelet pulls if the reference isn't already present on disk. Container creation will fail if the reference isn't present and the pull fails. + + The volume gets re-resolved if the pod gets deleted and recreated, which means that new remote content will become available on pod recreation. A failure to resolve or pull the image during pod startup will block containers from starting and may add significant latency. Failures will be retried using normal volume backoff and will be reported on the pod reason and message. The types of objects that may be mounted by this volume are defined by the container runtime implementation on a host machine and at minimum must include all valid types supported by the container image field. The OCI object gets mounted in a single directory (spec.containers[*].volumeMounts.mountPath) by merging the manifest layers in the same way as for container images. The volume will be mounted read-only (ro) and non-executable files (noexec). Sub path mounts for containers are not supported (spec.containers[*].volumeMounts.subpath) before 1.33. The field spec.securityContext.fsGroupChangePolicy has no effect on this volume type. + properties: + pullPolicy: + description: 'Policy for pulling OCI objects. Possible values are: Always: the kubelet always attempts to pull the reference. Container creation will fail If the pull fails. Never: the kubelet never pulls the reference and only uses a local image or artifact. Container creation will fail if the reference isn''t present. IfNotPresent: the kubelet pulls if the reference isn''t already present on disk. Container creation will fail if the reference isn''t present and the pull fails. Defaults to Always if :latest tag is specified, or IfNotPresent otherwise.' + type: string + reference: + description: 'Required: Image or artifact reference to be used. Behaves in the same way as pod.spec.containers[*].image. Pull secrets will be assembled in the same way as for the container image by looking up node credentials, SA image pull secrets, and pod spec image pull secrets. More info: https://kubernetes.io/docs/concepts/containers/images This field is optional to allow higher level config management to default or override container images in workload controllers like Deployments and StatefulSets.' + type: string + type: object + iscsi: + description: 'iscsi represents an ISCSI Disk resource that is attached to a kubelet''s host machine and then exposed to the pod. More info: https://kubernetes.io/docs/concepts/storage/volumes/#iscsi' + properties: + chapAuthDiscovery: + description: chapAuthDiscovery defines whether support iSCSI Discovery CHAP authentication + type: boolean + chapAuthSession: + description: chapAuthSession defines whether support iSCSI Session CHAP authentication + type: boolean + fsType: + description: 'fsType is the filesystem type of the volume that you want to mount. Tip: Ensure that the filesystem type is supported by the host operating system. Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. More info: https://kubernetes.io/docs/concepts/storage/volumes#iscsi' + type: string + initiatorName: + description: initiatorName is the custom iSCSI Initiator Name. If initiatorName is specified with iscsiInterface simultaneously, new iSCSI interface : will be created for the connection. + type: string + iqn: + description: iqn is the target iSCSI Qualified Name. + type: string + iscsiInterface: + description: iscsiInterface is the interface Name that uses an iSCSI transport. Defaults to 'default' (tcp). + type: string + lun: + description: lun represents iSCSI Target Lun number. + format: int32 + type: integer + portals: + description: portals is the iSCSI Target Portal List. The portal is either an IP or ip_addr:port if the port is other than default (typically TCP ports 860 and 3260). + items: + type: string + type: array + readOnly: + description: readOnly here will force the ReadOnly setting in VolumeMounts. Defaults to false. + type: boolean + secretRef: + description: secretRef is the CHAP Secret for iSCSI target and initiator authentication + properties: + name: + description: 'Name of the referent. This field is effectively required, but due to backwards compatibility is allowed to be empty. Instances of this type with an empty value here are almost certainly wrong. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + required: + - name + type: object + targetPortal: + description: targetPortal is iSCSI Target Portal. The Portal is either an IP or ip_addr:port if the port is other than default (typically TCP ports 860 and 3260). + type: string + required: + - iqn + - lun + - targetPortal + type: object + name: + description: 'name of the volume. Must be a DNS_LABEL and unique within the pod. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + nfs: + description: 'nfs represents an NFS mount on the host that shares a pod''s lifetime More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs' + properties: + path: + description: 'path that is exported by the NFS server. More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs' + type: string + readOnly: + description: 'readOnly here will force the NFS export to be mounted with read-only permissions. Defaults to false. More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs' + type: boolean + server: + description: 'server is the hostname or IP address of the NFS server. More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs' + type: string + required: + - path + - server + type: object + persistentVolumeClaim: + description: 'persistentVolumeClaimVolumeSource represents a reference to a PersistentVolumeClaim in the same namespace. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#persistentvolumeclaims' + properties: + claimName: + description: 'claimName is the name of a PersistentVolumeClaim in the same namespace as the pod using this volume. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#persistentvolumeclaims' + type: string + readOnly: + description: readOnly Will force the ReadOnly setting in VolumeMounts. Default false. + type: boolean + required: + - claimName + type: object + photonPersistentDisk: + description: 'photonPersistentDisk represents a PhotonController persistent disk attached and mounted on kubelets host machine. Deprecated: PhotonPersistentDisk is deprecated and the in-tree photonPersistentDisk type is no longer supported.' + properties: + fsType: + description: fsType is the filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + type: string + pdID: + description: pdID is the ID that identifies Photon Controller persistent disk + type: string + required: + - pdID + type: object + portworxVolume: + description: 'portworxVolume represents a portworx volume attached and mounted on kubelets host machine. Deprecated: PortworxVolume is deprecated. All operations for the in-tree portworxVolume type are redirected to the pxd.portworx.com CSI driver when the CSIMigrationPortworx feature-gate is on.' + properties: + fsType: + description: fSType represents the filesystem type to mount Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs". Implicitly inferred to be "ext4" if unspecified. + type: string + readOnly: + description: readOnly defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. + type: boolean + volumeID: + description: volumeID uniquely identifies a Portworx volume + type: string + required: + - volumeID + type: object + projected: + description: projected items for all in one resources secrets, configmaps, and downward API + properties: + defaultMode: + description: defaultMode are the mode bits used to set permissions on created files by default. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. Directories within the path are not affected by this setting. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + sources: + description: sources is the list of volume projections. Each entry in this list handles one source. + items: + description: Projection that may be projected along with other supported volume types. Exactly one of these fields must be set. + properties: + clusterTrustBundle: + description: |- + ClusterTrustBundle allows a pod to access the `.spec.trustBundle` field of ClusterTrustBundle objects in an auto-updating file. + + Alpha, gated by the ClusterTrustBundleProjection feature gate. + + ClusterTrustBundle objects can either be selected by name, or by the combination of signer name and a label selector. + + Kubelet performs aggressive normalization of the PEM contents written into the pod filesystem. Esoteric PEM features such as inter-block comments and block headers are stripped. Certificates are deduplicated. The ordering of certificates within the file is arbitrary, and Kubelet may change the order over time. + properties: + labelSelector: + description: Select all ClusterTrustBundles that match this label selector. Only has effect if signerName is set. Mutually-exclusive with name. If unset, interpreted as "match nothing". If set but empty, interpreted as "match everything". + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. The requirements are ANDed. + items: + description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values. + properties: + key: + description: key is the label key that the selector applies to. + type: string + operator: + description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch. + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + name: + description: Select a single ClusterTrustBundle by object name. Mutually-exclusive with signerName and labelSelector. + type: string + optional: + description: If true, don't block pod startup if the referenced ClusterTrustBundle(s) aren't available. If using name, then the named ClusterTrustBundle is allowed not to exist. If using signerName, then the combination of signerName and labelSelector is allowed to match zero ClusterTrustBundles. + type: boolean + path: + description: Relative path from the volume root to write the bundle. + type: string + signerName: + description: Select all ClusterTrustBundles that match this signer name. Mutually-exclusive with name. The contents of all selected ClusterTrustBundles will be unified and deduplicated. + type: string + required: + - path + type: object + configMap: + description: configMap information about the configMap data to project + properties: + items: + description: items if unspecified, each key-value pair in the Data field of the referenced ConfigMap will be projected into the volume as a file whose name is the key and content is the value. If specified, the listed keys will be projected into the specified paths, and unlisted keys will not be present. If a key is specified which is not present in the ConfigMap, the volume setup will error unless it is marked optional. Paths must be relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: mode bits used to set permissions on this file. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. If not specified, the volume defaultMode will be used. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' + format: int32 + type: integer + path: + description: path is the relative path of the file to map the key to. May not be an absolute path. May not contain the path element '..'. May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + name: + description: 'Name of the referent. This field is effectively required, but due to backwards compatibility is allowed to be empty. Instances of this type with an empty value here are almost certainly wrong. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + optional: + description: optional specify whether the ConfigMap or its keys must be defined + type: boolean + required: + - name + type: object + downwardAPI: + description: downwardAPI information about the downwardAPI data to project + properties: + items: + description: Items is a list of DownwardAPIVolume file + items: + description: DownwardAPIVolumeFile represents information to create the file containing the pod field + properties: + fieldRef: + description: 'Required: Selects a field of the pod: only annotations, labels, name, namespace and uid are supported.' + properties: + apiVersion: + description: Version of the schema the FieldPath is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in the specified API version. + type: string + required: + - fieldPath + type: object + mode: + description: 'Optional: mode bits used to set permissions on this file, must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. If not specified, the volume defaultMode will be used. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' + format: int32 + type: integer + path: + description: 'Required: Path is the relative path name of the file to be created. Must not be absolute or contain the ''..'' path. Must be utf-8 encoded. The first item of the relative path must not start with ''..''' + type: string + resourceFieldRef: + description: 'Selects a resource of the container: only resources limits and requests (limits.cpu, limits.memory, requests.cpu and requests.memory) are currently supported.' + properties: + containerName: + description: 'Container name: required for volumes, optional for env vars' + type: string + divisor: + description: Specifies the output format of the exposed resources, defaults to "1" + nullable: true + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + required: + - path + type: object + type: array + type: object + podCertificate: + description: |- + Projects an auto-rotating credential bundle (private key and certificate chain) that the pod can use either as a TLS client or server. + + Kubelet generates a private key and uses it to send a PodCertificateRequest to the named signer. Once the signer approves the request and issues a certificate chain, Kubelet writes the key and certificate chain to the pod filesystem. The pod does not start until certificates have been issued for each podCertificate projected volume source in its spec. + + Kubelet will begin trying to rotate the certificate at the time indicated by the signer using the PodCertificateRequest.Status.BeginRefreshAt timestamp. + + Kubelet can write a single file, indicated by the credentialBundlePath field, or separate files, indicated by the keyPath and certificateChainPath fields. + + The credential bundle is a single file in PEM format. The first PEM entry is the private key (in PKCS#8 format), and the remaining PEM entries are the certificate chain issued by the signer (typically, signers will return their certificate chain in leaf-to-root order). + + Prefer using the credential bundle format, since your application code can read it atomically. If you use keyPath and certificateChainPath, your application must make two separate file reads. If these coincide with a certificate rotation, it is possible that the private key and leaf certificate you read may not correspond to each other. Your application will need to check for this condition, and re-read until they are consistent. + + The named signer controls chooses the format of the certificate it issues; consult the signer implementation's documentation to learn how to use the certificates it issues. + properties: + certificateChainPath: + description: |- + Write the certificate chain at this path in the projected volume. + + Most applications should use credentialBundlePath. When using keyPath and certificateChainPath, your application needs to check that the key and leaf certificate are consistent, because it is possible to read the files mid-rotation. + type: string + credentialBundlePath: + description: |- + Write the credential bundle at this path in the projected volume. + + The credential bundle is a single file that contains multiple PEM blocks. The first PEM block is a PRIVATE KEY block, containing a PKCS#8 private key. + + The remaining blocks are CERTIFICATE blocks, containing the issued certificate chain from the signer (leaf and any intermediates). + + Using credentialBundlePath lets your Pod's application code make a single atomic read that retrieves a consistent key and certificate chain. If you project them to separate files, your application code will need to additionally check that the leaf certificate was issued to the key. + type: string + keyPath: + description: |- + Write the key at this path in the projected volume. + + Most applications should use credentialBundlePath. When using keyPath and certificateChainPath, your application needs to check that the key and leaf certificate are consistent, because it is possible to read the files mid-rotation. + type: string + keyType: + description: |- + The type of keypair Kubelet will generate for the pod. + + Valid values are "RSA3072", "RSA4096", "ECDSAP256", "ECDSAP384", "ECDSAP521", and "ED25519". + type: string + maxExpirationSeconds: + description: |- + maxExpirationSeconds is the maximum lifetime permitted for the certificate. + + Kubelet copies this value verbatim into the PodCertificateRequests it generates for this projection. + + If omitted, kube-apiserver will set it to 86400(24 hours). kube-apiserver will reject values shorter than 3600 (1 hour). The maximum allowable value is 7862400 (91 days). + + The signer implementation is then free to issue a certificate with any lifetime *shorter* than MaxExpirationSeconds, but no shorter than 3600 seconds (1 hour). This constraint is enforced by kube-apiserver. `kubernetes.io` signers will never issue certificates with a lifetime longer than 24 hours. + format: int32 + type: integer + signerName: + description: Kubelet's generated CSRs will be addressed to this signer. + type: string + userAnnotations: + additionalProperties: + type: string + description: |- + userAnnotations allow pod authors to pass additional information to the signer implementation. Kubernetes does not restrict or validate this metadata in any way. + + These values are copied verbatim into the `spec.unverifiedUserAnnotations` field of the PodCertificateRequest objects that Kubelet creates. + + Entries are subject to the same validation as object metadata annotations, with the addition that all keys must be domain-prefixed. No restrictions are placed on values, except an overall size limitation on the entire field. + + Signers should document the keys and values they support. Signers should deny requests that contain keys they do not recognize. + type: object + required: + - keyType + - signerName + type: object + secret: + description: secret information about the secret data to project + properties: + items: + description: items if unspecified, each key-value pair in the Data field of the referenced Secret will be projected into the volume as a file whose name is the key and content is the value. If specified, the listed keys will be projected into the specified paths, and unlisted keys will not be present. If a key is specified which is not present in the Secret, the volume setup will error unless it is marked optional. Paths must be relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: mode bits used to set permissions on this file. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. If not specified, the volume defaultMode will be used. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' + format: int32 + type: integer + path: + description: path is the relative path of the file to map the key to. May not be an absolute path. May not contain the path element '..'. May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + name: + description: 'Name of the referent. This field is effectively required, but due to backwards compatibility is allowed to be empty. Instances of this type with an empty value here are almost certainly wrong. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + optional: + description: optional field specify whether the Secret or its key must be defined + type: boolean + required: + - name + type: object + serviceAccountToken: + description: serviceAccountToken is information about the serviceAccountToken data to project + properties: + audience: + description: audience is the intended audience of the token. A recipient of a token must identify itself with an identifier specified in the audience of the token, and otherwise should reject the token. The audience defaults to the identifier of the apiserver. + type: string + expirationSeconds: + description: expirationSeconds is the requested duration of validity of the service account token. As the token approaches expiration, the kubelet volume plugin will proactively rotate the service account token. The kubelet will start trying to rotate the token if the token is older than 80 percent of its time to live or if the token is older than 24 hours.Defaults to 1 hour and must be at least 10 minutes. + format: int64 + type: integer + path: + description: path is the path relative to the mount point of the file to project the token into. + type: string + required: + - path + type: object + type: object + type: array + type: object + quobyte: + description: 'quobyte represents a Quobyte mount on the host that shares a pod''s lifetime. Deprecated: Quobyte is deprecated and the in-tree quobyte type is no longer supported.' + properties: + group: + description: group to map volume access to Default is no group + type: string + readOnly: + description: readOnly here will force the Quobyte volume to be mounted with read-only permissions. Defaults to false. + type: boolean + registry: + description: registry represents a single or multiple Quobyte Registry services specified as a string as host:port pair (multiple entries are separated with commas) which acts as the central registry for volumes + type: string + tenant: + description: tenant owning the given Quobyte volume in the Backend Used with dynamically provisioned Quobyte volumes, value is set by the plugin + type: string + user: + description: user to map volume access to Defaults to serivceaccount user + type: string + volume: + description: volume is a string that references an already created Quobyte volume by name. + type: string + required: + - registry + - volume + type: object + rbd: + description: 'rbd represents a Rados Block Device mount on the host that shares a pod''s lifetime. Deprecated: RBD is deprecated and the in-tree rbd type is no longer supported.' + properties: + fsType: + description: 'fsType is the filesystem type of the volume that you want to mount. Tip: Ensure that the filesystem type is supported by the host operating system. Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. More info: https://kubernetes.io/docs/concepts/storage/volumes#rbd' + type: string + image: + description: 'image is the rados image name. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it' + type: string + keyring: + description: 'keyring is the path to key ring for RBDUser. Default is /etc/ceph/keyring. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it' + type: string + monitors: + description: 'monitors is a collection of Ceph monitors. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it' + items: + type: string + type: array + pool: + description: 'pool is the rados pool name. Default is rbd. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it' + type: string + readOnly: + description: 'readOnly here will force the ReadOnly setting in VolumeMounts. Defaults to false. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it' + type: boolean + secretRef: + description: 'secretRef is name of the authentication secret for RBDUser. If provided overrides keyring. Default is nil. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it' + properties: + name: + description: 'Name of the referent. This field is effectively required, but due to backwards compatibility is allowed to be empty. Instances of this type with an empty value here are almost certainly wrong. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + required: + - name + type: object + user: + description: 'user is the rados user name. Default is admin. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it' + type: string + required: + - image + - monitors + type: object + scaleIO: + description: 'scaleIO represents a ScaleIO persistent volume attached and mounted on Kubernetes nodes. Deprecated: ScaleIO is deprecated and the in-tree scaleIO type is no longer supported.' + properties: + fsType: + description: fsType is the filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". Default is "xfs". + type: string + gateway: + description: gateway is the host address of the ScaleIO API Gateway. + type: string + protectionDomain: + description: protectionDomain is the name of the ScaleIO Protection Domain for the configured storage. + type: string + readOnly: + description: readOnly Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: secretRef references to the secret for ScaleIO user and other sensitive information. If this is not provided, Login operation will fail. + properties: + name: + description: 'Name of the referent. This field is effectively required, but due to backwards compatibility is allowed to be empty. Instances of this type with an empty value here are almost certainly wrong. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + required: + - name + type: object + sslEnabled: + description: sslEnabled Flag enable/disable SSL communication with Gateway, default false + type: boolean + storageMode: + description: storageMode indicates whether the storage for a volume should be ThickProvisioned or ThinProvisioned. Default is ThinProvisioned. + type: string + storagePool: + description: storagePool is the ScaleIO Storage Pool associated with the protection domain. + type: string + system: + description: system is the name of the storage system as configured in ScaleIO. + type: string + volumeName: + description: volumeName is the name of a volume already created in the ScaleIO system that is associated with this volume source. + type: string + required: + - gateway + - secretRef + - system + type: object + secret: + description: 'secret represents a secret that should populate this volume. More info: https://kubernetes.io/docs/concepts/storage/volumes#secret' + properties: + defaultMode: + description: 'defaultMode is Optional: mode bits used to set permissions on created files by default. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. Defaults to 0644. Directories within the path are not affected by this setting. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' + format: int32 + type: integer + items: + description: items If unspecified, each key-value pair in the Data field of the referenced Secret will be projected into the volume as a file whose name is the key and content is the value. If specified, the listed keys will be projected into the specified paths, and unlisted keys will not be present. If a key is specified which is not present in the Secret, the volume setup will error unless it is marked optional. Paths must be relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: mode bits used to set permissions on this file. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. If not specified, the volume defaultMode will be used. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' + format: int32 + type: integer + path: + description: path is the relative path of the file to map the key to. May not be an absolute path. May not contain the path element '..'. May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + optional: + description: optional field specify whether the Secret or its keys must be defined + type: boolean + secretName: + description: 'secretName is the name of the secret in the pod''s namespace to use. More info: https://kubernetes.io/docs/concepts/storage/volumes#secret' + type: string + type: object + storageos: + description: 'storageOS represents a StorageOS volume attached and mounted on Kubernetes nodes. Deprecated: StorageOS is deprecated and the in-tree storageos type is no longer supported.' + properties: + fsType: + description: fsType is the filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + type: string + readOnly: + description: readOnly defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: secretRef specifies the secret to use for obtaining the StorageOS API credentials. If not specified, default values will be attempted. + properties: + name: + description: 'Name of the referent. This field is effectively required, but due to backwards compatibility is allowed to be empty. Instances of this type with an empty value here are almost certainly wrong. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + required: + - name + type: object + volumeName: + description: volumeName is the human-readable name of the StorageOS volume. Volume names are only unique within a namespace. + type: string + volumeNamespace: + description: volumeNamespace specifies the scope of the volume within StorageOS. If no namespace is specified then the Pod's namespace will be used. This allows the Kubernetes name scoping to be mirrored within StorageOS for tighter integration. Set VolumeName to any name to override the default behaviour. Set to "default" if you are not using namespaces within StorageOS. Namespaces that do not pre-exist within StorageOS will be created. + type: string + type: object + vsphereVolume: + description: 'vsphereVolume represents a vSphere volume attached and mounted on kubelets host machine. Deprecated: VsphereVolume is deprecated. All operations for the in-tree vsphereVolume type are redirected to the csi.vsphere.vmware.com CSI driver.' + properties: + fsType: + description: fsType is filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + type: string + storagePolicyID: + description: storagePolicyID is the storage Policy Based Management (SPBM) profile ID associated with the StoragePolicyName. + type: string + storagePolicyName: + description: storagePolicyName is the storage Policy Based Management (SPBM) profile name. + type: string + volumePath: + description: volumePath is the path that identifies vSphere volume vmdk + type: string + required: + - volumePath + type: object + required: + - name + type: object + nullable: true + type: array + flakeRef: + description: Flake reference. E.g. `github:myorg/myapp`. + type: string + ingress: + description: If set, the operator creates an Ingress with ownerReferences. + nullable: true + properties: + className: + description: Ingress class name. + nullable: true + type: string + enabled: + default: false + description: Whether to create the Ingress resource. + type: boolean + hosts: + description: Ingress host rules. + items: + properties: + host: + description: Hostname. + type: string + paths: + description: Paths for this host. + items: + properties: + path: + description: URL path. + type: string + pathType: + description: 'Path type: Prefix, Exact, ImplementationSpecific.' + nullable: true + type: string + port: + description: Backend port (name or number as string). + type: string + required: + - path + - port + type: object + type: array + required: + - host + - paths + type: object + nullable: true + type: array + tls: + description: TLS configuration. + items: + properties: + hosts: + description: Hostnames covered by this TLS config. + items: + type: string + type: array + secretName: + description: Secret name containing TLS cert. + type: string + required: + - hosts + - secretName + type: object + nullable: true + type: array + type: object + livenessProbe: + description: Probe describes a health check to be performed against a container to determine whether it is alive or ready to receive traffic. + nullable: true + properties: + exec: + description: Exec specifies a command to execute in the container. + properties: + command: + description: Command is the command line to execute inside the container, the working directory for the command is root ('/') in the container's filesystem. The command is simply exec'd, it is not run inside a shell, so traditional shell instructions ('|', etc) won't work. To use a shell, you need to explicitly call out to that shell. Exit status of 0 is treated as live/healthy and non-zero is unhealthy. + items: + type: string + type: array + type: object + failureThreshold: + description: Minimum consecutive failures for the probe to be considered failed after having succeeded. Defaults to 3. Minimum value is 1. + format: int32 + type: integer + grpc: + description: GRPC specifies a GRPC HealthCheckRequest. + properties: + port: + description: Port number of the gRPC service. Number must be in the range 1 to 65535. + format: int32 + type: integer + service: + description: |- + Service is the name of the service to place in the gRPC HealthCheckRequest (see https://github.com/grpc/grpc/blob/master/doc/health-checking.md). + + If this is not specified, the default behavior is defined by gRPC. + type: string + required: + - port + type: object + httpGet: + description: HTTPGet specifies an HTTP GET request to perform. + properties: + host: + description: Host name to connect to, defaults to the pod IP. You probably want to set "Host" in httpHeaders instead. + type: string + httpHeaders: + description: Custom headers to set in the request. HTTP allows repeated headers. + items: + description: HTTPHeader describes a custom header to be used in HTTP probes + properties: + name: + description: The header field name. This will be canonicalized upon output, so case-variant names will be understood as the same header. + type: string + value: + description: The header field value + type: string + required: + - name + - value + type: object + type: array + path: + description: Path to access on the HTTP server. + type: string + port: + description: Name or number of the port to access on the container. Number must be in the range 1 to 65535. Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + scheme: + description: Scheme to use for connecting to the host. Defaults to HTTP. + type: string + required: + - port + type: object + initialDelaySeconds: + description: 'Number of seconds after the container has started before liveness probes are initiated. More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes' + format: int32 + type: integer + periodSeconds: + description: How often (in seconds) to perform the probe. Default to 10 seconds. Minimum value is 1. + format: int32 + type: integer + successThreshold: + description: Minimum consecutive successes for the probe to be considered successful after having failed. Defaults to 1. Must be 1 for liveness and startup. Minimum value is 1. + format: int32 + type: integer + tcpSocket: + description: TCPSocket specifies a connection to a TCP port. + properties: + host: + description: 'Optional: Host name to connect to, defaults to the pod IP.' + type: string + port: + description: Number or name of the port to access on the container. Number must be in the range 1 to 65535. Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + description: Optional duration in seconds the pod needs to terminate gracefully upon probe failure. The grace period is the duration in seconds after the processes running in the pod are sent a termination signal and the time when the processes are forcibly halted with a kill signal. Set this value longer than the expected cleanup time for your process. If this value is nil, the pod's terminationGracePeriodSeconds will be used. Otherwise, this value overrides the value provided by the pod spec. Value must be non-negative integer. The value zero indicates stop immediately via the kill signal (no opportunity to shut down). This is a beta field and requires enabling ProbeTerminationGracePeriod feature gate. Minimum value is 1. spec.terminationGracePeriodSeconds is used if unset. + format: int64 + type: integer + timeoutSeconds: + description: 'Number of seconds after which the probe times out. Defaults to 1 second. Minimum value is 1. More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes' + format: int32 + type: integer + type: object + nodeSelector: + additionalProperties: + type: string + description: Additional node selector labels (merged with `niphas.io/store=true`). + nullable: true + type: object + ports: + description: Exposed ports (same schema as K8s container.ports). + items: + description: ContainerPort represents a network port in a single container. + properties: + containerPort: + description: Number of port to expose on the pod's IP address. This must be a valid port number, 0 < x < 65536. + format: int32 + type: integer + hostIP: + description: What host IP to bind the external port to. + type: string + hostPort: + description: Number of port to expose on the host. If specified, this must be a valid port number, 0 < x < 65536. If HostNetwork is specified, this must match ContainerPort. Most containers do not need this. + format: int32 + type: integer + name: + description: If specified, this must be an IANA_SVC_NAME and unique within the pod. Each named port in a pod must have a unique name. Name for the port that can be referred to by services. + type: string + protocol: + description: Protocol for port. Must be UDP, TCP, or SCTP. Defaults to "TCP". + type: string + required: + - containerPort + type: object + nullable: true + type: array + readinessProbe: + description: Probe describes a health check to be performed against a container to determine whether it is alive or ready to receive traffic. + nullable: true + properties: + exec: + description: Exec specifies a command to execute in the container. + properties: + command: + description: Command is the command line to execute inside the container, the working directory for the command is root ('/') in the container's filesystem. The command is simply exec'd, it is not run inside a shell, so traditional shell instructions ('|', etc) won't work. To use a shell, you need to explicitly call out to that shell. Exit status of 0 is treated as live/healthy and non-zero is unhealthy. + items: + type: string + type: array + type: object + failureThreshold: + description: Minimum consecutive failures for the probe to be considered failed after having succeeded. Defaults to 3. Minimum value is 1. + format: int32 + type: integer + grpc: + description: GRPC specifies a GRPC HealthCheckRequest. + properties: + port: + description: Port number of the gRPC service. Number must be in the range 1 to 65535. + format: int32 + type: integer + service: + description: |- + Service is the name of the service to place in the gRPC HealthCheckRequest (see https://github.com/grpc/grpc/blob/master/doc/health-checking.md). + + If this is not specified, the default behavior is defined by gRPC. + type: string + required: + - port + type: object + httpGet: + description: HTTPGet specifies an HTTP GET request to perform. + properties: + host: + description: Host name to connect to, defaults to the pod IP. You probably want to set "Host" in httpHeaders instead. + type: string + httpHeaders: + description: Custom headers to set in the request. HTTP allows repeated headers. + items: + description: HTTPHeader describes a custom header to be used in HTTP probes + properties: + name: + description: The header field name. This will be canonicalized upon output, so case-variant names will be understood as the same header. + type: string + value: + description: The header field value + type: string + required: + - name + - value + type: object + type: array + path: + description: Path to access on the HTTP server. + type: string + port: + description: Name or number of the port to access on the container. Number must be in the range 1 to 65535. Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + scheme: + description: Scheme to use for connecting to the host. Defaults to HTTP. + type: string + required: + - port + type: object + initialDelaySeconds: + description: 'Number of seconds after the container has started before liveness probes are initiated. More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes' + format: int32 + type: integer + periodSeconds: + description: How often (in seconds) to perform the probe. Default to 10 seconds. Minimum value is 1. + format: int32 + type: integer + successThreshold: + description: Minimum consecutive successes for the probe to be considered successful after having failed. Defaults to 1. Must be 1 for liveness and startup. Minimum value is 1. + format: int32 + type: integer + tcpSocket: + description: TCPSocket specifies a connection to a TCP port. + properties: + host: + description: 'Optional: Host name to connect to, defaults to the pod IP.' + type: string + port: + description: Number or name of the port to access on the container. Number must be in the range 1 to 65535. Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + description: Optional duration in seconds the pod needs to terminate gracefully upon probe failure. The grace period is the duration in seconds after the processes running in the pod are sent a termination signal and the time when the processes are forcibly halted with a kill signal. Set this value longer than the expected cleanup time for your process. If this value is nil, the pod's terminationGracePeriodSeconds will be used. Otherwise, this value overrides the value provided by the pod spec. Value must be non-negative integer. The value zero indicates stop immediately via the kill signal (no opportunity to shut down). This is a beta field and requires enabling ProbeTerminationGracePeriod feature gate. Minimum value is 1. spec.terminationGracePeriodSeconds is used if unset. + format: int64 + type: integer + timeoutSeconds: + description: 'Number of seconds after which the probe times out. Defaults to 1 second. Minimum value is 1. More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes' + format: int32 + type: integer + type: object + replicas: + description: Number of pod replicas. Defaults to 1. + format: int32 + nullable: true + type: integer + resources: + description: CPU/memory requests and limits. + nullable: true + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. + + This field depends on the DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: Name must match the name of one entry in pod.spec.resourceClaims of the Pod where this field is used. It makes that resource available inside a container. + type: string + request: + description: Request is the name chosen for a request in the referenced claim. If empty, everything from the claim is made available, otherwise only the result of this request. + type: string + required: + - name + type: object + type: array + limits: + additionalProperties: + description: "Quantity is a fixed-point representation of a number. It provides convenient marshaling/unmarshaling in JSON and YAML, in addition to String() and AsInt64() accessors.\n\nThe serialization format is:\n\n``` ::= \n\n\t(Note that may be empty, from the \"\" case in .)\n\n ::= 0 | 1 | ... | 9 ::= | ::= | . | . | . ::= \"+\" | \"-\" ::= | ::= | | ::= Ki | Mi | Gi | Ti | Pi | Ei\n\n\t(International System of units; See: http://physics.nist.gov/cuu/Units/binary.html)\n\n ::= m | \"\" | k | M | G | T | P | E\n\n\t(Note that 1024 = 1Ki but 1000 = 1k; I didn't choose the capitalization.)\n\n ::= \"e\" | \"E\" ```\n\nNo matter which of the three exponent forms is used, no quantity may represent a number greater than 2^63-1 in magnitude, nor may it have more than 3 decimal places. Numbers larger or more precise will be capped or rounded up. (E.g.: 0.1m will rounded up to 1m.) This may be extended in the future if we require larger or smaller quantities.\n\nWhen a Quantity is parsed from a string, it will remember the type of suffix it had, and will use the same type again when it is serialized.\n\nBefore serializing, Quantity will be put in \"canonical form\". This means that Exponent/suffix will be adjusted up or down (with a corresponding increase or decrease in Mantissa) such that:\n\n- No precision is lost - No fractional digits will be emitted - The exponent (or suffix) is as large as possible.\n\nThe sign will be omitted unless the number is negative.\n\nExamples:\n\n- 1.5 will be serialized as \"1500m\" - 1.5Gi will be serialized as \"1536Mi\"\n\nNote that the quantity will NEVER be internally represented by a floating point number. That is the whole point of this exercise.\n\nNon-canonical values will still parse as long as they are well formed, but will be re-emitted in their canonical form. (So always use canonical form, or don't diff.)\n\nThis format is intended to make it difficult to use these numbers without writing some sort of special handling code in the hopes that that will cause implementors to also use a fixed point implementation." + x-kubernetes-int-or-string: true + description: 'Limits describes the maximum amount of compute resources allowed. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/' + type: object + requests: + additionalProperties: + description: "Quantity is a fixed-point representation of a number. It provides convenient marshaling/unmarshaling in JSON and YAML, in addition to String() and AsInt64() accessors.\n\nThe serialization format is:\n\n``` ::= \n\n\t(Note that may be empty, from the \"\" case in .)\n\n ::= 0 | 1 | ... | 9 ::= | ::= | . | . | . ::= \"+\" | \"-\" ::= | ::= | | ::= Ki | Mi | Gi | Ti | Pi | Ei\n\n\t(International System of units; See: http://physics.nist.gov/cuu/Units/binary.html)\n\n ::= m | \"\" | k | M | G | T | P | E\n\n\t(Note that 1024 = 1Ki but 1000 = 1k; I didn't choose the capitalization.)\n\n ::= \"e\" | \"E\" ```\n\nNo matter which of the three exponent forms is used, no quantity may represent a number greater than 2^63-1 in magnitude, nor may it have more than 3 decimal places. Numbers larger or more precise will be capped or rounded up. (E.g.: 0.1m will rounded up to 1m.) This may be extended in the future if we require larger or smaller quantities.\n\nWhen a Quantity is parsed from a string, it will remember the type of suffix it had, and will use the same type again when it is serialized.\n\nBefore serializing, Quantity will be put in \"canonical form\". This means that Exponent/suffix will be adjusted up or down (with a corresponding increase or decrease in Mantissa) such that:\n\n- No precision is lost - No fractional digits will be emitted - The exponent (or suffix) is as large as possible.\n\nThe sign will be omitted unless the number is negative.\n\nExamples:\n\n- 1.5 will be serialized as \"1500m\" - 1.5Gi will be serialized as \"1536Mi\"\n\nNote that the quantity will NEVER be internally represented by a floating point number. That is the whole point of this exercise.\n\nNon-canonical values will still parse as long as they are well formed, but will be re-emitted in their canonical form. (So always use canonical form, or don't diff.)\n\nThis format is intended to make it difficult to use these numbers without writing some sort of special handling code in the hopes that that will cause implementors to also use a fixed point implementation." + x-kubernetes-int-or-string: true + description: 'Requests describes the minimum amount of compute resources required. If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, otherwise to an implementation-defined value. Requests cannot exceed Limits. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/' + type: object + type: object + revision: + description: Git revision to pin. Must be a 6-40 char hex string. + nullable: true + type: string + service: + description: If set, the operator creates a Service with ownerReferences. + nullable: true + properties: + ports: + description: Service ports. + items: + properties: + name: + description: Port name. + nullable: true + type: string + port: + description: Service port number. + format: int32 + type: integer + protocol: + description: Protocol (TCP, UDP). Defaults to TCP. + nullable: true + type: string + targetPort: + description: Target port (name or number as string). + nullable: true + type: string + required: + - port + type: object + type: array + type: + description: 'Service type: ClusterIP, NodePort, LoadBalancer. Defaults to ClusterIP.' + nullable: true + type: string + required: + - ports + type: object + startupProbe: + description: Probe describes a health check to be performed against a container to determine whether it is alive or ready to receive traffic. + nullable: true + properties: + exec: + description: Exec specifies a command to execute in the container. + properties: + command: + description: Command is the command line to execute inside the container, the working directory for the command is root ('/') in the container's filesystem. The command is simply exec'd, it is not run inside a shell, so traditional shell instructions ('|', etc) won't work. To use a shell, you need to explicitly call out to that shell. Exit status of 0 is treated as live/healthy and non-zero is unhealthy. + items: + type: string + type: array + type: object + failureThreshold: + description: Minimum consecutive failures for the probe to be considered failed after having succeeded. Defaults to 3. Minimum value is 1. + format: int32 + type: integer + grpc: + description: GRPC specifies a GRPC HealthCheckRequest. + properties: + port: + description: Port number of the gRPC service. Number must be in the range 1 to 65535. + format: int32 + type: integer + service: + description: |- + Service is the name of the service to place in the gRPC HealthCheckRequest (see https://github.com/grpc/grpc/blob/master/doc/health-checking.md). + + If this is not specified, the default behavior is defined by gRPC. + type: string + required: + - port + type: object + httpGet: + description: HTTPGet specifies an HTTP GET request to perform. + properties: + host: + description: Host name to connect to, defaults to the pod IP. You probably want to set "Host" in httpHeaders instead. + type: string + httpHeaders: + description: Custom headers to set in the request. HTTP allows repeated headers. + items: + description: HTTPHeader describes a custom header to be used in HTTP probes + properties: + name: + description: The header field name. This will be canonicalized upon output, so case-variant names will be understood as the same header. + type: string + value: + description: The header field value + type: string + required: + - name + - value + type: object + type: array + path: + description: Path to access on the HTTP server. + type: string + port: + description: Name or number of the port to access on the container. Number must be in the range 1 to 65535. Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + scheme: + description: Scheme to use for connecting to the host. Defaults to HTTP. + type: string + required: + - port + type: object + initialDelaySeconds: + description: 'Number of seconds after the container has started before liveness probes are initiated. More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes' + format: int32 + type: integer + periodSeconds: + description: How often (in seconds) to perform the probe. Default to 10 seconds. Minimum value is 1. + format: int32 + type: integer + successThreshold: + description: Minimum consecutive successes for the probe to be considered successful after having failed. Defaults to 1. Must be 1 for liveness and startup. Minimum value is 1. + format: int32 + type: integer + tcpSocket: + description: TCPSocket specifies a connection to a TCP port. + properties: + host: + description: 'Optional: Host name to connect to, defaults to the pod IP.' + type: string + port: + description: Number or name of the port to access on the container. Number must be in the range 1 to 65535. Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + description: Optional duration in seconds the pod needs to terminate gracefully upon probe failure. The grace period is the duration in seconds after the processes running in the pod are sent a termination signal and the time when the processes are forcibly halted with a kill signal. Set this value longer than the expected cleanup time for your process. If this value is nil, the pod's terminationGracePeriodSeconds will be used. Otherwise, this value overrides the value provided by the pod spec. Value must be non-negative integer. The value zero indicates stop immediately via the kill signal (no opportunity to shut down). This is a beta field and requires enabling ProbeTerminationGracePeriod feature gate. Minimum value is 1. spec.terminationGracePeriodSeconds is used if unset. + format: int64 + type: integer + timeoutSeconds: + description: 'Number of seconds after which the probe times out. Defaults to 1 second. Minimum value is 1. More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes' + format: int32 + type: integer + type: object + tolerations: + description: Extra tolerations. Operator always injects not-ready/unreachable (30s). + items: + description: The pod this Toleration is attached to tolerates any taint that matches the triple using the matching operator . + properties: + effect: + description: Effect indicates the taint effect to match. Empty means match all taint effects. When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. + type: string + key: + description: Key is the taint key that the toleration applies to. Empty means match all taint keys. If the key is empty, operator must be Exists; this combination means to match all values and all keys. + type: string + operator: + description: Operator represents a key's relationship to the value. Valid operators are Exists, Equal, Lt, and Gt. Defaults to Equal. Exists is equivalent to wildcard for value, so that a pod can tolerate all taints of a particular category. Lt and Gt perform numeric comparisons (requires feature gate TaintTolerationComparisonOperators). + type: string + tolerationSeconds: + description: TolerationSeconds represents the period of time the toleration (which must be of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, it is not set, which means tolerate the taint forever (do not evict). Zero and negative values will be treated as 0 (evict immediately) by the system. + format: int64 + type: integer + value: + description: Value is the taint value the toleration matches to. If the operator is Exists, the value should be empty, otherwise just a regular string. + type: string + type: object + nullable: true + type: array + required: + - attribute + - flakeRef + type: object + status: + nullable: true + properties: + architectures: + description: Per-architecture status (only when `spec.architectures` is set). + items: + description: Per-architecture status for multi-arch workloads. + properties: + arch: + description: Architecture name (e.g. `x86_64`, `aarch64`). + type: string + closurePaths: + description: Closure paths for this architecture. + items: + type: string + nullable: true + type: array + readyReplicas: + description: Ready replicas for this architecture. + format: int32 + nullable: true + type: integer + resolvedCommand: + description: Resolved entrypoint for this architecture. + nullable: true + type: string + storePath: + description: Resolved store path for this architecture. + type: string + required: + - arch + - storePath + type: object + nullable: true + type: array + closurePaths: + description: |- + Full transitive closure (root + all references). + The CSI driver uses this to fetch NARs without depending on binary cache .narinfo. + items: + type: string + nullable: true + type: array + conditions: + description: Standard K8s-style conditions. + items: + properties: + lastTransitionTime: + description: When this condition last transitioned (ISO 8601). + type: string + message: + description: Human-readable message. + type: string + observedGeneration: + description: The generation this condition was set at. + format: int64 + nullable: true + type: integer + reason: + description: 'Machine-readable reason: EvalSucceeded, NarFetchFailed, ReplicasReady, etc.' + type: string + status: + description: Condition status. + enum: + - 'True' + - 'False' + - Unknown + type: string + type: + description: 'Condition type: Evaluated, ClosureCached, Available, Progressing, Degraded.' + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + nullable: true + type: array + lastEval: + description: ISO 8601 timestamp of last successful evaluation. + nullable: true + type: string + observedGeneration: + description: The `metadata.generation` most recently processed by the controller. + format: int64 + nullable: true + type: integer + phase: + default: Pending + description: 'High-level phase: Pending, Evaluating, Provisioning, Running, Failed.' + enum: + - Pending + - Evaluating + - Provisioning + - Running + - Failed + type: string + readyReplicas: + description: Number of pods in Ready state. + format: int32 + nullable: true + type: integer + resolvedCommand: + description: The actual entrypoint command. E.g. `/nix/store/abc123-myapp-1.0.0/bin/myapp`. + nullable: true + type: string + storePath: + description: Resolved Nix store path. E.g. `/nix/store/abc123-myapp-1.0.0`. + nullable: true + type: string + type: object + required: + - spec + title: NiphasWorkload + type: object + served: true + storage: true + subresources: + status: {} diff --git a/charts/niphas/templates/_helpers.tpl b/charts/niphas/templates/_helpers.tpl new file mode 100644 index 0000000..7b1f290 --- /dev/null +++ b/charts/niphas/templates/_helpers.tpl @@ -0,0 +1,46 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "niphas.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "niphas.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Common labels. +*/}} +{{- define "niphas.labels" -}} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/part-of: niphas +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +{{- end }} + +{{/* +Selector labels for a component. +*/}} +{{- define "niphas.selectorLabels" -}} +app.kubernetes.io/name: {{ .name }} +app.kubernetes.io/instance: {{ .root.Release.Name }} +{{- end }} + +{{/* +Namespace helper. +*/}} +{{- define "niphas.namespace" -}} +{{ .Values.namespace | default "niphas-system" }} +{{- end }} diff --git a/charts/niphas/templates/configmap.yaml b/charts/niphas/templates/configmap.yaml new file mode 100644 index 0000000..160aef6 --- /dev/null +++ b/charts/niphas/templates/configmap.yaml @@ -0,0 +1,26 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: niphas-config + namespace: {{ include "niphas.namespace" . }} + labels: + {{- include "niphas.labels" . | nindent 4 }} +data: + config.yaml: | + log_level: {{ .Values.config.logLevel | quote }} + binary_caches: + {{- range .Values.config.binaryCaches }} + - {{ . | quote }} + {{- end }} + allowed_flake_origins: + {{- range .Values.config.allowedFlakeOrigins }} + - {{ . | quote }} + {{- end }} + eval_webhook_url: {{ .Values.config.evalWebhookUrl | quote }} + health_port: {{ .Values.config.healthPort }} + eval_timeout: {{ .Values.config.evalTimeout | quote }} + cache: + path: {{ .Values.config.cache.path | quote }} + high_watermark: {{ .Values.config.cache.highWatermark }} + low_watermark: {{ .Values.config.cache.lowWatermark }} + gc_interval: {{ .Values.config.cache.gcInterval | quote }} diff --git a/charts/niphas/templates/csi/csidriver.yaml b/charts/niphas/templates/csi/csidriver.yaml new file mode 100644 index 0000000..9de99a8 --- /dev/null +++ b/charts/niphas/templates/csi/csidriver.yaml @@ -0,0 +1,12 @@ +apiVersion: storage.k8s.io/v1 +kind: CSIDriver +metadata: + name: niphas.io.csi + labels: + {{- include "niphas.labels" . | nindent 4 }} + app.kubernetes.io/component: csi +spec: + attachRequired: false + podInfoOnMount: true + volumeLifecycleModes: + - Ephemeral diff --git a/charts/niphas/templates/csi/daemonset.yaml b/charts/niphas/templates/csi/daemonset.yaml new file mode 100644 index 0000000..dd96a99 --- /dev/null +++ b/charts/niphas/templates/csi/daemonset.yaml @@ -0,0 +1,98 @@ +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: niphas-csi + namespace: {{ include "niphas.namespace" . }} + labels: + {{- include "niphas.labels" . | nindent 4 }} + app.kubernetes.io/component: csi + {{- include "niphas.selectorLabels" (dict "name" "niphas-csi" "root" .) | nindent 4 }} +spec: + selector: + matchLabels: + {{- include "niphas.selectorLabels" (dict "name" "niphas-csi" "root" .) | nindent 6 }} + template: + metadata: + labels: + {{- include "niphas.labels" . | nindent 8 }} + app.kubernetes.io/component: csi + {{- include "niphas.selectorLabels" (dict "name" "niphas-csi" "root" .) | nindent 8 }} + spec: + nodeSelector: + {{- toYaml .Values.csi.nodeSelector | nindent 8 }} + containers: + - name: csi-driver + image: "{{ .Values.csi.image.repository }}:{{ .Values.csi.image.tag }}" + imagePullPolicy: {{ .Values.csi.image.pullPolicy }} + args: + - "--endpoint=unix:///csi/csi.sock" + env: + - name: NIPHAS_CONFIG + value: /etc/niphas/config.yaml + - name: NODE_NAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + securityContext: + privileged: true + ports: + - name: healthz + containerPort: 9808 + protocol: TCP + livenessProbe: + httpGet: + path: /healthz + port: healthz + initialDelaySeconds: 10 + periodSeconds: 10 + resources: + {{- toYaml .Values.csi.resources | nindent 12 }} + volumeMounts: + - name: socket-dir + mountPath: /csi + - name: pods-mount-dir + mountPath: /var/lib/kubelet/pods + mountPropagation: Bidirectional + - name: cache + mountPath: /var/lib/niphas/cache + - name: config + mountPath: /etc/niphas + readOnly: true + - name: node-driver-registrar + image: "{{ .Values.csi.registrar.image.repository }}:{{ .Values.csi.registrar.image.tag }}" + args: + - "--csi-address=/csi/csi.sock" + - "--kubelet-registration-path=/var/lib/kubelet/plugins/niphas.io.csi/csi.sock" + volumeMounts: + - name: socket-dir + mountPath: /csi + - name: registration-dir + mountPath: /registration + - name: liveness-probe + image: "{{ .Values.csi.liveness.image.repository }}:{{ .Values.csi.liveness.image.tag }}" + args: + - "--csi-address=/csi/csi.sock" + - "--health-port=9808" + volumeMounts: + - name: socket-dir + mountPath: /csi + volumes: + - name: socket-dir + hostPath: + path: /var/lib/kubelet/plugins/niphas.io.csi + type: DirectoryOrCreate + - name: registration-dir + hostPath: + path: /var/lib/kubelet/plugins_registry + type: Directory + - name: pods-mount-dir + hostPath: + path: /var/lib/kubelet/pods + type: Directory + - name: cache + hostPath: + path: /var/lib/niphas/cache + type: DirectoryOrCreate + - name: config + configMap: + name: niphas-config diff --git a/charts/niphas/templates/eval/deployment.yaml b/charts/niphas/templates/eval/deployment.yaml new file mode 100644 index 0000000..bed1e7d --- /dev/null +++ b/charts/niphas/templates/eval/deployment.yaml @@ -0,0 +1,54 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: niphas-eval + namespace: {{ include "niphas.namespace" . }} + labels: + {{- include "niphas.labels" . | nindent 4 }} + app.kubernetes.io/component: eval + {{- include "niphas.selectorLabels" (dict "name" "niphas-eval" "root" .) | nindent 4 }} +spec: + replicas: {{ .Values.eval.replicas }} + selector: + matchLabels: + {{- include "niphas.selectorLabels" (dict "name" "niphas-eval" "root" .) | nindent 6 }} + template: + metadata: + labels: + {{- include "niphas.labels" . | nindent 8 }} + app.kubernetes.io/component: eval + {{- include "niphas.selectorLabels" (dict "name" "niphas-eval" "root" .) | nindent 8 }} + spec: + containers: + - name: eval + image: "{{ .Values.eval.image.repository }}:{{ .Values.eval.image.tag }}" + imagePullPolicy: {{ .Values.eval.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.eval.targetPort }} + protocol: TCP + env: + - name: NIPHAS_CONFIG + value: /etc/niphas/config.yaml + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /readyz + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + resources: + {{- toYaml .Values.eval.resources | nindent 12 }} + volumeMounts: + - name: config + mountPath: /etc/niphas + readOnly: true + volumes: + - name: config + configMap: + name: niphas-config diff --git a/charts/niphas/templates/eval/service.yaml b/charts/niphas/templates/eval/service.yaml new file mode 100644 index 0000000..e6024a6 --- /dev/null +++ b/charts/niphas/templates/eval/service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: niphas-eval + namespace: {{ include "niphas.namespace" . }} + labels: + {{- include "niphas.labels" . | nindent 4 }} + app.kubernetes.io/component: eval +spec: + type: ClusterIP + ports: + - name: http + port: {{ .Values.eval.servicePort }} + targetPort: {{ .Values.eval.targetPort }} + protocol: TCP + selector: + {{- include "niphas.selectorLabels" (dict "name" "niphas-eval" "root" .) | nindent 4 }} diff --git a/charts/niphas/templates/namespace.yaml b/charts/niphas/templates/namespace.yaml new file mode 100644 index 0000000..56c498d --- /dev/null +++ b/charts/niphas/templates/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: {{ include "niphas.namespace" . }} + labels: + {{- include "niphas.labels" . | nindent 4 }} diff --git a/charts/niphas/templates/operator/clusterrole.yaml b/charts/niphas/templates/operator/clusterrole.yaml new file mode 100644 index 0000000..b0103ad --- /dev/null +++ b/charts/niphas/templates/operator/clusterrole.yaml @@ -0,0 +1,39 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: niphas-operator + labels: + {{- include "niphas.labels" . | nindent 4 }} + app.kubernetes.io/component: operator +rules: + # NiphasWorkload CRD + - apiGroups: ["niphas.io"] + resources: ["niphasworkloads"] + verbs: ["get", "list", "watch", "update", "patch"] + - apiGroups: ["niphas.io"] + resources: ["niphasworkloads/status"] + verbs: ["get", "update", "patch"] + - apiGroups: ["niphas.io"] + resources: ["niphasworkloads/finalizers"] + verbs: ["update", "patch"] + # Child resources + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["services"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["networking.k8s.io"] + resources: ["ingresses"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["policy"] + resources: ["poddisruptionbudgets"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # Events + - apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] + # Pod status (for ready replica counting) + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] diff --git a/charts/niphas/templates/operator/clusterrolebinding.yaml b/charts/niphas/templates/operator/clusterrolebinding.yaml new file mode 100644 index 0000000..4d5f5e3 --- /dev/null +++ b/charts/niphas/templates/operator/clusterrolebinding.yaml @@ -0,0 +1,15 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: niphas-operator + labels: + {{- include "niphas.labels" . | nindent 4 }} + app.kubernetes.io/component: operator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: niphas-operator +subjects: + - kind: ServiceAccount + name: niphas-operator + namespace: {{ include "niphas.namespace" . }} diff --git a/charts/niphas/templates/operator/deployment.yaml b/charts/niphas/templates/operator/deployment.yaml new file mode 100644 index 0000000..dbf1259 --- /dev/null +++ b/charts/niphas/templates/operator/deployment.yaml @@ -0,0 +1,63 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: niphas-operator + namespace: {{ include "niphas.namespace" . }} + labels: + {{- include "niphas.labels" . | nindent 4 }} + app.kubernetes.io/component: operator + {{- include "niphas.selectorLabels" (dict "name" "niphas-operator" "root" .) | nindent 4 }} +spec: + replicas: {{ .Values.operator.replicas }} + selector: + matchLabels: + {{- include "niphas.selectorLabels" (dict "name" "niphas-operator" "root" .) | nindent 6 }} + template: + metadata: + labels: + {{- include "niphas.labels" . | nindent 8 }} + app.kubernetes.io/component: operator + {{- include "niphas.selectorLabels" (dict "name" "niphas-operator" "root" .) | nindent 8 }} + spec: + serviceAccountName: niphas-operator + containers: + - name: operator + image: "{{ .Values.operator.image.repository }}:{{ .Values.operator.image.tag }}" + imagePullPolicy: {{ .Values.operator.image.pullPolicy }} + ports: + - name: health + containerPort: {{ .Values.config.healthPort }} + protocol: TCP + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: NIPHAS_CONFIG + value: /etc/niphas/config.yaml + livenessProbe: + httpGet: + path: /healthz + port: health + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /readyz + port: health + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + {{- toYaml .Values.operator.resources | nindent 12 }} + volumeMounts: + - name: config + mountPath: /etc/niphas + readOnly: true + volumes: + - name: config + configMap: + name: niphas-config diff --git a/charts/niphas/templates/operator/role.yaml b/charts/niphas/templates/operator/role.yaml new file mode 100644 index 0000000..a57f915 --- /dev/null +++ b/charts/niphas/templates/operator/role.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: niphas-operator-leader-election + namespace: {{ include "niphas.namespace" . }} + labels: + {{- include "niphas.labels" . | nindent 4 }} + app.kubernetes.io/component: operator +rules: + - apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] diff --git a/charts/niphas/templates/operator/rolebinding.yaml b/charts/niphas/templates/operator/rolebinding.yaml new file mode 100644 index 0000000..c9f0371 --- /dev/null +++ b/charts/niphas/templates/operator/rolebinding.yaml @@ -0,0 +1,16 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: niphas-operator-leader-election + namespace: {{ include "niphas.namespace" . }} + labels: + {{- include "niphas.labels" . | nindent 4 }} + app.kubernetes.io/component: operator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: niphas-operator-leader-election +subjects: + - kind: ServiceAccount + name: niphas-operator + namespace: {{ include "niphas.namespace" . }} diff --git a/charts/niphas/templates/operator/serviceaccount.yaml b/charts/niphas/templates/operator/serviceaccount.yaml new file mode 100644 index 0000000..58a2632 --- /dev/null +++ b/charts/niphas/templates/operator/serviceaccount.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: niphas-operator + namespace: {{ include "niphas.namespace" . }} + labels: + {{- include "niphas.labels" . | nindent 4 }} + app.kubernetes.io/component: operator diff --git a/charts/niphas/values.yaml b/charts/niphas/values.yaml new file mode 100644 index 0000000..47835f6 --- /dev/null +++ b/charts/niphas/values.yaml @@ -0,0 +1,74 @@ +namespace: niphas-system + +operator: + image: + repository: ghcr.io/fullzer4/niphas-operator + tag: latest + pullPolicy: IfNotPresent + replicas: 1 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + +eval: + image: + repository: ghcr.io/fullzer4/niphas-eval + tag: latest + pullPolicy: IfNotPresent + replicas: 1 + servicePort: 8443 + targetPort: 8080 + resources: + requests: + cpu: 200m + memory: 256Mi + limits: + cpu: "1" + memory: 1Gi + +csi: + image: + repository: ghcr.io/fullzer4/niphas-csi + tag: latest + pullPolicy: IfNotPresent + nodeSelector: + niphas.io/store: "true" + registrar: + image: + repository: registry.k8s.io/sig-storage/csi-node-driver-registrar + tag: v2.13.0 + liveness: + image: + repository: registry.k8s.io/sig-storage/livenessprobe + tag: v2.14.0 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 256Mi + +runner: + image: + repository: ghcr.io/fullzer4/niphas-runner + tag: latest + +config: + logLevel: info + binaryCaches: + - https://cache.nixos.org + allowedFlakeOrigins: + - "github:*" + evalWebhookUrl: http://niphas-eval.niphas-system.svc:8443 + healthPort: 8080 + evalTimeout: 300s + cache: + path: /var/lib/niphas/cache + highWatermark: 15000000000 + lowWatermark: 10000000000 + gcInterval: 300s diff --git a/crates/niphas-core/Cargo.toml b/crates/niphas-core/Cargo.toml index 5b0f7dd..53c3ce5 100644 --- a/crates/niphas-core/Cargo.toml +++ b/crates/niphas-core/Cargo.toml @@ -47,6 +47,10 @@ sha2.workspace = true ed25519-dalek.workspace = true base64.workspace = true +[[bin]] +name = "niphas-crd-gen" +path = "src/bin/crd_gen.rs" + [features] default = [] runtime = ["kube/runtime"] diff --git a/crates/niphas-core/src/bin/crd_gen.rs b/crates/niphas-core/src/bin/crd_gen.rs new file mode 100644 index 0000000..ee33b58 --- /dev/null +++ b/crates/niphas-core/src/bin/crd_gen.rs @@ -0,0 +1,6 @@ +use kube::CustomResourceExt; +use niphas_core::crd::NiphasWorkload; + +fn main() { + print!("{}", serde_yaml::to_string(&NiphasWorkload::crd()).unwrap()); +} diff --git a/crates/niphas-e2e/Cargo.toml b/crates/niphas-e2e/Cargo.toml new file mode 100644 index 0000000..316df42 --- /dev/null +++ b/crates/niphas-e2e/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "niphas-e2e" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +publish = false + +[[bin]] +name = "niphas-mock-eval" +path = "src/bin/mock_eval.rs" + +[dependencies] +niphas-core = { workspace = true, features = ["runtime"] } +kube.workspace = true +k8s-openapi.workspace = true +tokio.workspace = true +serde_json.workspace = true +anyhow.workspace = true +reqwest.workspace = true +axum.workspace = true +serde.workspace = true +tracing.workspace = true diff --git a/crates/niphas-e2e/src/bin/mock_eval.rs b/crates/niphas-e2e/src/bin/mock_eval.rs new file mode 100644 index 0000000..ffa28c7 --- /dev/null +++ b/crates/niphas-e2e/src/bin/mock_eval.rs @@ -0,0 +1,48 @@ +use axum::{Json, Router, routing::get, routing::post}; +use niphas_core::eval::{EvalRequest, EvalResponse}; +use std::net::SocketAddr; + +async fn evaluate(Json(req): Json) -> Json { + let store_path = format!( + "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-{}", + req.flake_ref.split(':').next_back().unwrap_or("mock") + ); + let name = req + .flake_ref + .split('/') + .next_back() + .unwrap_or("mock-app") + .to_string(); + + Json(EvalResponse { + store_path: store_path.clone(), + name: name.clone(), + main_program: Some(name), + closure_paths: vec![store_path], + }) +} + +async fn healthz() -> &'static str { + "ok" +} + +async fn readyz() -> &'static str { + "ok" +} + +#[tokio::main] +async fn main() { + let addr: SocketAddr = std::env::var("LISTEN_ADDR") + .unwrap_or_else(|_| "0.0.0.0:8080".to_string()) + .parse() + .expect("invalid LISTEN_ADDR"); + + let app = Router::new() + .route("/evaluate", post(evaluate)) + .route("/healthz", get(healthz)) + .route("/readyz", get(readyz)); + + println!("mock-eval listening on {addr}"); + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/crates/niphas-e2e/src/lib.rs b/crates/niphas-e2e/src/lib.rs new file mode 100644 index 0000000..9497bfa --- /dev/null +++ b/crates/niphas-e2e/src/lib.rs @@ -0,0 +1,2 @@ +// niphas-e2e: end-to-end test crate for niphas platform. +// This crate contains mock binaries and integration tests. diff --git a/crates/niphas-e2e/tests/e2e_platform.rs b/crates/niphas-e2e/tests/e2e_platform.rs new file mode 100644 index 0000000..1369f99 --- /dev/null +++ b/crates/niphas-e2e/tests/e2e_platform.rs @@ -0,0 +1,175 @@ +use anyhow::{Context, Result, bail}; +use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition; +use kube::{ + Api, Client, + api::{DeleteParams, PostParams}, +}; +use niphas_core::crd::{NiphasWorkload, WorkloadPhase}; +use std::time::Duration; + +/// Poll the NiphasWorkload status until it reaches `expected` phase or timeout. +async fn wait_for_phase( + api: &Api, + name: &str, + expected: WorkloadPhase, + timeout: Duration, +) -> Result { + let start = std::time::Instant::now(); + loop { + if start.elapsed() > timeout { + bail!( + "timed out waiting for workload '{}' to reach phase {:?}", + name, + expected + ); + } + if let Some(wl) = api.get_opt(name).await? { + if let Some(ref status) = wl.status { + if status.phase == expected { + return Ok(wl); + } + } + } + tokio::time::sleep(Duration::from_secs(2)).await; + } +} + +/// Wait until the workload no longer exists. +async fn wait_for_deletion(api: &Api, name: &str, timeout: Duration) -> Result<()> { + let start = std::time::Instant::now(); + loop { + if start.elapsed() > timeout { + bail!("timed out waiting for workload '{}' to be deleted", name); + } + if api.get_opt(name).await?.is_none() { + return Ok(()); + } + tokio::time::sleep(Duration::from_secs(2)).await; + } +} + +fn test_workload(name: &str, flake_ref: &str) -> NiphasWorkload { + serde_json::from_value(serde_json::json!({ + "apiVersion": "niphas.io/v1alpha1", + "kind": "NiphasWorkload", + "metadata": { + "name": name, + }, + "spec": { + "flakeRef": flake_ref, + "attribute": "packages.x86_64-linux.default", + } + })) + .expect("valid test workload") +} + +#[tokio::test] +async fn test_crd_installed() -> Result<()> { + let client = Client::try_default().await?; + let crds: Api = Api::all(client); + let crd = crds + .get("niphasworkloads.niphas.io") + .await + .context("CRD niphasworkloads.niphas.io not found in cluster")?; + + assert_eq!(crd.spec.group, "niphas.io", "CRD group must be niphas.io"); + Ok(()) +} + +#[tokio::test] +async fn test_operator_healthy() -> Result<()> { + let url = std::env::var("OPERATOR_HEALTH_URL") + .unwrap_or_else(|_| "http://localhost:8080/healthz".to_string()); + + let resp = reqwest::get(&url) + .await + .context("failed to reach operator healthz")?; + assert!( + resp.status().is_success(), + "operator healthz returned {}", + resp.status() + ); + Ok(()) +} + +#[tokio::test] +async fn test_eval_healthy() -> Result<()> { + let url = std::env::var("EVAL_HEALTH_URL") + .unwrap_or_else(|_| "http://localhost:8443/healthz".to_string()); + + let resp = reqwest::get(&url) + .await + .context("failed to reach eval healthz")?; + assert!( + resp.status().is_success(), + "eval healthz returned {}", + resp.status() + ); + Ok(()) +} + +#[tokio::test] +async fn test_workload_eval_failure_sets_failed() -> Result<()> { + let client = Client::try_default().await?; + let ns = std::env::var("E2E_NAMESPACE").unwrap_or_else(|_| "niphas-e2e".to_string()); + let api: Api = Api::namespaced(client, &ns); + + let name = "e2e-fail-test"; + let wl = test_workload(name, "github:nonexistent/does-not-exist-12345"); + + // Clean up if leftover from previous run + let _ = api.delete(name, &DeleteParams::default()).await; + tokio::time::sleep(Duration::from_secs(2)).await; + + api.create(&PostParams::default(), &wl) + .await + .context("failed to create test workload")?; + + let result = wait_for_phase(&api, name, WorkloadPhase::Failed, Duration::from_secs(120)).await; + + // Cleanup + let _ = api.delete(name, &DeleteParams::default()).await; + + let wl = result.context("workload did not reach Failed phase")?; + let status = wl.status.expect("status should be set"); + + // Check Evaluated condition is False + if let Some(conditions) = &status.conditions { + let eval_cond = conditions.iter().find(|c| c.type_ == "Evaluated"); + assert!(eval_cond.is_some(), "expected Evaluated condition to exist"); + } + + Ok(()) +} + +#[tokio::test] +async fn test_workload_deletion_cleanup() -> Result<()> { + let client = Client::try_default().await?; + let ns = std::env::var("E2E_NAMESPACE").unwrap_or_else(|_| "niphas-e2e".to_string()); + let api: Api = Api::namespaced(client, &ns); + + let name = "e2e-delete-test"; + let wl = test_workload(name, "github:nixos/nixpkgs"); + + // Clean up if leftover + let _ = api.delete(name, &DeleteParams::default()).await; + tokio::time::sleep(Duration::from_secs(2)).await; + + api.create(&PostParams::default(), &wl) + .await + .context("failed to create test workload")?; + + // Wait for it to get a finalizer (operator picks it up) + tokio::time::sleep(Duration::from_secs(10)).await; + + // Delete it + api.delete(name, &DeleteParams::default()) + .await + .context("failed to delete test workload")?; + + wait_for_deletion(&api, name, Duration::from_secs(60)) + .await + .context("workload was not cleaned up after deletion")?; + + Ok(()) +} diff --git a/hack/kind-config.yaml b/hack/kind-config.yaml new file mode 100644 index 0000000..cebee6d --- /dev/null +++ b/hack/kind-config.yaml @@ -0,0 +1,5 @@ +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: + - role: control-plane + - role: worker diff --git a/nix/devShell.nix b/nix/devShell.nix index ddb8552..2a1c072 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -12,9 +12,14 @@ rustToolchain pkgs.pkg-config pkgs.protobuf + pkgs.clang + pkgs.lld pkgs.cargo-deny pkgs.cargo-nextest pkgs.cargo-watch + pkgs.kubectl + pkgs.kubernetes-helm + pkgs.kind ]; buildInputs = [ @@ -26,6 +31,13 @@ PROTOC = "${pkgs.protobuf}/bin/protoc"; + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ + pkgs.openssl + pkgs.zstd + pkgs.xz + pkgs.bzip2 + ]; + shellHook = '' echo "niphas devShell ready" ''; diff --git a/nix/images.nix b/nix/images.nix new file mode 100644 index 0000000..e05e056 --- /dev/null +++ b/nix/images.nix @@ -0,0 +1,67 @@ +{ inputs, ... }: +{ + perSystem = { pkgs, self', system, ... }: + let + mkImage = { name, pkg, contents ? [], entrypoint }: + pkgs.dockerTools.buildLayeredImage { + inherit name; + tag = "dev"; + contents = [ + pkg + pkgs.cacert + pkgs.tzdata + ] ++ contents; + config = { + Entrypoint = [ entrypoint ]; + Env = [ + "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + "TZDIR=${pkgs.tzdata}/share/zoneinfo" + ]; + }; + }; + in + { + packages = { + image-niphas-operator = mkImage { + name = "ghcr.io/fullzer4/niphas-operator"; + pkg = self'.packages.niphas-operator; + entrypoint = "/bin/niphas-operator"; + }; + + image-niphas-eval = mkImage { + name = "ghcr.io/fullzer4/niphas-eval"; + pkg = self'.packages.niphas-eval; + contents = [ pkgs.nix pkgs.git ]; + entrypoint = "/bin/niphas-eval"; + }; + + image-niphas-csi = mkImage { + name = "ghcr.io/fullzer4/niphas-csi"; + pkg = self'.packages.niphas-csi; + contents = [ pkgs.util-linux ]; + entrypoint = "/bin/niphas-csi"; + }; + + image-niphas-runner = pkgs.dockerTools.buildLayeredImage { + name = "ghcr.io/fullzer4/niphas-runner"; + tag = "dev"; + contents = [ + pkgs.busybox + pkgs.cacert + ]; + config = { + Entrypoint = [ "/bin/sh" ]; + Env = [ + "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + ]; + }; + }; + + image-niphas-mock-eval = mkImage { + name = "ghcr.io/fullzer4/niphas-mock-eval"; + pkg = self'.packages.niphas-mock-eval; + entrypoint = "/bin/niphas-mock-eval"; + }; + }; + }; +} diff --git a/nix/packages.nix b/nix/packages.nix index 6069b9d..f39d725 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -27,12 +27,24 @@ }; cargoArtifacts = craneLib.buildDepsOnly commonArgs; + + mkBin = name: craneLib.buildPackage (commonArgs // { + inherit cargoArtifacts; + cargoExtraArgs = "--bin ${name}"; + doCheck = false; + }); in { packages = { default = craneLib.buildPackage (commonArgs // { inherit cargoArtifacts; }); + + niphas-operator = mkBin "niphas-operator"; + niphas-eval = mkBin "niphas-eval"; + niphas-csi = mkBin "niphas-csi"; + niphas-crd-gen = mkBin "niphas-crd-gen"; + niphas-mock-eval = mkBin "niphas-mock-eval"; }; }; } From 2a664feb14492a53380644d523b88936988b25ff Mon Sep 17 00:00:00 2001 From: fullzer4 Date: Mon, 15 Jun 2026 17:06:48 -0300 Subject: [PATCH 02/24] fix: use git CLI for cargo-deny advisory-db fetch --- deny.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/deny.toml b/deny.toml index 95ea6f6..bbb30e2 100644 --- a/deny.toml +++ b/deny.toml @@ -1,4 +1,5 @@ [advisories] +git-fetch-with-cli = true ignore = [] [licenses] From 54a0d30f49363ca03f2abad5f0a5a52be5a356d1 Mon Sep 17 00:00:00 2001 From: fullzer4 Date: Mon, 15 Jun 2026 17:18:13 -0300 Subject: [PATCH 03/24] fix: clear corrupted advisory-db cache before cargo deny --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a07daa..e6d58d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v4 - uses: DeterminateSystems/nix-installer-action@main - uses: DeterminateSystems/magic-nix-cache-action@main - - run: nix develop -c cargo deny check + - run: rm -rf ~/.cargo/advisory-dbs && nix develop -c cargo deny check unit: name: "L1 · Unit Tests" From f17748ebc1a0f1655444977a687591adaa23f5b2 Mon Sep 17 00:00:00 2001 From: fullzer4 Date: Mon, 15 Jun 2026 17:35:32 -0300 Subject: [PATCH 04/24] fix: run advisory-db cleanup inside nix develop shell --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6d58d0..2f27bb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v4 - uses: DeterminateSystems/nix-installer-action@main - uses: DeterminateSystems/magic-nix-cache-action@main - - run: rm -rf ~/.cargo/advisory-dbs && nix develop -c cargo deny check + - run: nix develop -c bash -c 'rm -rf ~/.cargo/advisory-dbs && cargo deny check' unit: name: "L1 · Unit Tests" From 3eb447b8cf7137bbd3e6fe62d87e21403a59d05f Mon Sep 17 00:00:00 2001 From: fullzer4 Date: Mon, 15 Jun 2026 17:42:28 -0300 Subject: [PATCH 05/24] fix: revert git-fetch-with-cli and use cargo deny fetch before check --- .github/workflows/ci.yml | 2 +- deny.toml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f27bb0..ad57948 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v4 - uses: DeterminateSystems/nix-installer-action@main - uses: DeterminateSystems/magic-nix-cache-action@main - - run: nix develop -c bash -c 'rm -rf ~/.cargo/advisory-dbs && cargo deny check' + - run: nix develop -c bash -c 'rm -rf ~/.cargo/advisory-dbs && cargo deny fetch && cargo deny check' unit: name: "L1 · Unit Tests" diff --git a/deny.toml b/deny.toml index bbb30e2..95ea6f6 100644 --- a/deny.toml +++ b/deny.toml @@ -1,5 +1,4 @@ [advisories] -git-fetch-with-cli = true ignore = [] [licenses] From e267a35ac9f021523d0a664e5b7b78b334a28ca4 Mon Sep 17 00:00:00 2001 From: fullzer4 Date: Mon, 15 Jun 2026 17:45:22 -0300 Subject: [PATCH 06/24] fix: add git to devShell for cargo-deny advisory-db fetch --- .github/workflows/ci.yml | 2 +- nix/devShell.nix | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad57948..9a07daa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v4 - uses: DeterminateSystems/nix-installer-action@main - uses: DeterminateSystems/magic-nix-cache-action@main - - run: nix develop -c bash -c 'rm -rf ~/.cargo/advisory-dbs && cargo deny fetch && cargo deny check' + - run: nix develop -c cargo deny check unit: name: "L1 · Unit Tests" diff --git a/nix/devShell.nix b/nix/devShell.nix index 2a1c072..e10fd52 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -14,6 +14,7 @@ pkgs.protobuf pkgs.clang pkgs.lld + pkgs.git pkgs.cargo-deny pkgs.cargo-nextest pkgs.cargo-watch From dc05a5f6ce919054020c6a7c541395c06302553a Mon Sep 17 00:00:00 2001 From: fullzer4 Date: Mon, 15 Jun 2026 17:58:00 -0300 Subject: [PATCH 07/24] fix: revert to contents API for buildLayeredImage and pin flake.lock --- flake.lock | 114 +++++++++++++++++++++++++++++++++++++++++++++++++ nix/images.nix | 8 ++-- 2 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 flake.lock diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..6c7909e --- /dev/null +++ b/flake.lock @@ -0,0 +1,114 @@ +{ + "nodes": { + "crane": { + "locked": { + "lastModified": 1780532242, + "narHash": "sha256-D+BsdpxmtUwtqGoY0IXPhHgTlmqgcZKCEo1oMyn7ep0=", + "owner": "ipetkov", + "repo": "crane", + "rev": "59a82a1222dd3b2080b5cc52a1a2e8d5f1b77f37", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1778716662, + "narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "import-tree": { + "locked": { + "lastModified": 1778781969, + "narHash": "sha256-Jjuz5CmSkur8KvLDoGa+vylEp+RkQtv4mt/qcMznpH0=", + "owner": "vic", + "repo": "import-tree", + "rev": "d321337efd0f23a9eb14a42adb7b2c29313ab274", + "type": "github" + }, + "original": { + "owner": "vic", + "repo": "import-tree", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1781074563, + "narHash": "sha256-md8WlXOlfnIeHeOScMTTHFyf2d6iaTwPl2apR5EQ3P4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "9ae611a455b90cf061d8f332b977e387bda8e1ca", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1777168982, + "narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "root": { + "inputs": { + "crane": "crane", + "flake-parts": "flake-parts", + "import-tree": "import-tree", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1781493707, + "narHash": "sha256-lzf7qdQWuiY0T9VEbfH2CmP1LZQAYooU/sZuSgEJEBk=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "06f25b8e40805beb2121a4dae4cc37d6f981800f", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/nix/images.nix b/nix/images.nix index e05e056..f01a586 100644 --- a/nix/images.nix +++ b/nix/images.nix @@ -2,7 +2,7 @@ { perSystem = { pkgs, self', system, ... }: let - mkImage = { name, pkg, contents ? [], entrypoint }: + mkImage = { name, pkg, extraPaths ? [], entrypoint }: pkgs.dockerTools.buildLayeredImage { inherit name; tag = "dev"; @@ -10,7 +10,7 @@ pkg pkgs.cacert pkgs.tzdata - ] ++ contents; + ] ++ extraPaths; config = { Entrypoint = [ entrypoint ]; Env = [ @@ -31,14 +31,14 @@ image-niphas-eval = mkImage { name = "ghcr.io/fullzer4/niphas-eval"; pkg = self'.packages.niphas-eval; - contents = [ pkgs.nix pkgs.git ]; + extraPaths = [ pkgs.nix pkgs.git ]; entrypoint = "/bin/niphas-eval"; }; image-niphas-csi = mkImage { name = "ghcr.io/fullzer4/niphas-csi"; pkg = self'.packages.niphas-csi; - contents = [ pkgs.util-linux ]; + extraPaths = [ pkgs.util-linux ]; entrypoint = "/bin/niphas-csi"; }; From ff26ee22d2aecb38e8933d45375b6446982a9d62 Mon Sep 17 00:00:00 2001 From: fullzer4 Date: Mon, 15 Jun 2026 18:11:37 -0300 Subject: [PATCH 08/24] fix: correct crane source path from ../.. to ./.. for nix store resolution --- nix/packages.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/packages.nix b/nix/packages.nix index f39d725..4fd3b9b 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -5,7 +5,7 @@ rustToolchain = inputs.rust-overlay.packages.${system}.rust; craneLib = (inputs.crane.mkLib pkgs).overrideToolchain rustToolchain; - src = craneLib.cleanCargoSource (craneLib.path ../..); + src = craneLib.cleanCargoSource (craneLib.path ./..); commonArgs = { inherit src; From d7b1c1316394a71d078636a0d40159b8b00653c0 Mon Sep 17 00:00:00 2001 From: fullzer4 Date: Mon, 15 Jun 2026 18:22:11 -0300 Subject: [PATCH 09/24] fix: use self instead of craneLib.path for flake source resolution --- nix/packages.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nix/packages.nix b/nix/packages.nix index 4fd3b9b..87400b4 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -1,11 +1,11 @@ -{ inputs, ... }: +{ inputs, self, ... }: { perSystem = { pkgs, system, ... }: let rustToolchain = inputs.rust-overlay.packages.${system}.rust; craneLib = (inputs.crane.mkLib pkgs).overrideToolchain rustToolchain; - src = craneLib.cleanCargoSource (craneLib.path ./..); + src = craneLib.cleanCargoSource self; commonArgs = { inherit src; From e6c19ad34c2344be8261a52fb8957a2b3141b503 Mon Sep 17 00:00:00 2001 From: fullzer4 Date: Tue, 16 Jun 2026 09:29:52 -0300 Subject: [PATCH 10/24] fix: add clang and lld to nix build inputs for cargo linker config --- nix/packages.nix | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nix/packages.nix b/nix/packages.nix index 87400b4..e804cd3 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -14,6 +14,8 @@ nativeBuildInputs = [ pkgs.pkg-config pkgs.protobuf + pkgs.clang + pkgs.lld ]; buildInputs = [ From a18ae027197d29d3ea51c9b746354d47b63d4a70 Mon Sep 17 00:00:00 2001 From: fullzer4 Date: Tue, 16 Jun 2026 10:18:03 -0300 Subject: [PATCH 11/24] fix: include proto files in crane source filter and set pname --- nix/packages.nix | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/nix/packages.nix b/nix/packages.nix index e804cd3..a9620f5 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -5,10 +5,18 @@ rustToolchain = inputs.rust-overlay.packages.${system}.rust; craneLib = (inputs.crane.mkLib pkgs).overrideToolchain rustToolchain; - src = craneLib.cleanCargoSource self; + protoFilter = path: _type: builtins.match ".*\.proto$" path != null; + srcFilter = path: type: + (protoFilter path type) || (craneLib.filterCargoSources path type); + + src = pkgs.lib.cleanSourceWith { + src = self; + filter = srcFilter; + }; commonArgs = { inherit src; + pname = "niphas"; strictDeps = true; nativeBuildInputs = [ From 71a7286377bdcc6dc2b9eb81addacf092871bb26 Mon Sep 17 00:00:00 2001 From: fullzer4 Date: Tue, 16 Jun 2026 10:23:34 -0300 Subject: [PATCH 12/24] perf: build all images in single nix derivation to share cache --- .github/workflows/ci.yml | 19 +++++++------------ nix/images.nix | 8 ++++++++ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a07daa..8b540e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,14 +77,9 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main - uses: DeterminateSystems/magic-nix-cache-action@main - # Build OCI images + # Build all OCI images in a single nix build (shared cargoArtifacts) - name: Build images - run: | - nix build .#image-niphas-operator -o result-operator - nix build .#image-niphas-eval -o result-eval - nix build .#image-niphas-csi -o result-csi - nix build .#image-niphas-runner -o result-runner - nix build .#image-niphas-mock-eval -o result-mock-eval + run: nix build .#all-images -o result-images # Create kind cluster - uses: helm/kind-action@v1 @@ -95,11 +90,11 @@ jobs: # Load images into kind - name: Load images into kind run: | - kind load image-archive result-operator --name niphas-test - kind load image-archive result-eval --name niphas-test - kind load image-archive result-csi --name niphas-test - kind load image-archive result-runner --name niphas-test - kind load image-archive result-mock-eval --name niphas-test + kind load image-archive result-images/operator.tar.gz --name niphas-test + kind load image-archive result-images/eval.tar.gz --name niphas-test + kind load image-archive result-images/csi.tar.gz --name niphas-test + kind load image-archive result-images/runner.tar.gz --name niphas-test + kind load image-archive result-images/mock-eval.tar.gz --name niphas-test # Label nodes for CSI - name: Label nodes diff --git a/nix/images.nix b/nix/images.nix index f01a586..014580d 100644 --- a/nix/images.nix +++ b/nix/images.nix @@ -62,6 +62,14 @@ pkg = self'.packages.niphas-mock-eval; entrypoint = "/bin/niphas-mock-eval"; }; + + all-images = pkgs.linkFarm "niphas-all-images" [ + { name = "operator.tar.gz"; path = self'.packages.image-niphas-operator; } + { name = "eval.tar.gz"; path = self'.packages.image-niphas-eval; } + { name = "csi.tar.gz"; path = self'.packages.image-niphas-csi; } + { name = "runner.tar.gz"; path = self'.packages.image-niphas-runner; } + { name = "mock-eval.tar.gz"; path = self'.packages.image-niphas-mock-eval; } + ]; }; }; } From 4504fc348510b9fef34878f25239d478fe729167 Mon Sep 17 00:00:00 2001 From: fullzer4 Date: Tue, 16 Jun 2026 10:46:18 -0300 Subject: [PATCH 13/24] fix: use mock-eval image for eval service in e2e tests --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b540e6..8e24ee4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,6 +113,7 @@ jobs: --create-namespace \ --set operator.image.pullPolicy=Never \ --set operator.image.tag=dev \ + --set eval.image.repository=ghcr.io/fullzer4/niphas-mock-eval \ --set eval.image.pullPolicy=Never \ --set eval.image.tag=dev \ --set csi.image.pullPolicy=Never \ From 989ee745a2f3be80112e841df88e82965b2088f9 Mon Sep 17 00:00:00 2001 From: fullzer4 Date: Tue, 16 Jun 2026 11:09:27 -0300 Subject: [PATCH 14/24] fix: add debug output for CSI DaemonSet rollout failure --- .github/workflows/ci.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e24ee4..4c48983 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,6 +127,23 @@ jobs: kubectl -n niphas-system rollout status deployment/niphas-eval --timeout=120s kubectl -n niphas-system rollout status daemonset/niphas-csi --timeout=120s + # Debug on failure + - name: Debug pods on failure + if: failure() + run: | + echo "=== Pod status ===" + kubectl -n niphas-system get pods -o wide + echo "=== Pod descriptions ===" + kubectl -n niphas-system describe pods + echo "=== CSI logs ===" + kubectl -n niphas-system logs -l app.kubernetes.io/name=niphas-csi --all-containers --tail=50 || true + echo "=== Operator logs ===" + kubectl -n niphas-system logs -l app.kubernetes.io/name=niphas-operator --tail=50 || true + echo "=== Eval logs ===" + kubectl -n niphas-system logs -l app.kubernetes.io/name=niphas-eval --tail=50 || true + echo "=== Events ===" + kubectl -n niphas-system get events --sort-by=.lastTimestamp + # Create e2e namespace - name: Create e2e namespace run: kubectl create namespace niphas-e2e From 0281c4706edb41cba114f50300f92af3a8ca0e72 Mon Sep 17 00:00:00 2001 From: fullzer4 Date: Tue, 16 Jun 2026 11:38:20 -0300 Subject: [PATCH 15/24] fix: include runtime shared libraries in OCI images for dynamic linking --- nix/images.nix | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/nix/images.nix b/nix/images.nix index 014580d..5593818 100644 --- a/nix/images.nix +++ b/nix/images.nix @@ -2,6 +2,14 @@ { perSystem = { pkgs, self', system, ... }: let + # Runtime shared libraries needed by all Rust binaries + runtimeLibs = [ + pkgs.openssl.out + pkgs.zstd.out + pkgs.xz.out + pkgs.bzip2.out + ]; + mkImage = { name, pkg, extraPaths ? [], entrypoint }: pkgs.dockerTools.buildLayeredImage { inherit name; @@ -10,7 +18,7 @@ pkg pkgs.cacert pkgs.tzdata - ] ++ extraPaths; + ] ++ runtimeLibs ++ extraPaths; config = { Entrypoint = [ entrypoint ]; Env = [ From ce5f492541ce034f548438bbb75aae2f69c3c7d7 Mon Sep 17 00:00:00 2001 From: fullzer4 Date: Tue, 16 Jun 2026 13:12:38 -0300 Subject: [PATCH 16/24] fix: set LD_LIBRARY_PATH in OCI images for dynamic linker resolution --- nix/images.nix | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nix/images.nix b/nix/images.nix index 5593818..8adeaec 100644 --- a/nix/images.nix +++ b/nix/images.nix @@ -10,6 +10,8 @@ pkgs.bzip2.out ]; + runtimeLibPath = pkgs.lib.makeLibraryPath runtimeLibs; + mkImage = { name, pkg, extraPaths ? [], entrypoint }: pkgs.dockerTools.buildLayeredImage { inherit name; @@ -24,6 +26,7 @@ Env = [ "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" "TZDIR=${pkgs.tzdata}/share/zoneinfo" + "LD_LIBRARY_PATH=${runtimeLibPath}" ]; }; }; From c74488dd378190a63ae92f8fe2ab98b3c99fe4fe Mon Sep 17 00:00:00 2001 From: fullzer4 Date: Tue, 16 Jun 2026 13:40:44 -0300 Subject: [PATCH 17/24] fix: mock-eval returns error for nonexistent flake refs in e2e tests --- crates/niphas-e2e/src/bin/mock_eval.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/niphas-e2e/src/bin/mock_eval.rs b/crates/niphas-e2e/src/bin/mock_eval.rs index ffa28c7..9da33d1 100644 --- a/crates/niphas-e2e/src/bin/mock_eval.rs +++ b/crates/niphas-e2e/src/bin/mock_eval.rs @@ -1,8 +1,16 @@ -use axum::{Json, Router, routing::get, routing::post}; +use axum::{Json, Router, http::StatusCode, response::IntoResponse, routing::get, routing::post}; use niphas_core::eval::{EvalRequest, EvalResponse}; use std::net::SocketAddr; -async fn evaluate(Json(req): Json) -> Json { +async fn evaluate(Json(req): Json) -> impl IntoResponse { + // Simulate failure for flake refs containing "nonexistent" or "does-not-exist" + if req.flake_ref.contains("nonexistent") || req.flake_ref.contains("does-not-exist") { + return Err(( + StatusCode::UNPROCESSABLE_ENTITY, + format!("evaluation failed: flake '{}' not found", req.flake_ref), + )); + } + let store_path = format!( "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-{}", req.flake_ref.split(':').next_back().unwrap_or("mock") @@ -14,12 +22,12 @@ async fn evaluate(Json(req): Json) -> Json { .unwrap_or("mock-app") .to_string(); - Json(EvalResponse { + Ok(Json(EvalResponse { store_path: store_path.clone(), name: name.clone(), main_program: Some(name), closure_paths: vec![store_path], - }) + })) } async fn healthz() -> &'static str { From b8c2ffbc4f40f7a9cb1178e17163f52f1cf986b2 Mon Sep 17 00:00:00 2001 From: fullzer4 Date: Tue, 16 Jun 2026 14:24:45 -0300 Subject: [PATCH 18/24] feat: configure real nix eval with nix.conf, writable store volumes, and fixed readiness probe --- charts/niphas/templates/eval/deployment.yaml | 14 +++++++++++--- nix/images.nix | 10 +++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/charts/niphas/templates/eval/deployment.yaml b/charts/niphas/templates/eval/deployment.yaml index bed1e7d..7115302 100644 --- a/charts/niphas/templates/eval/deployment.yaml +++ b/charts/niphas/templates/eval/deployment.yaml @@ -38,17 +38,25 @@ spec: periodSeconds: 10 readinessProbe: httpGet: - path: /readyz + path: /healthz port: http - initialDelaySeconds: 10 - periodSeconds: 10 + initialDelaySeconds: 5 + periodSeconds: 5 resources: {{- toYaml .Values.eval.resources | nindent 12 }} volumeMounts: - name: config mountPath: /etc/niphas readOnly: true + - name: nix-var + mountPath: /nix/var + - name: nix-tmp + mountPath: /tmp volumes: - name: config configMap: name: niphas-config + - name: nix-var + emptyDir: {} + - name: nix-tmp + emptyDir: {} diff --git a/nix/images.nix b/nix/images.nix index 8adeaec..0b3183d 100644 --- a/nix/images.nix +++ b/nix/images.nix @@ -12,6 +12,14 @@ runtimeLibPath = pkgs.lib.makeLibraryPath runtimeLibs; + # Nix configuration for eval container (single-user, no sandbox) + nixConf = pkgs.writeTextDir "etc/nix/nix.conf" '' + sandbox = false + experimental-features = nix-command flakes + accept-flake-config = false + filter-syscalls = false + ''; + mkImage = { name, pkg, extraPaths ? [], entrypoint }: pkgs.dockerTools.buildLayeredImage { inherit name; @@ -42,7 +50,7 @@ image-niphas-eval = mkImage { name = "ghcr.io/fullzer4/niphas-eval"; pkg = self'.packages.niphas-eval; - extraPaths = [ pkgs.nix pkgs.git ]; + extraPaths = [ pkgs.nix pkgs.git nixConf ]; entrypoint = "/bin/niphas-eval"; }; From fa6d1903805e2d288a078545dbff8093c3e12371 Mon Sep 17 00:00:00 2001 From: fullzer4 Date: Tue, 16 Jun 2026 14:57:00 -0300 Subject: [PATCH 19/24] feat: add real E2E test script with darkhttpd from nixpkgs --- hack/test-e2e-real.sh | 148 ++++++++++++++++++++++++++++++++++++++++ hack/test-workload.yaml | 36 ++++++++++ 2 files changed, 184 insertions(+) create mode 100755 hack/test-e2e-real.sh create mode 100644 hack/test-workload.yaml diff --git a/hack/test-e2e-real.sh b/hack/test-e2e-real.sh new file mode 100755 index 0000000..9e6f10e --- /dev/null +++ b/hack/test-e2e-real.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Full E2E test with real Nix eval — validates the complete flow: +# flake eval → closure resolution → CSI NAR fetch → pod mount → running service +# +# Prerequisites: nix develop shell (provides kind, kubectl, helm) +# Usage: nix develop -c bash hack/test-e2e-real.sh + +CLUSTER_NAME="niphas-real-test" +NAMESPACE="niphas-system" +E2E_NS="niphas-e2e" + +info() { echo -e "\033[1;34m▸ $*\033[0m"; } +ok() { echo -e "\033[1;32m✓ $*\033[0m"; } +fail() { echo -e "\033[1;31m✗ $*\033[0m"; exit 1; } + +cleanup() { + info "Cleaning up kind cluster..." + kind delete cluster --name "$CLUSTER_NAME" 2>/dev/null || true +} +trap cleanup EXIT + +# ── Step 1: Build images ──────────────────────────────── +info "Building OCI images..." +nix build .#all-images -o result-images + +# ── Step 2: Create kind cluster ───────────────────────── +info "Creating kind cluster..." +kind delete cluster --name "$CLUSTER_NAME" 2>/dev/null || true +kind create cluster --name "$CLUSTER_NAME" --config hack/kind-config.yaml --wait 60s + +# ── Step 3: Load images into kind ─────────────────────── +info "Loading images into kind..." +for img in operator eval csi runner mock-eval; do + kind load image-archive "result-images/${img}.tar.gz" --name "$CLUSTER_NAME" +done + +# ── Step 4: Label nodes ───────────────────────────────── +info "Labeling nodes for CSI..." +kubectl label nodes --all niphas.io/store=true --overwrite + +# ── Step 5: Install Helm chart (real eval, not mock) ──── +info "Installing niphas with REAL eval..." +helm install niphas charts/niphas \ + --namespace "$NAMESPACE" \ + --create-namespace \ + --set operator.image.pullPolicy=Never \ + --set operator.image.tag=dev \ + --set eval.image.pullPolicy=Never \ + --set eval.image.tag=dev \ + --set csi.image.pullPolicy=Never \ + --set csi.image.tag=dev \ + --set runner.image.tag=dev \ + --wait --timeout 180s + +ok "All deployments ready" + +# ── Step 6: Check component health ───────────────────── +info "Checking component health..." +kubectl -n "$NAMESPACE" get pods -o wide + +# ── Step 7: Create test namespace ─────────────────────── +info "Creating test namespace..." +kubectl create namespace "$E2E_NS" 2>/dev/null || true + +# ── Step 8: Deploy test workload ──────────────────────── +info "Creating NiphasWorkload (darkhttpd from nixpkgs)..." +kubectl apply -f hack/test-workload.yaml + +# ── Step 9: Wait for evaluation ───────────────────────── +info "Waiting for evaluation (this fetches nixpkgs — may be slow first time)..." +for i in $(seq 1 90); do + PHASE=$(kubectl -n "$E2E_NS" get niphasworkload test-darkhttpd -o jsonpath='{.status.phase}' 2>/dev/null || echo "Pending") + case "$PHASE" in + Running) + ok "Workload phase: Running" + break + ;; + Failed) + echo "" + fail "Workload phase: Failed" + ;; + *) + printf "\r phase: %-20s (${i}/90, poll every 10s)" "$PHASE" + sleep 10 + ;; + esac +done +echo "" + +if [ "$PHASE" != "Running" ]; then + echo "" + info "Workload status:" + kubectl -n "$E2E_NS" get niphasworkload test-darkhttpd -o yaml | grep -A 30 "^status:" + info "Pod status:" + kubectl -n "$E2E_NS" get pods -o wide + info "Pod describe:" + kubectl -n "$E2E_NS" describe pods + info "Operator logs:" + kubectl -n "$NAMESPACE" logs -l app.kubernetes.io/name=niphas-operator --tail=30 + info "Eval logs:" + kubectl -n "$NAMESPACE" logs -l app.kubernetes.io/name=niphas-eval --tail=30 + info "CSI logs:" + kubectl -n "$NAMESPACE" logs -l app.kubernetes.io/name=niphas-csi --all-containers --tail=30 + fail "Workload did not reach Running phase (stuck at: $PHASE)" +fi + +# ── Step 10: Verify the pod is actually running ───────── +info "Checking pod status..." +kubectl -n "$E2E_NS" get pods -o wide +POD=$(kubectl -n "$E2E_NS" get pods -l niphas.io/workload=test-darkhttpd -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) +if [ -z "$POD" ]; then + fail "No pod found for workload test-darkhttpd" +fi +ok "Pod running: $POD" + +# ── Step 11: Verify nix store mount ───────────────────── +info "Checking /nix/store mount inside pod..." +kubectl -n "$E2E_NS" exec "$POD" -- ls /nix/store/ | head -5 +ok "Nix store is mounted" + +# ── Step 12: Test the service ─────────────────────────── +info "Port-forwarding to test service..." +kubectl -n "$E2E_NS" port-forward "svc/test-darkhttpd" 18080:80 & +PF_PID=$! +sleep 3 + +HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:18080/ 2>/dev/null || echo "000") +kill $PF_PID 2>/dev/null || true + +if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "403" ]; then + # darkhttpd returns 403 for empty dir listing (no index.html), which is fine + ok "HTTP service responding (status: $HTTP_CODE)" +else + fail "HTTP service not responding (status: $HTTP_CODE)" +fi + +# ── Step 13: Show workload status ─────────────────────── +info "Final workload status:" +kubectl -n "$E2E_NS" get niphasworkload test-darkhttpd -o jsonpath='{.status}' | python3 -m json.tool 2>/dev/null || \ + kubectl -n "$E2E_NS" get niphasworkload test-darkhttpd -o yaml | grep -A 30 "^status:" + +echo "" +ok "═══════════════════════════════════════════════" +ok " FULL E2E TEST PASSED" +ok " flake eval → closure → CSI mount → service" +ok "═══════════════════════════════════════════════" diff --git a/hack/test-workload.yaml b/hack/test-workload.yaml new file mode 100644 index 0000000..69b2fff --- /dev/null +++ b/hack/test-workload.yaml @@ -0,0 +1,36 @@ +apiVersion: niphas.io/v1alpha1 +kind: NiphasWorkload +metadata: + name: test-darkhttpd + namespace: niphas-e2e +spec: + flakeRef: github:nixos/nixpkgs + attribute: legacyPackages.x86_64-linux.darkhttpd + args: + - "/tmp" + - "--port" + - "8080" + replicas: 1 + ports: + - name: http + containerPort: 8080 + protocol: TCP + service: + type: ClusterIP + ports: + - name: http + port: 80 + targetPort: "8080" + protocol: TCP + readinessProbe: + tcpSocket: + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + requests: + cpu: 50m + memory: 32Mi + limits: + cpu: 200m + memory: 128Mi From c91d8ddcf80252ad667895127ea3562ec9e6bc24 Mon Sep 17 00:00:00 2001 From: fullzer4 Date: Tue, 16 Jun 2026 15:00:26 -0300 Subject: [PATCH 20/24] feat: add niphas test app with landing page and polished E2E script --- hack/test-app/flake.nix | 129 ++++++++++++++++++++ hack/test-e2e-real.sh | 262 ++++++++++++++++++++++------------------ hack/test-workload.yaml | 16 +-- 3 files changed, 280 insertions(+), 127 deletions(-) create mode 100644 hack/test-app/flake.nix diff --git a/hack/test-app/flake.nix b/hack/test-app/flake.nix new file mode 100644 index 0000000..99c5490 --- /dev/null +++ b/hack/test-app/flake.nix @@ -0,0 +1,129 @@ +{ + description = "Niphas test application — tiny HTTP server with status page"; + + inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; + + outputs = { nixpkgs, ... }: + let + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + + staticSite = pkgs.writeTextDir "index.html" '' + + + + + + niphas + + + +
+ +
nix-native workload platform for kubernetes
+
+
+ status + running +
+
+ source + nix flake +
+
+ delivery + CSI volume mount +
+
+ binary cache + cache.nixos.org +
+
+ server + darkhttpd +
+
+
+ served from /nix/store via niphas CSI driver +
+
+ + + ''; + in + { + packages.${system}.default = pkgs.writeShellApplication { + name = "niphas-test-app"; + runtimeInputs = [ pkgs.darkhttpd ]; + text = '' + echo "niphas-test-app: serving on port ''${PORT:-8080}" + exec darkhttpd ${staticSite} --port "''${PORT:-8080}" --no-listing + ''; + }; + }; +} diff --git a/hack/test-e2e-real.sh b/hack/test-e2e-real.sh index 9e6f10e..218e324 100755 --- a/hack/test-e2e-real.sh +++ b/hack/test-e2e-real.sh @@ -1,49 +1,88 @@ #!/usr/bin/env bash set -euo pipefail -# Full E2E test with real Nix eval — validates the complete flow: -# flake eval → closure resolution → CSI NAR fetch → pod mount → running service +# ═══════════════════════════════════════════════════════════ +# niphas — real E2E test +# validates: flake eval → closure → CSI mount → running pod # -# Prerequisites: nix develop shell (provides kind, kubectl, helm) -# Usage: nix develop -c bash hack/test-e2e-real.sh - -CLUSTER_NAME="niphas-real-test" -NAMESPACE="niphas-system" -E2E_NS="niphas-e2e" - -info() { echo -e "\033[1;34m▸ $*\033[0m"; } -ok() { echo -e "\033[1;32m✓ $*\033[0m"; } -fail() { echo -e "\033[1;31m✗ $*\033[0m"; exit 1; } - -cleanup() { - info "Cleaning up kind cluster..." - kind delete cluster --name "$CLUSTER_NAME" 2>/dev/null || true +# usage: nix develop -c bash hack/test-e2e-real.sh +# ═══════════════════════════════════════════════════════════ + +CLUSTER="niphas-e2e" +NS="niphas-system" +APP_NS="niphas-e2e" +PORT=18080 + +# ── colors ────────────────────────────────────────────── +bold='\033[1m' +dim='\033[2m' +blue='\033[34m' +green='\033[32m' +red='\033[31m' +cyan='\033[36m' +reset='\033[0m' + +step() { echo -e "\n${bold}${blue}[$1/${TOTAL}]${reset} ${bold}$2${reset}"; } +ok() { echo -e " ${green}✓${reset} $*"; } +info() { echo -e " ${dim}$*${reset}"; } +fail() { echo -e " ${red}✗ $*${reset}"; dump_debug; exit 1; } +TOTAL=10 + +dump_debug() { + echo -e "\n${bold}${red}── debug ──${reset}" + echo -e "${dim}" + kubectl -n "$APP_NS" get niphasworkload test-app -o yaml 2>/dev/null | tail -30 || true + echo "---" + kubectl -n "$APP_NS" get pods -o wide 2>/dev/null || true + echo "---" + kubectl -n "$APP_NS" describe pods 2>/dev/null | tail -40 || true + echo "---" + kubectl -n "$NS" logs -l app.kubernetes.io/name=niphas-operator --tail=20 2>/dev/null || true + echo "---" + kubectl -n "$NS" logs -l app.kubernetes.io/name=niphas-eval --tail=20 2>/dev/null || true + echo "---" + kubectl -n "$NS" logs -l app.kubernetes.io/name=niphas-csi --all-containers --tail=20 2>/dev/null || true + echo -e "${reset}" } + +cleanup() { kind delete cluster --name "$CLUSTER" 2>/dev/null || true; } trap cleanup EXIT -# ── Step 1: Build images ──────────────────────────────── -info "Building OCI images..." +echo -e "${bold}${cyan}" +echo " ┌─────────────────────────────────────────┐" +echo " │ niphas · real E2E test │" +echo " │ flake eval → closure → CSI → service │" +echo " └─────────────────────────────────────────┘" +echo -e "${reset}" + +# ── 1. build ──────────────────────────────────────────── +step 1 "building OCI images" +nix build .#all-images -o result-images --no-link 2>&1 | tail -3 nix build .#all-images -o result-images +ok "5 images built" -# ── Step 2: Create kind cluster ───────────────────────── -info "Creating kind cluster..." -kind delete cluster --name "$CLUSTER_NAME" 2>/dev/null || true -kind create cluster --name "$CLUSTER_NAME" --config hack/kind-config.yaml --wait 60s +# ── 2. cluster ────────────────────────────────────────── +step 2 "creating kind cluster" +kind delete cluster --name "$CLUSTER" 2>/dev/null || true +kind create cluster --name "$CLUSTER" --config hack/kind-config.yaml --wait 60s 2>&1 | tail -1 +ok "cluster ready (2 nodes)" -# ── Step 3: Load images into kind ─────────────────────── -info "Loading images into kind..." +# ── 3. images ─────────────────────────────────────────── +step 3 "loading images into kind" for img in operator eval csi runner mock-eval; do - kind load image-archive "result-images/${img}.tar.gz" --name "$CLUSTER_NAME" + kind load image-archive "result-images/${img}.tar.gz" --name "$CLUSTER" 2>/dev/null + ok "$img" done -# ── Step 4: Label nodes ───────────────────────────────── -info "Labeling nodes for CSI..." -kubectl label nodes --all niphas.io/store=true --overwrite +# ── 4. nodes ──────────────────────────────────────────── +step 4 "labeling nodes for CSI" +kubectl label nodes --all niphas.io/store=true --overwrite >/dev/null +ok "all nodes labeled niphas.io/store=true" -# ── Step 5: Install Helm chart (real eval, not mock) ──── -info "Installing niphas with REAL eval..." +# ── 5. deploy ─────────────────────────────────────────── +step 5 "installing niphas (real eval, not mock)" helm install niphas charts/niphas \ - --namespace "$NAMESPACE" \ + --namespace "$NS" \ --create-namespace \ --set operator.image.pullPolicy=Never \ --set operator.image.tag=dev \ @@ -52,97 +91,86 @@ helm install niphas charts/niphas \ --set csi.image.pullPolicy=Never \ --set csi.image.tag=dev \ --set runner.image.tag=dev \ - --wait --timeout 180s - -ok "All deployments ready" - -# ── Step 6: Check component health ───────────────────── -info "Checking component health..." -kubectl -n "$NAMESPACE" get pods -o wide - -# ── Step 7: Create test namespace ─────────────────────── -info "Creating test namespace..." -kubectl create namespace "$E2E_NS" 2>/dev/null || true - -# ── Step 8: Deploy test workload ──────────────────────── -info "Creating NiphasWorkload (darkhttpd from nixpkgs)..." -kubectl apply -f hack/test-workload.yaml - -# ── Step 9: Wait for evaluation ───────────────────────── -info "Waiting for evaluation (this fetches nixpkgs — may be slow first time)..." -for i in $(seq 1 90); do - PHASE=$(kubectl -n "$E2E_NS" get niphasworkload test-darkhttpd -o jsonpath='{.status.phase}' 2>/dev/null || echo "Pending") - case "$PHASE" in - Running) - ok "Workload phase: Running" - break - ;; - Failed) - echo "" - fail "Workload phase: Failed" - ;; - *) - printf "\r phase: %-20s (${i}/90, poll every 10s)" "$PHASE" - sleep 10 - ;; - esac + --wait --timeout 180s >/dev/null +ok "operator, eval, csi running" + +# ── 6. workload ───────────────────────────────────────── +step 6 "creating NiphasWorkload" +kubectl create namespace "$APP_NS" 2>/dev/null || true +kubectl apply -f hack/test-workload.yaml >/dev/null +info "flakeRef: github:fullzer4/niphas?dir=hack/test-app" +info "attribute: packages.x86_64-linux.default" +ok "workload created" + +# ── 7. wait for phases ────────────────────────────────── +step 7 "waiting for workload lifecycle" +LAST_PHASE="" +for i in $(seq 1 120); do + PHASE=$(kubectl -n "$APP_NS" get niphasworkload test-app \ + -o jsonpath='{.status.phase}' 2>/dev/null || echo "Pending") + + if [ "$PHASE" != "$LAST_PHASE" ]; then + [ -n "$LAST_PHASE" ] && echo "" + case "$PHASE" in + Pending) ok "phase: Pending" ;; + Evaluating) ok "phase: Evaluating (nix eval in progress...)" ;; + Provisioning) ok "phase: Provisioning (CSI fetching NARs...)" ;; + Running) ok "phase: Running"; break ;; + Failed) fail "phase: Failed" ;; + esac + LAST_PHASE="$PHASE" + else + printf "." + fi + sleep 5 done echo "" - -if [ "$PHASE" != "Running" ]; then - echo "" - info "Workload status:" - kubectl -n "$E2E_NS" get niphasworkload test-darkhttpd -o yaml | grep -A 30 "^status:" - info "Pod status:" - kubectl -n "$E2E_NS" get pods -o wide - info "Pod describe:" - kubectl -n "$E2E_NS" describe pods - info "Operator logs:" - kubectl -n "$NAMESPACE" logs -l app.kubernetes.io/name=niphas-operator --tail=30 - info "Eval logs:" - kubectl -n "$NAMESPACE" logs -l app.kubernetes.io/name=niphas-eval --tail=30 - info "CSI logs:" - kubectl -n "$NAMESPACE" logs -l app.kubernetes.io/name=niphas-csi --all-containers --tail=30 - fail "Workload did not reach Running phase (stuck at: $PHASE)" -fi - -# ── Step 10: Verify the pod is actually running ───────── -info "Checking pod status..." -kubectl -n "$E2E_NS" get pods -o wide -POD=$(kubectl -n "$E2E_NS" get pods -l niphas.io/workload=test-darkhttpd -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) -if [ -z "$POD" ]; then - fail "No pod found for workload test-darkhttpd" -fi -ok "Pod running: $POD" - -# ── Step 11: Verify nix store mount ───────────────────── -info "Checking /nix/store mount inside pod..." -kubectl -n "$E2E_NS" exec "$POD" -- ls /nix/store/ | head -5 -ok "Nix store is mounted" - -# ── Step 12: Test the service ─────────────────────────── -info "Port-forwarding to test service..." -kubectl -n "$E2E_NS" port-forward "svc/test-darkhttpd" 18080:80 & +[ "$PHASE" = "Running" ] || fail "stuck at phase: $PHASE (timeout after 10min)" + +# ── 8. pod check ──────────────────────────────────────── +step 8 "verifying pod" +POD=$(kubectl -n "$APP_NS" get pods -l niphas.io/workload=test-app \ + -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) +[ -n "$POD" ] || fail "no pod found" + +STORE_PATH=$(kubectl -n "$APP_NS" get niphasworkload test-app \ + -o jsonpath='{.status.storePath}' 2>/dev/null || echo "?") +CLOSURE_SIZE=$(kubectl -n "$APP_NS" get niphasworkload test-app \ + -o jsonpath='{.status.closurePaths}' 2>/dev/null | tr ',' '\n' | wc -l) + +ok "pod: $POD" +ok "store path: $STORE_PATH" +ok "closure size: $CLOSURE_SIZE paths" + +# ── 9. nix store mount ────────────────────────────────── +step 9 "checking /nix/store inside container" +MOUNT_COUNT=$(kubectl -n "$APP_NS" exec "$POD" -- ls /nix/store/ 2>/dev/null | wc -l) +ok "$MOUNT_COUNT store paths mounted via CSI" + +# ── 10. http test ─────────────────────────────────────── +step 10 "testing HTTP service" +kubectl -n "$APP_NS" port-forward "svc/test-app" "$PORT:80" &>/dev/null & PF_PID=$! sleep 3 -HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:18080/ 2>/dev/null || echo "000") -kill $PF_PID 2>/dev/null || true - -if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "403" ]; then - # darkhttpd returns 403 for empty dir listing (no index.html), which is fine - ok "HTTP service responding (status: $HTTP_CODE)" -else - fail "HTTP service not responding (status: $HTTP_CODE)" -fi +HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/" 2>/dev/null || echo "000") +BODY=$(curl -s "http://localhost:$PORT/" 2>/dev/null | head -1 || true) +kill "$PF_PID" 2>/dev/null || true -# ── Step 13: Show workload status ─────────────────────── -info "Final workload status:" -kubectl -n "$E2E_NS" get niphasworkload test-darkhttpd -o jsonpath='{.status}' | python3 -m json.tool 2>/dev/null || \ - kubectl -n "$E2E_NS" get niphasworkload test-darkhttpd -o yaml | grep -A 30 "^status:" +[ "$HTTP_CODE" = "200" ] || fail "HTTP $HTTP_CODE (expected 200)" +ok "HTTP 200 — page served from /nix/store" +info "open http://localhost:$PORT to see the page (port-forward stopped)" +# ── done ──────────────────────────────────────────────── echo "" -ok "═══════════════════════════════════════════════" -ok " FULL E2E TEST PASSED" -ok " flake eval → closure → CSI mount → service" -ok "═══════════════════════════════════════════════" +echo -e "${bold}${green}" +echo " ┌─────────────────────────────────────────┐" +echo " │ all checks passed │" +echo " │ │" +echo " │ flake eval ✓ │" +echo " │ closure resolve ✓ │" +echo " │ CSI NAR fetch ✓ │" +echo " │ pod mount ✓ │" +echo " │ HTTP service ✓ │" +echo " └─────────────────────────────────────────┘" +echo -e "${reset}" diff --git a/hack/test-workload.yaml b/hack/test-workload.yaml index 69b2fff..cc94747 100644 --- a/hack/test-workload.yaml +++ b/hack/test-workload.yaml @@ -1,15 +1,11 @@ apiVersion: niphas.io/v1alpha1 kind: NiphasWorkload metadata: - name: test-darkhttpd + name: test-app namespace: niphas-e2e spec: - flakeRef: github:nixos/nixpkgs - attribute: legacyPackages.x86_64-linux.darkhttpd - args: - - "/tmp" - - "--port" - - "8080" + flakeRef: github:fullzer4/niphas?dir=hack/test-app + attribute: packages.x86_64-linux.default replicas: 1 ports: - name: http @@ -25,12 +21,12 @@ spec: readinessProbe: tcpSocket: port: 8080 - initialDelaySeconds: 5 - periodSeconds: 5 + initialDelaySeconds: 3 + periodSeconds: 3 resources: requests: cpu: 50m memory: 32Mi limits: cpu: 200m - memory: 128Mi + memory: 64Mi From ad9b42b79800d1f866362771237742d2a97ae010 Mon Sep 17 00:00:00 2001 From: fullzer4 Date: Tue, 16 Jun 2026 16:08:19 -0300 Subject: [PATCH 21/24] feat: add VitePress docs site with Nix build integration - Add VitePress project in docs/ with architecture, components, and reference sections - Add nix/docs.nix flake-parts module (buildNpmPackage, darkhttpd wrapper, OCI image) - Add technical documentation .md files at root for GitHub browsing - Add nodejs to devShell for local docs development - Recreate hack/kind-config.yaml for CI - Remove old hack/test-app and e2e scripts (replaced by proper infra) - Update .gitignore for VitePress artifacts --- .gitignore | 3 + ARCHITECTURE.md | 447 +++++ CRD_REFERENCE.md | 302 ++++ CSI_DRIVER.md | 344 ++++ DEPLOY_FLOW.md | 565 ++++++ EVAL.md | 544 ++++++ LICENSE | 190 +++ MESH_PROTOCOL.md | 688 ++++++++ NIX_WIRE.md | 846 +++++++++ OPERATOR.md | 524 ++++++ PROBLEM.md | 473 ++++++ RESILIENCE.md | 372 ++++ RUNTIME_MODEL.md | 384 +++++ SECURITY.md | 20 + SECURITY_DESIGN.md | 539 ++++++ TESTING.md | 723 ++++++++ docs/.vitepress/config.ts | 74 + docs/architecture/deploy-flow.md | 113 ++ docs/architecture/index.md | 64 + docs/architecture/runtime-model.md | 57 + docs/components/csi-driver.md | 94 + docs/components/eval.md | 111 ++ docs/components/index.md | 29 + docs/components/mesh-protocol.md | 94 + docs/components/operator.md | 102 ++ docs/index.md | 23 + docs/package-lock.json | 2550 ++++++++++++++++++++++++++++ docs/package.json | 13 + docs/reference/crd-reference.md | 121 ++ docs/reference/index.md | 25 + docs/reference/nix-wire.md | 101 ++ docs/reference/problem.md | 89 + docs/reference/resilience.md | 103 ++ docs/reference/security-design.md | 88 + docs/reference/security.md | 21 + docs/reference/testing.md | 93 + hack/test-app/flake.nix | 129 -- hack/test-e2e-real.sh | 176 -- hack/test-workload.yaml | 32 - nix/devShell.nix | 1 + nix/docs.nix | 55 + 41 files changed, 10985 insertions(+), 337 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 CRD_REFERENCE.md create mode 100644 CSI_DRIVER.md create mode 100644 DEPLOY_FLOW.md create mode 100644 EVAL.md create mode 100644 LICENSE create mode 100644 MESH_PROTOCOL.md create mode 100644 NIX_WIRE.md create mode 100644 OPERATOR.md create mode 100644 PROBLEM.md create mode 100644 RESILIENCE.md create mode 100644 RUNTIME_MODEL.md create mode 100644 SECURITY.md create mode 100644 SECURITY_DESIGN.md create mode 100644 TESTING.md create mode 100644 docs/.vitepress/config.ts create mode 100644 docs/architecture/deploy-flow.md create mode 100644 docs/architecture/index.md create mode 100644 docs/architecture/runtime-model.md create mode 100644 docs/components/csi-driver.md create mode 100644 docs/components/eval.md create mode 100644 docs/components/index.md create mode 100644 docs/components/mesh-protocol.md create mode 100644 docs/components/operator.md create mode 100644 docs/index.md create mode 100644 docs/package-lock.json create mode 100644 docs/package.json create mode 100644 docs/reference/crd-reference.md create mode 100644 docs/reference/index.md create mode 100644 docs/reference/nix-wire.md create mode 100644 docs/reference/problem.md create mode 100644 docs/reference/resilience.md create mode 100644 docs/reference/security-design.md create mode 100644 docs/reference/security.md create mode 100644 docs/reference/testing.md delete mode 100644 hack/test-app/flake.nix delete mode 100755 hack/test-e2e-real.sh delete mode 100644 hack/test-workload.yaml create mode 100644 nix/docs.nix diff --git a/.gitignore b/.gitignore index 8f1517b..4d2082c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ result-* *.swp *.swo *~ +docs/.vitepress/dist/ +docs/.vitepress/cache/ +docs/node_modules/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..abb4b46 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,447 @@ +# Niphas – Architecture & Project Structure + +## Directory Layout + +``` +niphas/ +├── Cargo.toml # Workspace root +├── Cargo.lock # Shared lockfile +├── flake.nix # Nix flake (crane-based) +├── flake.lock +├── rust-toolchain.toml # Pin Rust version +├── .cargo/ +│ └── config.toml # Cargo build settings +├── deny.toml # cargo-deny (licenses, advisories) +├── proto/ +│ └── csi/ +│ └── v1/ +│ └── csi.proto # CSI spec protobuf (vendored) +├── crates/ +│ ├── niphas-core/ # Shared library +│ │ └── src/ +│ │ ├── lib.rs +│ │ ├── crd.rs # NiphasWorkload CRD (kube-derive) +│ │ ├── nix/ +│ │ │ ├── mod.rs +│ │ │ ├── nar.rs # NAR archive parser + extractor +│ │ │ ├── narinfo.rs # .narinfo text parser +│ │ │ ├── hash.rs # Nix hashing (SHA-256, Nix-base32) +│ │ │ ├── signature.rs # Ed25519 signature verification +│ │ │ ├── store_path.rs # Store path parsing + validation +│ │ │ ├── cache_client.rs # Binary cache HTTP client +│ │ │ └── closure.rs # Recursive closure resolution +│ │ ├── config.rs # Shared config loading +│ │ ├── eval.rs # Eval request/response types + input validation +│ │ ├── error.rs # Error types (thiserror) +│ │ ├── telemetry.rs # Tracing init with opt-in OTEL (TelemetryGuard) +│ │ └── testutils.rs # Test fixtures (feature-gated) +│ ├── niphas-eval/ # Webhook binary (Nix eval via subprocess) +│ │ └── src/ +│ │ ├── main.rs +│ │ ├── handlers.rs # Axum route handlers +│ │ ├── evaluator.rs # Nix eval subprocess + closure resolution +│ │ ├── allowlist.rs # Flake origin validation +│ │ └── error.rs # Eval-specific error types +│ ├── niphas-operator/ # Operator binary +│ │ └── src/ +│ │ ├── main.rs +│ │ ├── reconciler.rs # Reconciler for NiphasWorkload +│ │ ├── resources.rs # K8s resource construction (Deployment, Service, etc.) +│ │ ├── context.rs # Shared operator context (kube client, config) +│ │ ├── eval.rs # Eval webhook client +│ │ ├── health.rs # Health/readiness probes (Axum) +│ │ └── error.rs # Operator-specific error types +│ ├── niphas-csi/ # CSI driver binary +│ │ ├── build.rs # tonic-build for CSI proto +│ │ └── src/ +│ │ ├── main.rs +│ │ ├── identity.rs # CSI Identity service +│ │ ├── node.rs # CSI Node service (mount/unmount) +│ │ ├── mount.rs # Nix closure mount logic +│ │ └── cache.rs # NAR cache management +│ └── niphas-mesh/ # P2P binary (early stage) +│ └── src/ +│ └── main.rs +└── manifests/ # K8s manifests + ├── crd.yaml # Generated from niphas-core + ├── operator.yaml + ├── csi-driver.yaml + └── eval-webhook.yaml +``` + +## Workspace Cargo.toml + +```toml +[workspace] +resolver = "2" +members = [ + "crates/niphas-core", + "crates/niphas-eval", + "crates/niphas-operator", + "crates/niphas-csi", + "crates/niphas-mesh", +] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +repository = "https://github.com/fullzer4/niphas" +rust-version = "1.85" + +[workspace.dependencies] +# Kubernetes +kube = { version = "3.1", features = ["runtime", "derive", "client"] } +k8s-openapi = { version = "0.27", features = ["latest", "schemars"] } +kube-runtime = "3.1" +kube-leader-election = "0.43" + +# Async +tokio = { version = "1.52", features = ["rt-multi-thread", "net", "time", "signal", "macros", "process"] } + +# Web +axum = "0.8" +tower = "0.5" +tower-http = { version = "0.6", features = ["trace"] } + +# gRPC (CSI) +tonic = "0.14" +tonic-build = "0.14" +tonic-prost-build = "0.14" +tonic-prost = "0.14" +prost = "0.14" +prost-types = "0.14" + +# P2P +libp2p = { version = "0.56", features = [ + "tcp", "quic", "noise", "yamux", + "dns", "identify", "gossipsub", "request-response", + "tokio", "macros", +] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" + +# Zero-copy (P2P protocol) +rkyv = "0.8" + +# Cryptography / hashing +sha2 = "0.10" +ed25519-dalek = { version = "2", features = ["pkcs8"] } +base64 = "0.22" + +# HTTP client +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "gzip"] } + +# Error handling +thiserror = "2" +anyhow = "1" + +# Observability +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "registry"] } +tracing-opentelemetry = "0.30" +opentelemetry = "0.29" +opentelemetry_sdk = { version = "0.29", features = ["rt-tokio"] } +opentelemetry-otlp = { version = "0.29", features = ["grpc-tonic", "trace", "logs"] } +opentelemetry-appender-tracing = "0.29" + +# Schema / CRD +schemars = "1" +garde = { version = "0.23", features = ["derive", "serde"] } + +# Config +figment = { version = "0.10", features = ["env", "toml", "yaml"] } + +# Allocator +tikv-jemallocator = { version = "0.7", features = ["profiling", "stats"] } + +# Performance primitives +smallvec = "1" +compact_str = "0.9" +bumpalo = { version = "3", features = ["collections"] } +memmap2 = "0.9" +bytes = "1" + +# Object pooling +lockfree-object-pool = "0.1" + +# Async utilities +futures = "0.3" +async-compression = { version = "0.4", features = ["tokio", "zstd", "xz", "bzip2"] } + +# Time +time = { version = "0.3", features = ["formatting"] } + +# Internal +niphas-core = { path = "crates/niphas-core" } +``` + +## niphas-core (shared crate) + +Contains everything that two or more binaries need: + +| Module | Contents | Consumers | +|---|---|---| +| `crd.rs` | `NiphasWorkload` CRD struct (`#[derive(CustomResource)]`) | operator, eval, CSI | +| `nix/nar.rs` | NAR archive streaming parser + extractor | CSI, mesh | +| `nix/narinfo.rs` | `.narinfo` text parser | CSI, mesh, eval | +| `nix/hash.rs` | Nix hashing (SHA-256, Nix-base32 encoding) | CSI, mesh, eval | +| `nix/signature.rs` | Ed25519 NAR signature verification | CSI, mesh | +| `nix/store_path.rs` | `StorePath` parsing + validation | all | +| `nix/cache_client.rs` | Binary cache HTTP client | CSI, eval | +| `nix/closure.rs` | Recursive closure resolution via HTTP | eval | +| `eval.rs` | Eval request/response types, input validation (flake_ref, attribute, revision) | operator, eval | +| `error.rs` | `NiphasError` enum (thiserror) | all | +| `config.rs` | Shared config loading (figment) | all | +| `telemetry.rs` | `init_tracing()` with opt-in OTEL (traces + logs), `TelemetryGuard` | all | +| `testutils.rs` | Test fixtures: `WorkloadBuilder`, `FakeCacheClient` (feature-gated) | all (dev) | + +Uses a `runtime` feature flag so lightweight consumers (CSI, mesh) can depend on it without pulling in `kube-runtime`: + +```toml +[features] +default = [] +runtime = ["kube/runtime"] +``` + +## CRD Definition (crd.rs) + +```rust +#[derive(CustomResource, Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[kube( + group = "niphas.io", + version = "v1alpha1", + kind = "NiphasWorkload", + namespaced, + status = "NiphasWorkloadStatus", +)] +pub struct NiphasWorkloadSpec { + pub flake_ref: String, + pub attribute: String, + pub replicas: Option, +} + +pub struct NiphasWorkloadStatus { + pub observed_generation: Option, // detect desync + pub phase: String, + pub store_path: Option, + pub closure_paths: Option>, // full closure for rescheduling + pub ready_replicas: Option, + pub conditions: Option>, +} + +pub struct NiphasCondition { + pub type_: String, // Evaluated, ClosureCached, Available, Progressing, Degraded + pub status: String, // True, False, Unknown + pub reason: String, // EvalSucceeded, NarFetchFailed, ReplicasReady + pub message: String, + pub last_transition_time: String, + pub observed_generation: Option, +} +``` + +`closure_paths` is critical for resilience: when a pod is rescheduled to a new +node, the CSI driver knows exactly which store paths to fetch without depending +on `.narinfo` from an external cache. The CSI driver checks local cache first, +then mesh peers (if available), then binary cache HTTP. See `docs/RESILIENCE.md`. + +## Per-Crate Key Dependencies + +### niphas-eval +`niphas-core` (runtime), `kube`, `kube-runtime`, `k8s-openapi`, `axum`, `tower`, `tokio`, `serde`, `tracing`, `anyhow`, `reqwest` (closure resolution via binary cache HTTP). Nix evaluation runs via `tokio::process::Command("nix")` subprocess with sandbox flags. Future: in-process FFI via Nix C API. + +### niphas-operator +`niphas-core` (runtime), `kube`, `kube-runtime`, `k8s-openapi`, `kube-leader-election` (Lease-based leader election), `axum` (health probes), `tokio`, `serde`, `tracing`, `anyhow`, `reqwest` (eval webhook client). + +### niphas-csi +`niphas-core`, `tonic`, `prost`, `prost-types`, `tokio`, `tracing`, `anyhow`, `reqwest` (binary cache fallback), `sha2`, `ed25519-dalek`, `async-compression` (zstd/xz/bzip2 decompression). +Build dep: `tonic-build`, `tonic-prost-build` (compiles vendored CSI proto). + +### niphas-mesh +`niphas-core`, `libp2p`, `tokio`, `tracing`, `anyhow`, `serde`, `async-compression` (zstd, NAR streaming), `futures`. Early stage -- only `main.rs` exists currently. + +## Nix Build (flake.nix with Crane) + +Stack: **rust-overlay + crane + flake-utils + nix-direnv** + +Crane's two-phase strategy: +1. `buildDepsOnly` – builds all workspace dependencies (cacheable via Cachix/Attic) +2. `buildPackage` per crate – builds only source code against cached deps + +Each crate produces a binary and an OCI image (`dockerTools.buildLayeredImage`). + +The devShell includes: `kubectl`, `helm`, `cargo-deny`, `cargo-nextest`, `cargo-watch`, `protobuf`. + +## Key Design Decisions + +**Axum over actix-web** – tower middleware is composable and shared with kube-rs's own tower-based client. Axum is the framework used in kube-rs examples. + +**Proto vendoring** – CSI protobuf lives in `proto/csi/` rather than being fetched at build time. Nix builds are sandboxed (no network access). Alternative: use the `k8s-csi` crate for pre-generated bindings. + +**libp2p feature selection** – only `tcp`, `quic`, `noise`, `yamux`, `gossipsub`, `request-response`, `stream`. The full feature set pulls a massive dep tree. `stream` gives raw `AsyncRead`/`AsyncWrite` substreams for bulk NAR transfers. + +**Workspace dependency inheritance** – every dep used by 2+ crates goes in `[workspace.dependencies]`. Members use `dep.workspace = true`. Prevents version drift. + +**Single Cargo.lock** – all crates build against identical dependency versions. + +## Observability + +### Structured logging + +All binaries emit JSON-structured logs on stdout via `tracing-subscriber`. Log level +is controlled by `RUST_LOG` env var (default: `info`). Every log line includes +timestamp, level, target, span context, and structured fields. + +### OpenTelemetry (opt-in) + +Telemetry is initialized in `niphas-core/src/telemetry.rs` via `init_tracing()`. +OTEL support is **opt-in** -- controlled entirely by standard OTEL SDK env vars. +Without them, behavior is identical to plain JSON logging with zero OTEL overhead. + +| Env var | Effect | +|---------|--------| +| (none) | JSON logs on stdout only | +| `OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4317` | Adds OTLP trace exporter (spans) + OTLP log exporter | +| `OTEL_SERVICE_NAME=custom-name` | Overrides the default service name in the OTEL resource | +| `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf` | Uses HTTP instead of gRPC for OTLP export | + +Any env var documented in the [OTEL SDK specification](https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/) is supported. + +### Architecture + +``` +tracing-subscriber::registry + | + +-- fmt::layer().json() (always: JSON logs on stdout) + +-- OpenTelemetryLayer (opt-in: OTLP trace spans) + +-- OpenTelemetryTracingBridge (opt-in: OTLP log export) +``` + +`init_tracing()` returns a `TelemetryGuard` that each binary holds until shutdown. +The guard flushes pending spans and logs on drop via `SdkTracerProvider::shutdown()` +and `SdkLoggerProvider::shutdown()`, preventing data loss on graceful exit. + +```rust +// Every main.rs: +let _telemetry = niphas_core::telemetry::init_tracing("niphas-operator"); +// guard lives until end of main, ensuring flush +``` + +### Distributed tracing + +With OTEL enabled, every reconciliation, eval request, and CSI mount operation +produces trace spans that propagate across component boundaries. This enables +end-to-end visibility: operator -> eval webhook -> closure resolution -> CSI mount. + +W3C TraceContext propagation between operator and eval can be added via +`tower-http` tracing layer + `opentelemetry-http` (future enhancement). + +### Metrics (future) + +The health server in each binary can expose a `/metrics` endpoint for Prometheus +scraping via `opentelemetry-prometheus`. The OTEL layer architecture supports +adding a metrics layer without changing the existing setup. + +## Input Validation + +### Threat: Nix expression injection + +The eval service constructs Nix expressions by interpolating user-provided fields +(`flake_ref`, `attribute`, `revision`) into a `format!()` string that is passed +to `nix eval`. Without validation, characters like `"`, `\`, `$`, `;`, `(`, `)` +can break out of the string literal and inject arbitrary Nix code. + +```rust +// evaluator.rs -- the interpolation point: +let expr = format!( + r#"let drv = (builtins.getFlake "{pinned_ref}").{attribute}; in ..."# +); +``` + +The flake allowlist (glob matching) validates the **pattern** of the flake ref +but does not reject dangerous characters. A ref like +`github:myorg/app" ++ builtins.readFile /etc/shadow ++ "` could pass a +`github:myorg/*` glob and still inject code. + +### Defense: strict input validation + +Validation functions in `niphas-core/src/eval.rs` enforce character-level +restrictions before any interpolation happens: + +| Field | Regex | Rejects | +|-------|-------|---------| +| `flake_ref` | `^[a-zA-Z][a-zA-Z0-9+\-\.]*:[a-zA-Z0-9/_\-\.]+$` | `"`, `\`, `$`, `;`, `(`, `)`, spaces, unicode | +| `attribute` | `^[a-zA-Z_][a-zA-Z0-9_\-]*(\.[a-zA-Z_][a-zA-Z0-9_\-]*)*$` | Anything that isn't a valid Nix attribute path | +| `revision` | `^[a-fA-F0-9]{6,40}$` | Non-hex characters, wrong length | + +These run **before** the allowlist check and **before** any string interpolation. +Rejection at this layer is a 400 Bad Request, not a security event. + +### Defense layers (eval pipeline) + +``` +1. Input validation -- reject malformed flake_ref / attribute / revision +2. Flake allowlist -- reject refs not matching configured patterns +3. Nix sandbox -- restrict_eval=true, IFD=false, max_jobs=0 +4. Container isolation -- non-root, no capabilities, read-only rootfs +5. Network policy -- egress limited to git HTTPS + DNS only +``` + +See [`docs/SECURITY_DESIGN.md`](docs/SECURITY_DESIGN.md) for the full 4-layer +defense model on eval sandboxing. + +## Leader Election + +The operator must run with a single active reconciler to avoid duplicate eval +calls, SSA conflicts, and race conditions when multiple replicas process the +same `NiphasWorkload` simultaneously. + +### Mechanism + +Lease-based leader election via `coordination.k8s.io/v1` Lease object. Only the +leader runs the `Controller` reconciliation loop; standby replicas wait and +monitor the lease. + +| Parameter | Value | Rationale | +|-----------|-------|-----------| +| Lease TTL | 15s | Time before a dead leader is considered gone | +| Renew interval | 5s | Leader heartbeat frequency | +| Retry interval | 5s | Non-leader polling frequency | + +Failover takes at most ~15 seconds. Reconciliation is idempotent (SSA-based), +so brief dual-leader windows during transition are safe. + +### RBAC + +The operator ServiceAccount requires a namespace-scoped Role for Lease +management in `niphas-system`: + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: niphas-operator-leader-election + namespace: niphas-system +rules: + - apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +``` + +### Readiness integration + +The `/readyz` endpoint returns 200 only after leader election is resolved and +the controller is actively watching. During standby, the pod reports not-ready +so the health Service does not route traffic to it. + +See [`docs/OPERATOR.md`](docs/OPERATOR.md) for the full operator design +including reconciliation state machine, error handling, and watches. +See [`docs/RESILIENCE.md`](docs/RESILIENCE.md) for failure scenarios and +recovery timelines. + +## CRD Generation + +A small binary or example in `niphas-core` that outputs `serde_yaml::to_string(&NiphasWorkload::crd())` keeps `manifests/crd.yaml` in sync with the Rust types. Wire into CI. diff --git a/CRD_REFERENCE.md b/CRD_REFERENCE.md new file mode 100644 index 0000000..7f90269 --- /dev/null +++ b/CRD_REFERENCE.md @@ -0,0 +1,302 @@ +# Niphas -- CRD Reference + +Single source of truth for the `NiphasWorkload` custom resource. + +```yaml +apiVersion: niphas.io/v1alpha1 +kind: NiphasWorkload +``` + +## Spec + +### Core fields (required) + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `flakeRef` | `string` | yes | Flake reference. E.g. `github:myorg/myapp` | +| `attribute` | `string` | yes | Flake output attribute. E.g. `packages.x86_64-linux.default` | + +### Core fields (optional) + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `revision` | `string` | latest | Git revision to pin. E.g. `a1b2c3d4e5f6` | +| `replicas` | `i32` | `1` | Number of pod replicas | +| `binaryCache` | `string` | from ConfigMap | Override binary cache URL for this workload | + +### Container fields + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `command` | `Vec` | auto-detected | Override entrypoint. If omitted, resolved from `meta.mainProgram` during eval | +| `args` | `Vec` | none | Arguments passed to the command | +| `env` | `Vec` | none | Environment variables. Same schema as K8s `container.env` | +| `ports` | `Vec` | none | Exposed ports. Same schema as K8s `container.ports` | + +### Resource management + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `resources` | `ResourceRequirements` | none | CPU/memory requests and limits. Same schema as K8s | + +### Health checks + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `livenessProbe` | `Probe` | none | Same schema as K8s `container.livenessProbe` | +| `readinessProbe` | `Probe` | none | Same schema as K8s `container.readinessProbe` | +| `startupProbe` | `Probe` | none | Same schema as K8s `container.startupProbe` | + +### Scheduling + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `nodeSelector` | `Map` | none | Additional node selector labels (merged with `niphas.io/store=true`) | +| `tolerations` | `Vec` | failover tolerations | Extra tolerations. The operator always injects `not-ready`/`unreachable` tolerations (30s) | +| `affinity` | `Affinity` | none | Pod affinity/anti-affinity rules | + +### Service exposure + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `service` | `ServiceSpec` | none | If set, the operator creates a Service with `ownerReferences` | +| `ingress` | `IngressSpec` | none | If set, the operator creates an Ingress with `ownerReferences` | + +#### ServiceSpec + +```yaml +service: + type: ClusterIP # ClusterIP, NodePort, LoadBalancer + ports: + - name: http + port: 80 + targetPort: http # name or number +``` + +#### IngressSpec + +```yaml +ingress: + enabled: true + className: nginx + hosts: + - host: myapp.example.com + paths: + - path: / + pathType: Prefix + port: http + tls: + - secretName: myapp-tls + hosts: + - myapp.example.com +``` + +### Extra volumes + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `extraVolumes` | `Vec` | none | Additional K8s volumes (ConfigMaps, Secrets, etc.) | +| `extraVolumeMounts` | `Vec` | none | Mount points for extra volumes | + +### Multi-architecture + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `architectures` | `Vec` | none | If set, evaluate per-architecture. Uses `{arch}` template in `attribute`. E.g. `["x86_64", "aarch64"]` | + +When `architectures` is set: +- The `attribute` field must contain `{arch}` placeholder +- Operator evaluates once per architecture +- Creates separate Deployments per architecture with `nodeSelector` +- All Deployments share the label `niphas.io/workload: ` +- A single Service selects all architectures via the shared label + +--- + +## Status + +All status fields are set by the operator. Users do not write to status. + +### Top-level status + +| Field | Type | Description | +|-------|------|-------------| +| `observedGeneration` | `i64` | The `metadata.generation` most recently processed. If `< generation`, the operator hasn't processed the latest spec change | +| `phase` | `string` | High-level state. One of: `Pending`, `Evaluating`, `Provisioning`, `Running`, `Failed` | +| `storePath` | `string` | Resolved Nix store path. E.g. `/nix/store/abc123-myapp-1.0.0` | +| `resolvedCommand` | `string` | The actual entrypoint. E.g. `/nix/store/abc123-myapp-1.0.0/bin/myapp` | +| `closurePaths` | `Vec` | Full closure (root + all transitive references). CSI uses this to fetch without depending on binary cache `.narinfo` | +| `lastEval` | `string` | ISO 8601 timestamp of last successful evaluation | +| `readyReplicas` | `i32` | Number of pods in Ready state | +| `conditions` | `Vec` | Standard K8s-style conditions | + +### Multi-architecture status + +When `spec.architectures` is set, status includes per-arch details: + +| Field | Type | Description | +|-------|------|-------------| +| `architectures` | `Vec` | Per-architecture status | + +```yaml +status: + architectures: + - arch: x86_64 + storePath: "/nix/store/abc123-myapp-1.0.0" + resolvedCommand: "/nix/store/abc123-myapp-1.0.0/bin/myapp" + readyReplicas: 3 + - arch: aarch64 + storePath: "/nix/store/xyz789-myapp-1.0.0" + resolvedCommand: "/nix/store/xyz789-myapp-1.0.0/bin/myapp" + readyReplicas: 3 + readyReplicas: 6 # sum of all archs +``` + +### Phase lifecycle + +``` +Pending --> Evaluating --> Provisioning --> Running + | | | + v v v + Failed Failed Failed +``` + +| Phase | Meaning | +|-------|---------| +| `Pending` | CRD created, operator has not started reconciliation | +| `Evaluating` | Operator called eval webhook, waiting for result | +| `Provisioning` | Eval succeeded, operator is creating child resources (Deployment, Service, etc.) | +| `Running` | At least one replica is Ready | +| `Failed` | Eval failed, NAR not found in cache, or child resource creation failed | + +Transitions back to `Evaluating` happen when `observedGeneration < generation` +(spec was updated). + +### Condition types + +| Type | True | False | +|------|------|-------| +| `Evaluated` | Nix eval succeeded, store path resolved | Eval failed, pending, or flake not allowed | +| `ClosureCached` | All closure paths available in binary cache | Fetch failed or pending | +| `Available` | >= 1 replica running and Ready | No replicas ready | +| `Progressing` | Rollout in progress (new spec being applied) | Stable | +| `Degraded` | Partial failure (some replicas down, NAR corrupted, etc.) | Everything healthy | + +#### Condition struct + +```yaml +conditions: + - type: Evaluated + status: "True" # "True", "False", "Unknown" + reason: EvalSucceeded # machine-readable + message: "store path resolved: /nix/store/abc123-myapp-1.0.0" + lastTransitionTime: "2026-06-05T12:00:00Z" + observedGeneration: 3 # generation when this condition was set +``` + +#### Reason values + +| Condition | Reason (True) | Reason (False) | +|-----------|--------------|----------------| +| `Evaluated` | `EvalSucceeded` | `EvalFailed`, `FlakeNotAllowed`, `EvalTimeout` | +| `ClosureCached` | `CacheVerified` | `StorePathNotCached`, `NarFetchFailed` | +| `Available` | `ReplicasReady` | `NoReplicasReady` | +| `Progressing` | `RolloutInProgress` | `RolloutComplete` | +| `Degraded` | `PartialFailure`, `NarCorrupted`, `NarSignatureVerificationFailed` | `Healthy` | + +--- + +## Validation rules + +| Field | Rule | +|-------|------| +| `flakeRef` | Must match `^[a-zA-Z][a-zA-Z0-9+.-]*:.+$` (valid flake reference syntax) | +| `attribute` | Must not be empty | +| `replicas` | >= 0 | +| `revision` | If set, must match `^[a-f0-9]{6,40}$` (git short or full SHA) | +| `ports[].containerPort` | 1-65535, unique within the array | +| `architectures[]` | Each must be a valid Nix system arch: `x86_64`, `aarch64`, `i686`, `armv7l` | +| `architectures` + `attribute` | If `architectures` is set, `attribute` must contain `{arch}` | + +## Defaults injected by the operator + +The operator merges these into the generated Deployment spec: + +| What | Value | Source | +|------|-------|--------| +| `nodeSelector` | `niphas.io/store: "true"` | Always injected, merged with `spec.nodeSelector` | +| `tolerations` | `not-ready`/`unreachable` with 30s | Always injected for fast failover | +| `topologySpreadConstraints` | hostname spread (hard), zone spread (soft) | Injected for replicas >= 2 | +| `labels` | `niphas.io/workload: ` | On all pods, for Service selection | +| `volumes` | CSI inline volume for `/nix/store` | Always, from closure data | +| `volumeMounts` | `/nix/store` (read-only) | Always | +| `image` | `ghcr.io/fullzer4/niphas-runner:latest` | Stub image, overridable | +| `command` | `[resolvedCommand]` | From eval result | + +## Labels and annotations set by the operator + +### On child resources (Deployment, Service, Ingress, PDB) + +```yaml +labels: + niphas.io/workload: + niphas.io/managed-by: niphas-operator + app.kubernetes.io/name: + app.kubernetes.io/managed-by: niphas +annotations: + niphas.io/store-path: /nix/store/abc123-myapp-1.0.0 + niphas.io/flake-ref: github:myorg/myapp + niphas.io/revision: a1b2c3d4e5f6 +``` + +### On pods + +```yaml +labels: + niphas.io/workload: # for Service selection + niphas.io/store-hash: abc123 # short hash for identification +``` + +## ownerReferences + +All child resources have `ownerReferences` pointing to the NiphasWorkload: + +```yaml +ownerReferences: + - apiVersion: niphas.io/v1alpha1 + kind: NiphasWorkload + name: + uid: + controller: true + blockOwnerDeletion: true +``` + +K8s garbage collection automatically deletes child resources when the +NiphasWorkload is deleted (after finalizer cleanup). + +## Print columns (kubectl output) + +``` +$ kubectl get niphasworkloads +NAME FLAKE PHASE READY +myapp github:myorg/myapp Running 3 +hello github:NixOS/nixpkgs Running 1 +``` + +Defined via `printcolumn` in the CRD derive: + +| Column | JSONPath | +|--------|----------| +| Flake | `.spec.flakeRef` | +| Phase | `.status.phase` | +| Ready | `.status.readyReplicas` | + +## Short names + +```yaml +shortNames: + - nw + - niphas +``` + +`kubectl get nw` is equivalent to `kubectl get niphasworkloads`. diff --git a/CSI_DRIVER.md b/CSI_DRIVER.md new file mode 100644 index 0000000..f6f42f1 --- /dev/null +++ b/CSI_DRIVER.md @@ -0,0 +1,344 @@ +# niphas-csi -- CSI Driver Design + +## Why CSI + +CSI (Container Storage Interface) is the official K8s storage abstraction. +It is the only approach that: + +- Follows K8s storage standards (no hostPath, no node mutation) +- Works under Pod Security Standards Restricted +- Functions on all managed K8s (EKS, GKE, AKS, OpenShift) +- Lets kubelet manage the volume lifecycle correctly +- Supports topology-aware scheduling + +## Architecture + +niphas-csi is a **Node-only plugin**. No Controller component. No PV/PVC. +It uses **CSI Ephemeral Inline Volumes** (GA since K8s 1.25). + +``` +pod spec + volumes: + - csi: + driver: niphas.io.csi + volumeAttributes: + storePath: "/nix/store/abc123-hello-2.12.1" + binaryCacheUrl: "https://cache.nixos.org" + +kubelet sees driver: niphas.io.csi + --> connects to /var/lib/kubelet/plugins/niphas.io.csi/csi.sock + --> calls NodePublishVolume(volume_id, target_path, volume_context) + +niphas-csi driver + --> checks node-local cache at /var/lib/niphas/cache/abc123-hello-2.12.1/ + --> if cache miss: fetches .narinfo from binary cache (HTTP) + --> downloads and extracts NAR to cache dir + --> bind-mounts cache dir --> target_path (read-only) + --> returns success + +kubelet starts pod containers with volume mounted + +pod deletion + --> kubelet calls NodeUnpublishVolume + --> driver unmounts bind mount + --> cached NAR data stays for reuse by other pods +``` + +## gRPC Services + +CSI v1.12.0 defines 3 gRPC services. niphas-csi only implements Identity + Node. + +### Identity Service (3 RPCs, all required) + +| RPC | What niphas-csi returns | +|-----|------------------------| +| `GetPluginInfo` | name=`niphas.io.csi`, version from Cargo.toml | +| `GetPluginCapabilities` | empty (no CONTROLLER_SERVICE) | +| `Probe` | ready=true | + +### Node Service (4 RPCs implemented, 4 return UNIMPLEMENTED) + +| RPC | Status | Purpose | +|-----|--------|---------| +| `NodePublishVolume` | Implemented | Fetch NAR, extract, bind-mount | +| `NodeUnpublishVolume` | Implemented | Unmount bind mount | +| `NodeGetCapabilities` | Implemented | Returns empty capabilities | +| `NodeGetInfo` | Implemented | Returns node hostname | +| `NodeStageVolume` | UNIMPLEMENTED | Not needed (no staging) | +| `NodeUnstageVolume` | UNIMPLEMENTED | Not needed | +| `NodeGetVolumeStats` | UNIMPLEMENTED | Not needed | +| `NodeExpandVolume` | UNIMPLEMENTED | Not needed | + +### Controller Service + +Not implemented. Not needed for ephemeral inline volumes. + +**Total RPCs to implement: 7** (3 Identity + 4 Node). + +## Volume Lifecycle + +### Pod creation + +1. Scheduler places pod on node +2. Kubelet reads pod spec, sees `driver: niphas.io.csi` +3. Kubelet checks `CSIDriver` object: `attachRequired: false`, skips attach +4. Kubelet generates `volume_id` (e.g. `csi--`) +5. Kubelet calls `NodePublishVolume` with: + - `volume_id` + - `target_path`: `/var/lib/kubelet/pods//volumes/kubernetes.io~csi//mount` + - `volume_context`: `storePath`, `binaryCacheUrl`, pod metadata + - `readonly: true` +6. Driver fetches NAR (or hits cache), bind-mounts, returns OK +7. Kubelet starts containers + +### Pod deletion + +1. Kubelet calls `NodeUnpublishVolume(volume_id, target_path)` +2. Driver unmounts, returns OK +3. Cached data stays on disk for dedup + +### Idempotency + +Both `NodePublishVolume` and `NodeUnpublishVolume` must be idempotent: +- Publish on already-mounted volume: check mountpoint, return OK +- Unpublish on already-unmounted volume: return OK + +## Node-Local Cache and Deduplication + +The driver manages a cache at `/var/lib/niphas/cache/` on each node: + +``` +/var/lib/niphas/cache/ + abc123-hello-2.12.1/ # extracted NAR contents + def456-glibc-2.38/ # shared dependency + .tmp-xyz789/ # in-progress extraction (cleaned on crash) +``` + +### Dedup guarantees + +- **Same path, multiple pods**: first pod triggers download, others hit cache +- **Concurrent requests**: per-path mutex prevents duplicate downloads +- **Atomic population**: extract to `.tmp-*`, then `rename()` into place +- **Crash safety**: incomplete `.tmp-*` dirs are cleaned on driver startup +- **Content-addressed**: hash in path name means cache is always valid (no staleness) + +### Garbage collection + +Background task periodically: +1. Scans active mounts (ref counting) +2. Evicts unreferenced paths by LRU or disk pressure +3. Never evicts paths with active bind mounts + +## Closure Handling + +A Nix closure includes a package and all its transitive dependencies. + +Closure resolution happens in `niphas-eval` (not the CSI driver). The eval +webhook resolves the full closure by recursively fetching `.narinfo` files +from the binary cache (pure HTTP, no Nix needed) and writes the complete +list to `status.closurePaths` in the CRD. + +The operator passes the closure list to the CSI driver via `volumeAttributes`: + +```yaml +volumes: + - name: app-closure + csi: + driver: niphas.io.csi + volumeAttributes: + closurePaths: "/nix/store/abc123-hello,/nix/store/def456-glibc,..." + mountMode: "closure" +``` + +The CSI driver receives the pre-resolved closure list. It does NOT need +to resolve references itself. For each path in the list, it: + +1. Checks local cache (`/var/lib/niphas/cache//`) +2. If cache miss: fetches from mesh or binary cache +3. After all paths are cached: constructs a merged `/nix/store` view +4. Bind-mounts the merged view at `target_path` (read-only) + +This design keeps the CSI driver simple (no `.narinfo` parsing needed for +closure resolution) and avoids depending on binary cache availability +during mount -- the closure list is already known. + +## Serving on Unix Domain Socket + +The driver listens on a UDS, not TCP. kubelet communicates with CSI drivers +exclusively via Unix sockets. + +``` +/var/lib/kubelet/plugins/niphas.io.csi/csi.sock <-- driver socket +/var/lib/kubelet/plugins_registry/ <-- registrar creates reg socket here +``` + +The `node-driver-registrar` sidecar: +1. Connects to the driver socket +2. Calls `GetPluginInfo` + `NodeGetInfo` +3. Creates a registration socket in `/var/lib/kubelet/plugins_registry/` +4. kubelet's plugin watcher discovers the driver + +## CSIDriver K8s Object + +```yaml +apiVersion: storage.k8s.io/v1 +kind: CSIDriver +metadata: + name: niphas.io.csi +spec: + attachRequired: false + podInfoOnMount: true + volumeLifecycleModes: + - Ephemeral + fsGroupPolicy: None + requiresRepublish: false + seLinuxMount: false +``` + +| Field | Value | Reason | +|-------|-------|--------| +| `attachRequired` | false | No controller, kubelet calls Node directly | +| `podInfoOnMount` | true | Driver receives pod name/namespace/uid in volume_context | +| `volumeLifecycleModes` | Ephemeral | Inline volumes only (for now) | +| `fsGroupPolicy` | None | Nix store paths are immutable, no permission changes | + +## DaemonSet Deployment + +```yaml +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: niphas-csi-node + namespace: niphas-system +spec: + selector: + matchLabels: + app: niphas-csi-node + template: + metadata: + labels: + app: niphas-csi-node + spec: + serviceAccountName: niphas-csi-node + containers: + - name: niphas-csi + image: ghcr.io/fullzer4/niphas-csi:latest + securityContext: + privileged: true + env: + - name: NODE_NAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + volumeMounts: + - name: socket-dir + mountPath: /csi + - name: pods-mount-dir + mountPath: /var/lib/kubelet/pods + mountPropagation: Bidirectional + - name: niphas-cache + mountPath: /var/lib/niphas/cache + + - name: node-driver-registrar + image: registry.k8s.io/sig-storage/csi-node-driver-registrar:v2.13.0 + args: + - "--csi-address=/csi/csi.sock" + - "--kubelet-registration-path=/var/lib/kubelet/plugins/niphas.io.csi/csi.sock" + volumeMounts: + - name: socket-dir + mountPath: /csi + - name: registration-dir + mountPath: /registration + + - name: liveness-probe + image: registry.k8s.io/sig-storage/livenessprobe:v2.14.0 + args: + - "--csi-address=/csi/csi.sock" + - "--health-port=9898" + volumeMounts: + - name: socket-dir + mountPath: /csi + + volumes: + - name: socket-dir + hostPath: + path: /var/lib/kubelet/plugins/niphas.io.csi/ + type: DirectoryOrCreate + - name: pods-mount-dir + hostPath: + path: /var/lib/kubelet/pods + type: Directory + - name: registration-dir + hostPath: + path: /var/lib/kubelet/plugins_registry/ + type: Directory + - name: niphas-cache + hostPath: + path: /var/lib/niphas/cache + type: DirectoryOrCreate +``` + +### Key volume details + +| Volume | hostPath | Why | +|--------|----------|-----| +| `socket-dir` | `/var/lib/kubelet/plugins/niphas.io.csi/` | Driver socket, shared with registrar | +| `pods-mount-dir` | `/var/lib/kubelet/pods` | Where kubelet expects bind mounts. **Must have `mountPropagation: Bidirectional`** | +| `registration-dir` | `/var/lib/kubelet/plugins_registry/` | Registrar creates reg socket here for kubelet discovery | +| `niphas-cache` | `/var/lib/niphas/cache` | Node-local NAR cache, persists across DaemonSet restarts | + +### Why the DaemonSet uses hostPath but consumer pods don't + +The CSI DaemonSet needs hostPath to: +- Place the Unix socket where kubelet expects it +- Access kubelet's pod mount directory +- Maintain a persistent cache + +But **consumer pods don't use hostPath**. They use standard CSI inline volumes. +The CSI abstraction exists precisely to give privileged access to the driver +while keeping consumer pods unprivileged. + +## NAR Fetching + +The CSI driver fetches individual NARs via the fallback chain +(see [`RESILIENCE.md`](RESILIENCE.md)): + +1. Local cache (`/var/lib/niphas/cache//`) +2. Mesh P2P via Unix socket (`/var/run/niphas/mesh.sock`) +3. Binary cache HTTP (direct) + +For each store path in the closure list (received via `volumeAttributes`): + +1. Fetch `.narinfo` from binary cache +2. Verify Ed25519 signature +3. Download compressed NAR (`.nar.zst`) +4. Verify `FileHash` (compressed) +5. Decompress +6. Verify `NarHash` (uncompressed, computed inline during extraction) +7. Extract to temp dir with safety checks (CVE-2024-45593 mitigations) +8. Atomic rename to cache dir + +All NAR format parsing, hash verification, and signature checking is +implemented in-house in `niphas-core`. See [`NIX_WIRE.md`](NIX_WIRE.md) +for the complete specification. + +## Sidecars + +| Sidecar | Required | Image | Purpose | +|---------|----------|-------|---------| +| node-driver-registrar | Yes | `registry.k8s.io/sig-storage/csi-node-driver-registrar:v2.13.0` | Registers driver with kubelet | +| livenessprobe | Recommended | `registry.k8s.io/sig-storage/livenessprobe:v2.14.0` | Health monitoring via `Probe` RPC | +| csi-provisioner | No | - | Only for PV/PVC (not needed for ephemeral) | +| csi-attacher | No | - | Only for `attachRequired: true` | + +## Proto File + +Vendor CSI v1.12.0 proto from: +``` +https://github.com/container-storage-interface/spec/blob/v1.12.0/csi.proto +``` + +Place at `proto/csi/v1/csi.proto`. Build with `tonic-build` in `build.rs`. + +Proto depends on `google/protobuf/{descriptor,timestamp,wrappers}.proto`, +which prost/tonic-build bundle automatically. diff --git a/DEPLOY_FLOW.md b/DEPLOY_FLOW.md new file mode 100644 index 0000000..e4caab0 --- /dev/null +++ b/DEPLOY_FLOW.md @@ -0,0 +1,565 @@ +# Niphas -- Deploy Flow + +## User experience + +A user deploys a Nix flake on K8s with a single resource: + +```yaml +apiVersion: niphas.io/v1alpha1 +kind: NiphasWorkload +metadata: + name: myapp +spec: + flakeRef: "github:myorg/myapp" + attribute: "packages.x86_64-linux.default" +``` + +No Dockerfile, no registry, no image tags. The operator handles eval, +build, caching, mounting, service creation, and failure recovery. + +## Minimal deploy (3 lines) + +```yaml +apiVersion: niphas.io/v1alpha1 +kind: NiphasWorkload +metadata: + name: hello +spec: + flakeRef: "github:NixOS/nixpkgs" + attribute: "legacyPackages.x86_64-linux.hello" +``` + +This evaluates the flake, resolves the store path, creates a single-replica +Deployment, mounts the closure via CSI, and runs the `hello` binary. + +## Production deploy + +```yaml +apiVersion: niphas.io/v1alpha1 +kind: NiphasWorkload +metadata: + name: myapp + namespace: production +spec: + flakeRef: "github:myorg/myapp" + attribute: "packages.x86_64-linux.default" + revision: "a1b2c3d4e5f6" + replicas: 3 + + args: ["--port", "8080"] + + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: myapp-db + key: url + - name: LOG_LEVEL + value: "info" + + ports: + - name: http + containerPort: 8080 + - name: metrics + containerPort: 9090 + + resources: + requests: + cpu: "250m" + memory: "256Mi" + limits: + cpu: "1" + memory: "1Gi" + + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 10 + readinessProbe: + httpGet: + path: /ready + port: http + + service: + type: ClusterIP + ports: + - name: http + port: 80 + targetPort: http + + ingress: + enabled: true + className: nginx + hosts: + - host: myapp.example.com + paths: + - path: / + pathType: Prefix + port: http + tls: + - secretName: myapp-tls + hosts: + - myapp.example.com + + binaryCache: "https://cache.company.com" + + extraVolumes: + - name: config + configMap: + name: myapp-config + extraVolumeMounts: + - name: config + mountPath: /etc/myapp + readOnly: true +``` + +## CRD fields + +The NiphasWorkload replaces every Dockerfile concept: + +| Dockerfile | NiphasWorkload | +|-----------|----------------| +| `FROM` / image | `spec.flakeRef` + `spec.attribute` | +| `ENTRYPOINT` | `spec.command` (auto-detected from `meta.mainProgram` if omitted) | +| `CMD` | `spec.args` | +| `EXPOSE` | `spec.ports` | +| `ENV` | `spec.env` | +| `HEALTHCHECK` | `spec.livenessProbe` / `spec.readinessProbe` | + +Plus K8s-native fields: `resources`, `nodeSelector`, `tolerations`, +`affinity`, `service`, `ingress`, `extraVolumes`, `extraVolumeMounts`. + +## Build model: CI builds, cluster consumes + +Niphas does NOT build in-cluster. The cluster only evaluates and fetches. + +``` +CI (GitHub Actions, Hydra, etc.): + nix build .#packages.x86_64-linux.default + nix copy --to https://cache.company.com ./result +``` + +`nix eval` resolves the store path deterministically from the derivation +inputs (the `.drv` hash) without building. The path is pure math -- same +inputs always produce the same `/nix/store/-`. The cluster +runs `nix eval` to discover the path, then fetches the pre-built NAR +from the binary cache. + +If the store path is not found in any configured binary cache, the +workload fails with a clear error: + +``` +status.phase = "Failed" +status.conditions: + - type: Evaluated + status: "False" + reason: StorePathNotCached + message: "/nix/store/abc123-myapp-1.0.0 not found in any binary cache. Build in CI first." +``` + +This is intentional. In-cluster builds would execute arbitrary code with +network access, require Nix daemon privileges, and add massive complexity. +The CI is the build system. The cluster is the runtime. + +## Evaluation: in-process via Nix C API (no Jobs) + +niphas-eval does NOT spawn Jobs or shell out to `nix eval`. It calls the +Nix evaluator directly via C FFI using `nix-bindings-rust`, the same +approach devenv 2.0 uses in production. + +The Nix C API (`libexpr-c`, `libstore-c`, `libflake-c`) exposes evaluation +as library calls. niphas-eval links against these at build time via +Rust FFI bindings. This eliminates: + +- Job creation overhead (K8s API round-trip) +- Pod scheduling latency +- Container image pull for eval sandbox +- Process spawn + exit overhead +- Log scraping + +Evaluation becomes a function call inside the niphas-eval process: + +```rust +// Simplified -- actual implementation uses nix-bindings-rust +fn evaluate_workload(flake_ref: &str, attribute: &str) -> Result { + let ctx = Context::new()?; + let store = Store::open(&ctx, None)?; + let state = EvalStateBuilder::new(&store)?.build()?; + + // Construct flake installable: "github:myorg/myapp#packages.x86_64-linux.default" + let expr = format!( + "let drv = (builtins.getFlake \"{}\").{}; in {{ \ + outPath = drv.outPath; \ + name = drv.name; \ + mainProgram = drv.meta.mainProgram or null; \ + }}", + flake_ref, attribute + ); + + let value = state.eval_from_string(&expr, "")?; + let attrs = value.require_attrs()?; + + Ok(EvalResult { + store_path: attrs.get("outPath")?.require_string()?, + name: attrs.get("name")?.require_string()?, + main_program: attrs.get("mainProgram")?.as_string().ok(), + }) +} +``` + +### Eval performance + +| Scenario | Time | Why | +|----------|------|-----| +| First eval of a new flake | 5-15s | Fetches flake source + inputs from git | +| Same flake, new revision | 1-3s | Inputs cached, re-evaluates expression | +| Same flake, same revision | milliseconds | Eval cache hit | +| Redeploy (no spec change) | skipped | `observedGeneration == generation` | + +The Nix eval cache persists in the niphas-eval pod's local store +(`/nix/store` on a PVC or hostPath). Cold start (first eval ever) +fetches nixpkgs and inputs -- this is expected and normal. + +### Sandbox flags + +Even via FFI, the evaluator runs with restrictive settings: + +```rust +let mut settings = EvalSettings::default(); +settings.sandbox = true; +settings.restrict_eval = true; +settings.allowed_uris = vec![ + "https://github.com", + "https://cache.nixos.org", +]; +settings.allow_import_from_derivation = false; // critical: blocks IFD +settings.max_jobs = 0; // no builds, eval only +``` + +`allow-import-from-derivation = false` is the most important flag. IFD +would allow arbitrary builds during eval, which is the main attack vector +for malicious flakes. + +### Flake allowlist (deny-by-default) + +Before evaluation, niphas-eval validates the flakeRef: + +```rust +fn validate_flake_ref(flake_ref: &str, allowlist: &[String]) -> Result<()> { + for pattern in allowlist { + if matches_glob(flake_ref, pattern) { + return Ok(()); + } + } + Err(EvalError::FlakeNotAllowed(flake_ref.into())) +} +``` + +Config: +```yaml +allowedFlakeOrigins: + - "github:myorg/*" + - "github:nixos/nixpkgs" +``` + +Any flakeRef not matching is rejected before the evaluator is invoked. + +## End-to-end sequence + +``` +User creates NiphasWorkload CR + | + v +[1] operator watches CRD, sees new resource + sets status.phase = "Evaluating" + | + v +[2] operator calls niphas-eval webhook + POST /evaluate { flakeRef, attribute, revision } + | + v +[3] niphas-eval validates flakeRef against allowlist + if rejected: returns error, operator sets phase = "Failed" + | + v +[4] niphas-eval calls Nix evaluator via C FFI (in-process): + - constructs flakeRef with revision: "github:myorg/myapp/" + - evaluates: outPath, name, meta.mainProgram + - sandbox=true, IFD=false, restrict-eval=true + - returns EvalResult in milliseconds (warm) to seconds (cold) + | + v +[5] niphas-eval resolves the full closure via HTTP: + - fetches /.narinfo + - parses References field (immediate dependencies) + - recursively fetches .narinfo for each reference + (parallel, concurrency 16-32) + - collects all store paths in the closure + | + v +[6] if root .narinfo returns 404 from all caches: + returns error, operator sets phase = "Failed", + reason = StorePathNotCached + (user must build in CI and push to cache first) + | + v +[7] niphas-eval returns result to operator. + operator updates CRD status: + status.phase = "Provisioning" + status.storePath = "/nix/store/abc123-myapp-1.0.0" + status.resolvedCommand = "/nix/store/abc123-myapp-1.0.0/bin/myapp" + status.closurePaths = ["/nix/store/abc123-myapp-1.0.0", + "/nix/store/def456-glibc-2.38", ...] + status.conditions += { type: Evaluated, status: True } + | + v +[8] operator generates child resources: + + Deployment: + - image: ghcr.io/fullzer4/niphas-runner:latest (scratch stub) + - command: ["/nix/store/abc123-myapp-1.0.0/bin/myapp"] + - volumes: + - csi: + driver: niphas.io.csi + volumeAttributes: + closurePaths: "/nix/store/abc123-myapp-1.0.0,..." + mountMode: "closure" + - volumeMounts: /nix/store (read-only) + - env, ports, probes, resources from CRD spec + - topology spread + failover tolerations + Service (if spec.service set) + Ingress (if spec.ingress set) + PDB (if replicas >= 2) + + all child resources have ownerReferences -> NiphasWorkload + | + v +[9] K8s schedules pods. kubelet on each node: + - calls NodePublishVolume on niphas-csi + - CSI fetches closure via fallback chain: + local cache -> mesh P2P -> binary cache HTTP + - verifies NAR signatures (Ed25519) + - extracts and bind-mounts at /nix/store (read-only) + | + v +[10] container starts, probes pass, pod becomes Ready + | + v +[11] operator updates status: + phase = "Running" + readyReplicas = 3 + conditions += { type: Available, status: True } + | + v +[12] niphas-mesh (if enabled) announces new NARs via gossipsub + other nodes discover availability for future fetches + no proactive replication -- each node fetches on demand +``` + +## The runner image + +K8s requires an `image` field on every container. Since Niphas mounts +the actual binary from the CSI volume, the container image is just a +stub that provides a minimal Linux userspace. + +`niphas-runner` is a scratch-like image (~1MB) that provides: +- `/lib64/ld-linux-x86-64.so.2` (dynamic linker, from Nix) +- Basic `/etc` files (passwd, group, nsswitch.conf) +- Nothing else. No shell, no package manager. + +For statically linked Nix binaries (musl), even this is unnecessary +and a true `FROM scratch` (0 bytes) image works. + +## Command resolution + +When `spec.command` is omitted: + +1. Eval resolves `meta.mainProgram` from the derivation +2. If set: uses `${storePath}/bin/${mainProgram}` +3. If not: lists `$out/bin/`, picks the single binary +4. If ambiguous (multiple binaries): eval fails with clear error +5. Resolved command stored in status for observability + +## Updates and rollouts + +When the user changes `flakeRef`, `attribute`, or `revision`: + +1. `metadata.generation` increments +2. Operator detects `observedGeneration < generation` +3. Re-triggers eval (steps 2-6) +4. If store path changed: updates Deployment CSI volumeAttributes + command +5. K8s performs standard rolling update +6. Old pods drain, new pods mount new closure +7. Canary/blue-green strategies work as normal + +## Multi-architecture + +For mixed clusters (amd64 + arm64), use the `{arch}` template: + +```yaml +spec: + flakeRef: "github:myorg/myapp" + attribute: "packages.{arch}-linux.default" + architectures: + - x86_64 + - aarch64 + replicas: 6 +``` + +The operator: +1. Evaluates per architecture (two in-process eval calls via Nix C API) +2. Creates two Deployments (3 replicas each) with `nodeSelector` +3. Both share the label `niphas.io/workload: myapp` +4. Single Service selects both via the shared label +5. Load balances across architectures transparently + +Status: +```yaml +status: + architectures: + - arch: x86_64 + storePath: "/nix/store/abc123-myapp-1.0.0" + readyReplicas: 3 + - arch: aarch64 + storePath: "/nix/store/xyz789-myapp-1.0.0" + readyReplicas: 3 + readyReplicas: 6 +``` + +## Helm chart for user workloads + +Users in Helm-based workflows can deploy via a convenience chart: + +```bash +helm install myapp niphas/workload \ + --set flakeRef=github:myorg/myapp \ + --set attribute=packages.x86_64-linux.default \ + --set replicas=3 \ + --set ports[0].name=http \ + --set ports[0].containerPort=8080 \ + --set service.enabled=true \ + --set service.ports[0].port=80 \ + --set service.ports[0].targetPort=http \ + --set ingress.enabled=true \ + --set ingress.hosts[0].host=myapp.example.com +``` + +Or with a values file: + +```yaml +# myapp-values.yaml +flakeRef: "github:myorg/myapp" +attribute: "packages.x86_64-linux.default" +revision: "a1b2c3d" +replicas: 3 +ports: + - name: http + containerPort: 8080 +resources: + requests: + cpu: "250m" + memory: "256Mi" +service: + enabled: true + ports: + - name: http + port: 80 + targetPort: http +ingress: + enabled: true + className: nginx + hosts: + - host: myapp.example.com + paths: + - path: / + port: http +``` + +```bash +helm install myapp niphas/workload -f myapp-values.yaml +``` + +The chart generates a NiphasWorkload CR from the values. + +## Helm chart for Niphas platform + +Installing Niphas itself: + +```bash +# 1. Install the platform +helm install niphas niphas/niphas -n niphas-system --create-namespace + +# 2. Label nodes that should run Niphas workloads +kubectl label node worker-{1..5} niphas.io/store=true + +# 3. Optional: enable mesh for P2P NAR sharing +kubectl label node worker-{1..5} niphas.io/mesh=true +``` + +With custom binary cache and mesh disabled: + +```bash +helm install niphas niphas/niphas -n niphas-system --create-namespace \ + --set csi.binaryCaches[0].url=https://cache.company.com \ + --set csi.binaryCaches[0].publicKey="company-1:abc..." \ + --set mesh.enabled=false +``` + +The platform chart deploys: operator (Deployment), CSI driver (DaemonSet +with `nodeSelector: niphas.io/store=true`), mesh (DaemonSet with +`nodeSelector: niphas.io/mesh=true`, optional via `mesh.enabled`), +eval webhook (Deployment), CRDs, RBAC, NetworkPolicies, PriorityClasses. + +Nodes must be labeled before workloads can be scheduled on them: + +```bash +kubectl label node worker-1 worker-2 worker-3 niphas.io/store=true +# Optional: enable mesh for P2P NAR sharing between nodes +kubectl label node worker-1 worker-2 worker-3 niphas.io/mesh=true +``` + +## GitOps integration + +NiphasWorkload CRDs work with ArgoCD and Flux out of the box. + +ArgoCD health check (add to argocd-cm ConfigMap): + +```lua +-- resource.customizations.health.niphas.io_NiphasWorkload +hs = {} +if obj.status ~= nil then + if obj.status.phase == "Running" then + hs.status = "Healthy" + elseif obj.status.phase == "Failed" then + hs.status = "Degraded" + else + hs.status = "Progressing" + end + hs.message = "Phase: " .. (obj.status.phase or "Unknown") +end +return hs +``` + +Flux uses `observedGeneration` from the status to detect drift +(already implemented in the CRD). + +## Service exposure + +### Embedded (simple case) + +The CRD includes `spec.service` and `spec.ingress`. The operator +creates both with `ownerReferences`. Covers 80% of use cases. + +### Standalone (advanced case) + +For Gateway API, multiple services, or service mesh routing, the user +creates standard K8s resources that select pods via label: + +```yaml +selector: + niphas.io/workload: myapp +``` + +The operator labels all generated pods with `niphas.io/workload: `, +making them selectable by any K8s networking resource. diff --git a/EVAL.md b/EVAL.md new file mode 100644 index 0000000..1e0ab31 --- /dev/null +++ b/EVAL.md @@ -0,0 +1,544 @@ +# Niphas -- Eval Webhook Design + +niphas-eval is an HTTP webhook that evaluates Nix flakes via the Nix C API +(in-process FFI) and resolves closures via binary cache HTTP. It is called +by the operator during reconciliation. + +## Architecture + +``` +operator niphas-eval binary cache + | | | + | POST /evaluate | | + | {flakeRef, attribute} | | + |------------------------->| | + | | | + | | 1. validate flakeRef | + | | against allowlist | + | | | + | | 2. nix eval via C FFI | + | | (in-process, no Jobs) | + | | -> outPath, name, | + | | mainProgram | + | | | + | | 3. resolve closure | + | | GET /.narinfo ---->| + | | parse References <----| + | | (recursive, parallel) | + | | | + | {storePath, closure} | | + |<-------------------------| | +``` + +niphas-eval runs as a Deployment (not DaemonSet). Typically 2 replicas +for availability. Stateless except for the Nix eval cache (`/nix/store` +on a PVC or hostPath). + +## HTTP API + +### POST /evaluate + +Request: + +```json +{ + "flakeRef": "github:myorg/myapp", + "attribute": "packages.x86_64-linux.default", + "revision": "a1b2c3d4e5f6", + "binaryCache": "https://cache.company.com" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `flakeRef` | string | yes | Flake reference | +| `attribute` | string | yes | Output attribute path | +| `revision` | string | no | Git revision to pin | +| `binaryCache` | string | no | Override binary cache URL | + +Response (success, 200): + +```json +{ + "storePath": "/nix/store/abc123-myapp-1.0.0", + "name": "myapp-1.0.0", + "mainProgram": "myapp", + "closurePaths": [ + "/nix/store/abc123-myapp-1.0.0", + "/nix/store/def456-glibc-2.38", + "/nix/store/ghi789-openssl-3.1" + ] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `storePath` | string | Resolved output path | +| `name` | string | Derivation name (from `drv.name`) | +| `mainProgram` | string or null | From `meta.mainProgram`. Used for command auto-detection | +| `closurePaths` | Vec | Full transitive closure (root + all references) | + +Response (error, 4xx/5xx): + +```json +{ + "error": "FlakeNotAllowed", + "message": "github:evil/repo not in allowlist" +} +``` + +| HTTP Status | Error code | Meaning | +|-------------|-----------|---------| +| 403 | `FlakeNotAllowed` | flakeRef not in allowlist | +| 422 | `EvalFailed` | Nix evaluation error | +| 408 | `EvalTimeout` | Evaluation exceeded timeout | +| 404 | `StorePathNotCached` | NAR not found in any binary cache | +| 502 | `ClosureResolutionFailed` | Could not resolve all closure paths | +| 500 | `InternalError` | Unexpected error | + +### GET /healthz + +Returns 200 if the process is running and the Nix evaluator is initialized. + +### GET /readyz + +Returns 200 if the evaluator has completed at least one successful eval +(warm cache). Returns 503 during cold start. + +## Nix C FFI integration + +niphas-eval links against the Nix C libraries (`libexpr-c`, `libstore-c`, +`libflake-c`) at build time via `nix-bindings-rust`. The evaluator runs +in-process, not in a separate process or container. + +### Lifecycle + +```rust +// On startup (once) +let nix_ctx = nix::Context::new()?; +let store = nix::Store::open(&nix_ctx, None)?; + +// Configure evaluator settings +let mut settings = nix::EvalSettings::default(); +settings.sandbox = true; +settings.restrict_eval = true; +settings.allowed_uris = config.allowed_uris.clone(); +settings.allow_import_from_derivation = false; +settings.max_jobs = 0; + +let state = nix::EvalStateBuilder::new(&store)? + .with_settings(&settings) + .build()?; + +// Per-request (shared state, concurrent via Arc) +let state = Arc::new(state); +``` + +The `EvalState` is initialized once and shared across requests. The Nix +C API is internally thread-safe for evaluation (each eval call acquires +internal locks). Concurrent evaluations of different flakes are safe. + +### Evaluation call + +```rust +fn evaluate( + state: &EvalState, + flake_ref: &str, + attribute: &str, + revision: Option<&str>, +) -> Result { + // Construct flake reference with optional revision + let pinned_ref = match revision { + Some(rev) => format!("{}/{}", flake_ref, rev), + None => flake_ref.to_string(), + }; + + // Build Nix expression that extracts what we need + let expr = format!( + r#"let drv = (builtins.getFlake "{}").{}; in {{ + outPath = drv.outPath; + name = drv.name; + mainProgram = drv.meta.mainProgram or null; + }}"#, + pinned_ref, attribute + ); + + // Evaluate with timeout + let value = tokio::time::timeout( + Duration::from_secs(300), + tokio::task::spawn_blocking(move || { + state.eval_from_string(&expr, "") + }), + ).await + .map_err(|_| EvalError::Timeout)??; + + let attrs = value.require_attrs()?; + + Ok(EvalResult { + store_path: attrs.get("outPath")?.require_string()?, + name: attrs.get("name")?.require_string()?, + main_program: attrs.get("mainProgram")?.as_string().ok(), + }) +} +``` + +### Why spawn_blocking + +Nix evaluation is CPU-bound and blocks the thread. Running it on +tokio's blocking thread pool prevents stalling the async runtime. +The eval call can take 1-15 seconds for cold evaluations. + +### Eval settings + +| Setting | Value | Rationale | +|---------|-------|-----------| +| `sandbox` | `true` | Restrict filesystem access during eval | +| `restrict_eval` | `true` | Only allow access to explicitly allowed URIs | +| `allowed_uris` | `["https://github.com", "https://cache.nixos.org"]` | Configurable via ConfigMap | +| `allow_import_from_derivation` | `false` | **Critical**: blocks IFD (arbitrary code execution during eval) | +| `max_jobs` | `0` | No builds, eval only. Even if IFD is somehow bypassed, no builder is available | + +See [`SECURITY_DESIGN.md`](SECURITY_DESIGN.md) for the full 4-layer +defense model. + +## Flake allowlist + +Before evaluation, the webhook validates the flakeRef against a +deny-by-default allowlist. + +```rust +fn validate_flake_ref(flake_ref: &str, allowlist: &[String]) -> Result<(), EvalError> { + for pattern in allowlist { + if matches_glob(flake_ref, pattern) { + return Ok(()); + } + } + Err(EvalError::FlakeNotAllowed(flake_ref.into())) +} +``` + +### Glob matching rules + +| Pattern | Matches | Does not match | +|---------|---------|----------------| +| `github:myorg/*` | `github:myorg/myapp`, `github:myorg/lib` | `github:other/repo` | +| `github:nixos/nixpkgs` | `github:nixos/nixpkgs` | `github:nixos/nix` | +| `github:*/*` | any GitHub flake | `path:/local/flake` | + +### Config + +```yaml +# niphas-eval ConfigMap +apiVersion: v1 +kind: ConfigMap +metadata: + name: niphas-eval-config + namespace: niphas-system +data: + config.yaml: | + allowedFlakeOrigins: + - "github:myorg/*" + - "github:nixos/nixpkgs" + evalTimeout: 300s + allowedUris: + - "https://github.com" + - "https://cache.nixos.org" + binaryCaches: + - url: "https://cache.company.com" + publicKey: "cache.company.com-1:..." + - url: "https://cache.nixos.org" + publicKey: "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" + closureResolution: + concurrency: 32 + timeout: 60s +``` + +## Closure resolution + +After evaluation, niphas-eval resolves the full transitive closure by +walking `.narinfo` files from the binary cache. This is pure HTTP -- +no Nix needed. + +### Algorithm + +```rust +async fn resolve_closure( + cache: &CacheClient, + root_path: &str, +) -> Result, EvalError> { + let mut closure = Vec::new(); + let mut visited = HashSet::new(); + let mut queue = VecDeque::new(); + + queue.push_back(root_path.to_string()); + visited.insert(root_path.to_string()); + + while let Some(path) = queue.pop_front() { + // Fetch .narinfo for this path + let hash = store_path_hash(&path)?; + let narinfo = cache.fetch_narinfo(&hash).await?; + + closure.push(path); + + // Add unvisited references to queue + for reference in &narinfo.references { + let ref_path = format!("/nix/store/{}", reference); + if visited.insert(ref_path.clone()) { + queue.push_back(ref_path); + } + } + } + + Ok(closure) +} +``` + +### Parallel resolution + +The BFS is parallelized: multiple `.narinfo` fetches happen concurrently +(up to `closureResolution.concurrency`, default 32). + +```rust +async fn resolve_closure_parallel( + cache: &CacheClient, + root_path: &str, + concurrency: usize, +) -> Result, EvalError> { + let mut closure = Vec::new(); + let mut visited = HashSet::new(); + let mut pending = FuturesUnordered::new(); + + // Seed with root + visited.insert(root_path.to_string()); + pending.push(fetch_and_parse(cache, root_path)); + + while let Some(result) = pending.next().await { + let (path, narinfo) = result?; + closure.push(path); + + for reference in &narinfo.references { + let ref_path = format!("/nix/store/{}", reference); + if visited.insert(ref_path.clone()) { + if pending.len() < concurrency { + pending.push(fetch_and_parse(cache, &ref_path)); + } + } + } + } + + Ok(closure) +} +``` + +### What if root .narinfo returns 404? + +The store path was evaluated but never built (or not pushed to the +binary cache). This is the `StorePathNotCached` error: + +``` +The workload cannot be deployed. Build in CI and push to binary cache first. + + nix build .#packages.x86_64-linux.default + nix copy --to https://cache.company.com ./result +``` + +The operator sets: +```yaml +status: + phase: Failed + conditions: + - type: Evaluated + status: "False" + reason: StorePathNotCached + message: "/nix/store/abc123-myapp-1.0.0 not found in any binary cache" +``` + +## Eval cache + +The Nix evaluator maintains its own internal cache: + +| Cache | Location | Contents | +|-------|----------|----------| +| Eval cache | `/nix/store` (PVC) | Fetched flake inputs, evaluated derivations | +| Git cache | `~/.cache/nix/` | Cloned git repos (flake sources) | + +### Performance from cache + +| Scenario | Time | Why | +|----------|------|-----| +| First eval of a new flake | 5-15s | Fetches flake source + inputs from git | +| Same flake, new revision | 1-3s | Inputs cached, re-evaluates expression | +| Same flake, same revision | <100ms | Eval cache hit | +| Redeploy (no spec change) | skipped | Operator checks `observedGeneration == generation` | + +The eval cache persists across pod restarts via PVC. Cold start (first +eval ever in a fresh pod) fetches nixpkgs and inputs -- this is expected +and documented in startup logs. + +### Cache sizing + +The eval cache grows as new flakes are evaluated. nixpkgs alone is ~1 GB +in the eval cache. For most deployments, 10-20 GB PVC is sufficient. +The Nix evaluator handles its own GC internally. + +## Concurrency model + +niphas-eval uses Axum with tokio multi-thread runtime: + +```rust +#[tokio::main] +async fn main() -> Result<()> { + // Initialize tracing + niphas_core::telemetry::init_tracing(); + + // Load config + let config = Config::load()?; + + // Initialize Nix evaluator (once) + let evaluator = Arc::new(Evaluator::new(&config)?); + + // Build router + let app = Router::new() + .route("/evaluate", post(handlers::evaluate)) + .route("/healthz", get(handlers::healthz)) + .route("/readyz", get(handlers::readyz)) + .layer(Extension(evaluator)) + .layer(Extension(config)); + + // Serve + let addr = SocketAddr::from(([0, 0, 0, 0], 8443)); + let listener = TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + + Ok(()) +} +``` + +### Concurrent eval requests + +Multiple eval requests can arrive simultaneously (e.g. operator creates +multiple NiphasWorkloads). Each request runs on its own tokio task: + +1. Allowlist check: instant, no blocking +2. Nix eval: `spawn_blocking` (CPU-bound, runs on blocking thread pool) +3. Closure resolution: async HTTP calls (concurrent `.narinfo` fetches) + +The Nix C API's `EvalState` is behind an `Arc`. The C API internally +serializes evaluation (one eval at a time via internal mutex). This +means concurrent eval requests are queued at the C level. For true +parallelism, niphas-eval would need multiple `EvalState` instances -- +this is a future optimization. + +### Request timeout + +Each handler wraps the evaluation in a tokio timeout: + +```rust +async fn evaluate( + Extension(evaluator): Extension>, + Json(req): Json, +) -> Result, AppError> { + let result = tokio::time::timeout( + evaluator.config.eval_timeout, + evaluator.evaluate(&req), + ).await + .map_err(|_| AppError::EvalTimeout)? + .map_err(AppError::from)?; + + Ok(Json(result)) +} +``` + +## Deployment + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: niphas-eval + namespace: niphas-system +spec: + replicas: 2 + selector: + matchLabels: + app: niphas-eval + template: + metadata: + labels: + app: niphas-eval + spec: + serviceAccountName: niphas-eval + securityContext: + runAsNonRoot: true + runAsUser: 65534 + seccompProfile: + type: RuntimeDefault + containers: + - name: niphas-eval + image: ghcr.io/fullzer4/niphas-eval:latest + ports: + - containerPort: 8443 + name: webhook + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "2" + memory: "4Gi" + volumeMounts: + - name: nix-store + mountPath: /nix/store + - name: eval-config + mountPath: /etc/niphas + readOnly: true + livenessProbe: + httpGet: + path: /healthz + port: webhook + initialDelaySeconds: 10 + readinessProbe: + httpGet: + path: /readyz + port: webhook + initialDelaySeconds: 30 # cold start may take time + volumes: + - name: nix-store + persistentVolumeClaim: + claimName: niphas-eval-store + - name: eval-config + configMap: + name: niphas-eval-config +--- +apiVersion: v1 +kind: Service +metadata: + name: niphas-eval + namespace: niphas-system +spec: + selector: + app: niphas-eval + ports: + - port: 8443 + targetPort: webhook +``` + +### Why PVC for /nix/store + +The Nix eval cache (fetched flake inputs, evaluated derivations) must +persist across pod restarts. Without PVC, every restart triggers a cold +eval (fetch nixpkgs from git, ~5-15 seconds). With PVC, warm evals +take <100ms. + +The PVC can be `ReadWriteOnce` -- only one pod writes at a time (the +Nix evaluator serializes writes internally). If running 2 replicas, +each gets its own PVC (`volumeClaimTemplates` in a StatefulSet) or +they share via `ReadWriteMany` if the storage class supports it. + +Alternative: hostPath at `/var/lib/niphas/eval-cache/`. Simpler but +ties the eval pod to a specific node. PVC is preferred for portability. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8acfc37 --- /dev/null +++ b/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025 Niphas Contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/MESH_PROTOCOL.md b/MESH_PROTOCOL.md new file mode 100644 index 0000000..aef6d94 --- /dev/null +++ b/MESH_PROTOCOL.md @@ -0,0 +1,688 @@ +# Niphas -- Mesh Protocol Design + +## Overview + +niphas-mesh is an **optional** DaemonSet that runs on nodes labeled with +`niphas.io/store=true`. It provides P2P distribution of Nix store paths +(NARs) between Niphas-enabled nodes, accelerating closure fetching via +LAN-speed peer transfers instead of WAN binary cache HTTP. + +The mesh is NOT required. Without it, the CSI driver fetches directly from +binary cache HTTP. The mesh is an optimization for: + +- **Scale**: 100+ nodes fetching the same NAR simultaneously would overwhelm + a single binary cache server. P2P distributes the load. +- **Speed**: LAN peer fetch (~1 GB/s) vs WAN binary cache (~100 MB/s). +- **Resilience**: If the binary cache goes down, peers serve cached NARs. + +**No full replication.** Each node only caches the NARs its pods actually +use. The mesh enables fast peer-to-peer fetch on cache miss, not cluster-wide +synchronization. + +## Selective Deployment + +### Deployer controls via node labels + +```bash +# Mark nodes that should run Niphas workloads +kubectl label node worker-1 niphas.io/store=true +kubectl label node worker-2 niphas.io/store=true + +# Nodes without the label: zero Niphas overhead +# worker-3 through worker-100: no CSI, no mesh, no cache +``` + +### DaemonSet nodeSelector + +```yaml +# CSI DaemonSet -- only on labeled nodes +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: niphas-csi + namespace: niphas-system +spec: + selector: + matchLabels: + app: niphas-csi + template: + spec: + nodeSelector: + niphas.io/store: "true" + # ... + +--- +# Mesh DaemonSet -- only on labeled nodes, optional +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: niphas-mesh + namespace: niphas-system +spec: + selector: + matchLabels: + app: niphas-mesh + template: + spec: + nodeSelector: + niphas.io/store: "true" + # ... +``` + +### Resource usage + +| Node type | CSI pod | Mesh pod | Cache | RAM overhead | +|-----------|---------|----------|-------|-------------| +| `niphas.io/store=true` | Running (~10 MB idle) | Running (~30-50 MB idle) | Grows with usage | ~40-60 MB | +| No label | None | None | None | 0 | + +For comparison: calico-node uses 150-256 MB, Cilium uses 330-430 MB. + +### Mesh is optional + +```yaml +# Helm values +mesh: + enabled: true # false = CSI-only, binary cache HTTP + nodeSelector: + niphas.io/store: "true" + resources: + requests: + cpu: "50m" + memory: "64Mi" + limits: + cpu: "500m" + memory: "256Mi" +``` + +When `mesh.enabled: false`, the CSI driver fetches exclusively from binary +cache HTTP. For small clusters with a fast self-hosted binary cache +(Attic/harmonia on the same LAN), this is sufficient. + +## Architecture + +``` + Nodes with niphas.io/store=true + +--------------------------------------------------+ + | | + | +------------------+ +---------------------+ | + | | niphas-csi | | niphas-mesh | | + | | (DaemonSet) | | (DaemonSet,optional)| | + | | | | | | + | | NodePublishVolume| | Gossipsub | | + | | NodeUnpublish | | Request-Response | | + | | NAR verification | | libp2p_stream | | + | +--------+---------+ +----------+----------+ | + | | | | + | +-------+ +------------+ | + | | | | + | +-------v---v--------+ | + | | /var/lib/niphas/ | | + | | cache/ | | + | | (hostPath, shared) | | + | +--------------------+ | + +--------------------------------------------------+ + + Nodes WITHOUT label + +--------------------------------------------------+ + | No Niphas pods. Zero overhead. | + +--------------------------------------------------+ +``` + +## No Leader Election + +The mesh has **no leader**. Every mesh pod is equal. Each node acts +autonomously based on local state: + +- What it has in its local cache +- What gossipsub tells it peers have (NAR index) +- What the CSI driver requests + +This works because NARs are content-addressed and immutable. Two nodes +pushing the same NAR to a third is idempotent -- deduplicated by hash. + +The operator has leader election (Lease-based, for CRD reconciliation), +but the mesh layer is fully decentralized. + +## libp2p Stack + +### Behaviour + +```rust +use libp2p::{ + tcp, quic, noise, yamux, + identify, gossipsub, request_response, + swarm::NetworkBehaviour, +}; + +#[derive(NetworkBehaviour)] +struct MeshBehaviour { + identify: identify::Behaviour, + gossipsub: gossipsub::Behaviour, + /// Control plane: HasNar, QueryNarInfo + control: request_response::cbor::Behaviour, + /// Data plane: raw streaming for bulk NAR transfers + transfer: libp2p_stream::Behaviour, +} +``` + +| Layer | Choice | Why | +|-------|--------|-----| +| Transport | TCP + QUIC | TCP for reliability, QUIC for low-latency (UDP) | +| Encryption | Noise XX | Mutual auth, PFS, no TLS cert management | +| Multiplexing | Yamux | Standard for libp2p Rust | +| Announcements | Gossipsub | Pubsub Have/Evicted, peers learn what's available | +| Content index | Local HashMap | O(1) lookup: "who has NAR X?" | +| Control | Request-Response (CBOR) | HasNar, QueryNarInfo | +| Data transfer | libp2p_stream | Raw AsyncRead/AsyncWrite, zero-copy streaming | +| Identity | Identify | Exchange peer metadata (version, zone, etc.) | + +### Port + +Single port: `4001` (TCP + QUIC). Same as IPFS convention. + +### Gossipsub Config + +```rust +// Tuned for K8s clusters. Matches Ethereum beacon chain production +// config (validated at 100k+ validators). +let gossipsub_config = gossipsub::ConfigBuilder::default() + .heartbeat_interval(Duration::from_millis(700)) + .mesh_n(8) // target mesh degree + .mesh_n_low(6) // minimum before grafting + .mesh_n_high(12) // maximum before pruning + .history_length(6) // IHAVE history for reliability + .history_gossip(3) // gossip to D_lazy peers + .max_transmit_size(65536) // gossip messages are small (~100 bytes) + .build() + .unwrap(); +``` + +## Protocol Messages + +### Gossipsub (announcements) + +Single topic: `niphas/nar/v1` + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +enum GossipMessage { + /// Peer has a new NAR available in its local cache. + Have { nar_hash: String, store_path: String, nar_size: u64 }, + /// Peer evicted a NAR from local cache (GC). + Evicted { nar_hash: String }, +} +``` + +When a message arrives: +- `Have`: add peer to NAR index +- `Evicted`: remove peer from NAR index for that hash +- Peer disconnects: remove all entries for that peer + +### Control plane (Request-Response CBOR) + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum MeshRequest { + /// Check if peer has a NAR. + HasNar { nar_hash: String }, + + /// Query narinfo for a store path. + QueryNarInfo { store_path_hash: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum MeshResponse { + HasNar { available: bool, nar_size: u64 }, + NarInfo { narinfo: String }, + NotFound, + Busy, +} +``` + +### Data plane (libp2p_stream) + +NAR transfers use raw substreams via `libp2p_stream::Behaviour`. + +Stream protocol ID: `/niphas/nar-transfer/1` + +```rust +/// Sent at the start of a transfer substream. +struct TransferHeader { + nar_hash: [u8; 32], + offset: u64, // for parallel range requests (0 = from start) + length: u64, // 0 = send everything from offset to end +} + +/// Header is 48 bytes, fixed layout, big-endian. +/// After the header, sender streams raw compressed NAR bytes until +/// `length` bytes are sent, then closes the substream write half. +``` + +The receiver: +1. Opens substream, sends `TransferHeader` +2. Sender streams compressed NAR bytes directly from disk (zero-copy via mmap) +3. Receiver writes to temp file, verifies NarHash after all bytes received +4. If valid: moves to cache, announces via gossipsub `Have` +5. If invalid: discards temp file, logs warning + +### Parallel range download (swarming) + +For NARs > 64 MB, the receiver can open substreams to **multiple peers** +simultaneously, each requesting a different byte range: + +``` +NAR = 256 MB, 4 peers available + +Peer A: offset=0, length=64MB +Peer B: offset=64MB, length=64MB +Peer C: offset=128MB,length=64MB +Peer D: offset=192MB,length=64MB + +All download in parallel -> ~4x throughput +Receiver assembles chunks, verifies NarHash of the whole file +``` + +For NARs < 64 MB (the vast majority), single-peer transfer is sufficient. + +## NAR Index + +Each mesh node maintains a local HashMap tracking which peers have which +NARs. Populated entirely from gossipsub messages. + +```rust +use std::collections::{HashMap, HashSet}; + +struct NarIndex { + index: HashMap>, +} + +impl NarIndex { + /// O(1) lookup, zero network latency. + fn providers(&self, nar_hash: &str) -> Vec { + self.index.get(nar_hash) + .map(|peers| peers.iter().copied().collect()) + .unwrap_or_default() + } + + fn add(&mut self, nar_hash: &str, peer: PeerId) { + self.index.entry(nar_hash.to_string()).or_default().insert(peer); + } + + fn remove_peer(&mut self, peer: &PeerId) { + for peers in self.index.values_mut() { + peers.remove(peer); + } + self.index.retain(|_, peers| !peers.is_empty()); + } +} +``` + +Memory: only tracks NARs that at least one peer announced. With no full +replication, each peer only announces what it cached for its pods. A node +running 20 microservices with ~3000 unique store paths: +~3000 entries * ~80 bytes = ~240 KB. Negligible. + +## Peer Discovery + +### Bootstrap: Headless Service DNS + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: niphas-mesh + namespace: niphas-system +spec: + clusterIP: None + selector: + app: niphas-mesh + ports: + - port: 4001 + name: libp2p +``` + +On startup, resolve `niphas-mesh.niphas-system.svc.cluster.local` +to get all mesh pod IPs. Dial each one. + +```rust +async fn bootstrap_peers(service_dns: &str) -> Vec { + let addrs = tokio::net::lookup_host(format!("{}:4001", service_dns)).await?; + addrs.map(|addr| { + format!("/ip4/{}/tcp/4001", addr.ip()).parse().unwrap() + }).collect() +} +``` + +### Dynamic: K8s API Watch + +Watch mesh pods for real-time membership changes + node labels. + +```rust +async fn watch_mesh_pods(client: kube::Client) { + let pods: Api = Api::namespaced(client, "niphas-system"); + let params = ListParams::default().labels("app=niphas-mesh"); + let mut stream = watcher(pods, params).boxed(); + + while let Some(event) = stream.next().await { + match event? { + Event::Applied(pod) => { + let ip = pod.status?.pod_ip?; + let node = pod.spec?.node_name?; + let zone = get_node_label(&node, "topology.kubernetes.io/zone"); + peer_registry.add(PeerInfo { ip, node, zone, .. }); + } + Event::Deleted(pod) => { + peer_registry.remove(pod.metadata.name); + } + _ => {} + } + } +} +``` + +## CSI Interface + +The CSI driver communicates with the local mesh pod via Unix socket. + +### Socket path + +``` +/var/run/niphas/mesh.sock +``` + +Shared via a hostPath volume between the CSI DaemonSet and mesh DaemonSet. + +### Protocol (length-prefixed JSON over UDS) + +```rust +/// CSI -> Mesh requests +enum CsiRequest { + /// Fetch a NAR. Mesh tries peers, returns path or error. + FetchNar { + store_path: String, + nar_hash: String, + nar_size: u64, + }, + /// Check if a NAR is available on any peer. + HasNar { + nar_hash: String, + }, +} + +/// Mesh -> CSI responses +enum CsiResponse { + /// NAR fetched from peer and now available locally. + Fetched { cache_path: String }, + /// NAR not available from any mesh peer. + NotFound, + /// Error during fetch. + Error { message: String }, +} +``` + +### Fetch flow (CSI perspective) + +``` +1. CSI checks local cache (/var/lib/niphas/cache/-/) + --> if exists: mount directly, done (common case after first run) + +2. CSI checks if mesh socket exists (/var/run/niphas/mesh.sock) + --> if no mesh running: skip to step 5 + +3. CSI asks mesh via UDS: FetchNar { store_path, nar_hash, nar_size } + +4. Mesh queries local NAR index: nar_index.providers(hash) + --> if found: fetch from best peer (same zone preferred) + --> verify signature + NarHash + --> write to local cache + --> publish gossipsub Have { ... } + --> respond Fetched { cache_path } + --> if not found: respond NotFound + +5. CSI falls back to binary cache HTTP (direct fetch) + --> uses CacheClient from niphas-core + --> verify signature + NarHash + --> write to local cache + +6. If all sources fail: + --> return gRPC Unavailable to kubelet + --> kubelet retries with backoff +``` + +The CSI driver does NOT depend on the mesh. If `mesh.sock` does not exist +(mesh disabled or not yet started), CSI goes directly to binary cache HTTP. + +## Cache Management + +### Per-node, lazy cache + +Each node only caches NARs that its pods actually used. No proactive +replication, no background sync. + +``` +/var/lib/niphas/cache/ + abc123-hello-2.12.1/ # extracted, used by a local pod + def456-glibc-2.38/ # dependency of hello, also cached + .tmp-xyz789/ # in-progress extraction +``` + +Cache grows as workloads are deployed. Shared dependencies (glibc, +coreutils, etc.) are fetched once and reused by all subsequent closures. + +### Garbage collection (per-node, autonomous) + +No cluster coordination needed. Each node manages its own cache with +simple LRU eviction. + +```rust +struct GcPolicy { + /// Start evicting when disk usage exceeds this + high_watermark_percent: u8, // default: 85 + /// Stop evicting when disk drops below this + low_watermark_percent: u8, // default: 75 +} + +fn gc_local(cache: &mut Cache, policy: &GcPolicy) -> Result<()> { + while cache.disk_usage_percent() > policy.low_watermark_percent { + // Get LRU NAR that is NOT mounted by a local pod + let candidate = cache.lru_unmounted()?; + cache.evict(&candidate.nar_hash)?; + + // Announce eviction so peers remove us from their NAR index + gossipsub.publish("niphas/nar/v1", GossipMessage::Evicted { + nar_hash: candidate.nar_hash.clone(), + }); + } + Ok(()) +} +``` + +**Invariant:** never evict a NAR that is mounted by a local pod (CSI +ref-counting prevents this). + +### Cache config (deployer-controlled) + +```yaml +# ConfigMap: niphas-config +data: + config.yaml: | + cache: + path: /var/lib/niphas/cache # overridable for dedicated disk + highWatermarkPercent: 85 + lowWatermarkPercent: 75 + binaryCaches: + - url: "https://cache.company.com" + priority: 1 + publicKey: "cache.company.com-1:..." + - url: "https://cache.nixos.org" + priority: 2 + publicKey: "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" + mesh: + bandwidthLimitMbps: 500 + peerFetchTimeout: 5s +``` + +## Bandwidth Control + +### Per-peer rate limiting + +```rust +use governor::{Quota, RateLimiter}; + +struct BandwidthController { + /// Global outbound rate limit + global: RateLimiter<...>, + /// Per-peer outbound rate limit + per_peer: DashMap>, +} +``` + +### Priority queue + +```rust +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +enum TransferPriority { + CsiMount = 0, // highest: pod waiting for volume + PeerRequest = 1, // another node's CSI needs this NAR +} + +struct TransferQueue { + queue: BinaryHeap, + semaphore: Semaphore, // limit concurrent transfers (e.g. 8) +} +``` + +### Connection limits + +```rust +let swarm = SwarmBuilder::with_existing_identity(keypair) + .with_tokio() + .with_tcp(...) + .with_quic() + .with_behaviour(|key| MeshBehaviour::new(key))? + .with_swarm_config(|cfg| { + cfg.with_max_negotiating_inbound_streams(128) + .with_idle_connection_timeout(Duration::from_secs(60)) + }) + .build(); +``` + +Each node does NOT connect to all peers. Gossipsub maintains D=8 mesh +connections. Transfer streams open on-demand and close after transfer. + +## Authentication + +### Noise XX Handshake + +Every connection uses libp2p Noise XX: +- Both peers prove possession of their Ed25519 identity key +- Ephemeral Curve25519 keys per session (PFS) +- Post-handshake encryption: ChaCha20-Poly1305 + +### Closed Mesh (PeerId Allowlist) + +Only known cluster members can connect. The peer registry (from K8s API +watch) provides the allowlist. + +```rust +impl MeshBehaviour { + fn handle_new_connection(&mut self, peer_id: &PeerId) -> Result<(), ConnectionDenied> { + if !self.peer_registry.contains(peer_id) { + tracing::warn!(%peer_id, "rejected connection from unknown peer"); + return Err(ConnectionDenied::new("peer not in cluster")); + } + Ok(()) + } +} +``` + +### Key Generation + +Each mesh pod generates an Ed25519 keypair on first boot and stores it +in a K8s Secret (one per node): + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: niphas-mesh-identity- + namespace: niphas-system +type: Opaque +data: + private-key: +``` + +The PeerId (derived from the public key) is published as a pod annotation: + +```yaml +metadata: + annotations: + niphas.io/peer-id: "12D3KooW..." +``` + +### Key Rotation + +1. Delete the Secret +2. Restart the mesh pod +3. Pod generates new keypair, publishes new PeerId +4. Other pods see updated annotation via K8s API watch +5. Old PeerId expires from allowlist + +## Peer Metadata + +```rust +struct PeerMetadata { + zone: String, + hostname: String, + available_disk: u64, + load_percent: u8, + cached_paths: u32, + version: String, +} +``` + +Exchanged via the Identify protocol (periodic, every 5 minutes). +Used for peer selection (same-zone preference, least-loaded fallback). + +## Observability + +### Metrics (Prometheus) + +``` +niphas_mesh_peers_connected gauge # connected mesh peers +niphas_mesh_nar_fetch_total counter # fetches by source (cache/peer/http) +niphas_mesh_nar_fetch_bytes_total counter # bytes fetched +niphas_mesh_nar_fetch_duration_seconds histogram # fetch latency +niphas_mesh_nar_serve_total counter # NARs served to other peers +niphas_mesh_gossip_messages_total counter # gossipsub messages (have/evicted) +niphas_mesh_bandwidth_bytes_total counter # total bandwidth (in/out) +niphas_mesh_cache_size_bytes gauge # local cache disk usage +niphas_mesh_cache_paths gauge # cached store paths +niphas_mesh_gc_evictions_total counter # NARs evicted by GC +``` + +### Structured logs + +``` +level=info msg="NAR fetched from peer" store_path=/nix/store/abc123-hello nar_hash=sha256:... source_peer=12D3KooW... zone=us-east-1a bytes=12345 duration_ms=42 +level=info msg="NAR served to peer" nar_hash=sha256:... target_peer=12D3KooW... bytes=12345 duration_ms=38 +level=info msg="cache miss, falling back to HTTP" store_path=/nix/store/abc123-hello cache=https://cache.company.com +level=warn msg="NAR signature verification failed" nar_hash=sha256:... source=mesh peer=12D3KooW... +level=info msg="GC eviction" nar_hash=sha256:... reason=disk_pressure +``` + +## Failure Scenarios + +| Failure | Behavior | +|---------|----------| +| Mesh pod dies | CSI still works: falls back to binary cache HTTP. Existing mounts survive. Local cache persists (hostPath) | +| Mesh disabled (`mesh.enabled: false`) | CSI fetches directly from binary cache HTTP. No P2P, no gossipsub, no mesh pods | +| No peer has the NAR | Mesh responds NotFound, CSI falls back to binary cache HTTP | +| Peer disconnects mid-transfer | Receiver discards partial data, tries another peer or falls back to HTTP | +| Binary cache down + no peer has NAR | Pod stuck in ContainerCreating, kubelet retries with backoff | +| Node dies | Pods rescheduled on another labeled node. CSI fetches closure from local cache (if previously used) or mesh peers or binary cache | +| Corrupted NAR from peer | Hash verification fails, discarded, try next peer or HTTP | +| Disk full | GC evicts LRU unmounted NARs until below low watermark | +| New labeled node joins | CSI + mesh pods start. Cache is empty. First workload fetches from peers/HTTP, caches locally | +| Label removed from node | CSI + mesh pods evicted. Cache persists on hostPath. Re-labeling restores warm cache | diff --git a/NIX_WIRE.md b/NIX_WIRE.md new file mode 100644 index 0000000..4978b93 --- /dev/null +++ b/NIX_WIRE.md @@ -0,0 +1,846 @@ +# Niphas -- Nix Wire Formats & Binary Cache Protocol + +Niphas implements all Nix format parsing and verification in-house. +Zero dependency on nix-compat, libnar, or any external Nix crate. +Everything lives in `niphas-core` as shared modules consumed by CSI, mesh, and eval. + +## Module layout (niphas-core) + +``` +niphas-core/src/ + nix/ + mod.rs + nar.rs # NAR archive parser + extractor + narinfo.rs # .narinfo text parser + hash.rs # Nix hashing (SHA-256, Nix-base32) + signature.rs # Ed25519 signature verification + store_path.rs # Store path parsing + validation + cache_client.rs # Binary cache HTTP client + closure.rs # Recursive closure resolution +``` + +--- + +## NAR format + +NAR (Nix Archive) is a deterministic binary archive. Same filesystem tree +always produces the exact same bytes. No timestamps, no ownership, no +extended attributes. + +### Wire primitives + +**Integer:** 64-bit unsigned, little-endian. + +``` +encode_u64(n) = n as [u8; 8] in LE +``` + +**String/bytes:** Length-prefixed, padded to 8-byte alignment. + +``` +encode_bytes(b) = + encode_u64(b.len()) + + b + + zero_pad(b.len()) # 0..7 null bytes to reach next 8-byte boundary + +zero_pad(len) = [0u8; (8 - (len % 8)) % 8] +``` + +### Grammar + +``` +nar = str("nix-archive-1") node + +node = str("(") node_body str(")") + +node_body = str("type") str("regular") regular_body + | str("type") str("symlink") symlink_body + | str("type") str("directory") directory_body + +regular_body = [ str("executable") str("") ] + str("contents") bytes(file_data) + +symlink_body = str("target") str(target_path) + +directory_body = { entry } # zero or more, sorted by name ASC + +entry = str("entry") str("(") + str("name") str(entry_name) + str("node") node + str(")") +``` + +Every token (`"nix-archive-1"`, `"("`, `"type"`, `"regular"`, etc.) is +encoded via `encode_bytes()`. File contents use `bytes()` (same encoding). + +### Constraints the parser must enforce + +| Rule | Reject if | +|------|-----------| +| Magic | First token != `"nix-archive-1"` | +| Entry names | Contains `/`, `\0`, or equals `.` or `..` or is empty | +| Entry order | Current name <= previous name (must be strictly ascending) | +| Duplicate names | Same name appears twice in a directory | +| Nesting depth | Exceeds configurable limit (default: 256) | +| File size | `contents` length > configurable max (from NarSize in narinfo) | +| Padding | Padding bytes are not all zero | +| Trailing data | Bytes remain after the root node closes | + +### Parser design (streaming, zero-alloc where possible) + +```rust +/// Token-level reader over an AsyncRead stream. +struct NarReader { + inner: R, + buf: [u8; 8], // reused for every u64/padding read + depth: u16, // current nesting depth + max_depth: u16, // configurable limit (default 256) + bytes_read: u64, // for NarHash computation + hasher: Sha256, // computes hash inline during parse +} + +impl NarReader { + /// Read a length-prefixed byte string. + async fn read_bytes(&mut self) -> Result>; + + /// Read a length-prefixed string, validate UTF-8. + async fn read_str(&mut self) -> Result; + + /// Read exactly the expected string token, error otherwise. + async fn expect_str(&mut self, expected: &str) -> Result<()>; + + /// Read a u64 (8 bytes LE). + async fn read_u64(&mut self) -> Result; + + /// Skip padding bytes (0..7 after a string), validate all zero. + async fn skip_padding(&mut self, len: u64) -> Result<()>; +} +``` + +The parser never loads the entire NAR into memory. It streams entries +and writes each file/symlink/directory to disk as it encounters them. + +### Extractor design + +```rust +/// Extracts a NAR stream into a target directory. +/// +/// Safety invariants: +/// - Target dir must be inside /var/lib/niphas/cache/ +/// - Extraction happens into a .tmp- dir, atomically renamed on success +/// - Symlinks are created but NEVER followed during extraction +/// - All paths resolved relative to extraction root, never escaping +struct NarExtractor { + root: PathBuf, // e.g. /var/lib/niphas/cache/.tmp-/ + max_nar_size: u64, // from narinfo NarSize +} + +impl NarExtractor { + /// Extract NAR from reader into root directory. + /// Returns the SHA-256 hash of the NAR byte stream. + async fn extract( + &self, + reader: R, + ) -> Result; +} +``` + +#### Symlink safety (CVE-2024-45593 mitigation) + +The Nix reference implementation had a critical vuln (CVSS 9.0) where +a NAR containing a symlink followed by a directory of the same name +could write files outside the extraction root. + +Mitigation: + +```rust +fn extract_entry(&self, parent_fd: RawFd, name: &str, node: Node) -> Result<()> { + // Validate entry name + if name.contains('/') || name.contains('\0') || name == "." || name == ".." || name.is_empty() { + return Err(NarError::InvalidEntryName(name.into())); + } + + match node { + Node::Regular { executable, contents } => { + // O_NOFOLLOW: if `name` is an existing symlink, this fails + // O_EXCL: fail if entry already exists (catches duplicate names) + let fd = openat(parent_fd, name, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, mode)?; + write_all(fd, contents)?; + if executable { + fchmod(fd, 0o555)?; + } else { + fchmod(fd, 0o444)?; + } + } + Node::Symlink { target } => { + // Create symlink. Do NOT validate target -- Nix allows + // arbitrary symlink targets (e.g. /nix/store/... or relative). + // Safety comes from never following symlinks, not from restricting targets. + symlinkat(target, parent_fd, name)?; + } + Node::Directory { entries } => { + // O_NOFOLLOW on mkdirat: if `name` is a symlink, this fails + mkdirat(parent_fd, name, 0o755)?; + let dir_fd = openat(parent_fd, name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW, 0)?; + for entry in entries { + self.extract_entry(dir_fd, &entry.name, entry.node)?; + } + } + } + Ok(()) +} +``` + +Key: every filesystem operation uses `*at()` syscalls (`openat`, `mkdirat`, +`symlinkat`) relative to a parent fd. Combined with `O_NOFOLLOW`, this +makes symlink-based escapes impossible regardless of NAR contents. + +#### Atomic extraction + +``` +1. mkdtemp("/var/lib/niphas/cache/.tmp-XXXXXX") +2. extract NAR into temp dir +3. verify computed NarHash == expected NarHash +4. rename(temp_dir, "/var/lib/niphas/cache/-/") +5. if rename fails (exists): another thread won the race, delete temp +``` + +If the process crashes mid-extraction, `.tmp-*` dirs are cleaned on +driver startup (they are always incomplete/unverified). + +--- + +## .narinfo format + +Plain text, one field per line, colon-separated. + +``` +StorePath: /nix/store/abc123-hello-2.12.1 +URL: nar/1w1fff338fvdw53sqgamddn1b2xgds473pv6y13gizdbqjv4i5p3.nar.zst +Compression: zstd +FileHash: sha256:1w1fff... +FileSize: 12345 +NarHash: sha256:1impfh... +NarSize: 45678 +References: abc123-hello-2.12.1 def456-glibc-2.38 ghi789-zlib-1.3.1 +Deriver: xyz-hello-2.12.1.drv +Sig: cache.nixos.org-1:GrGV/Ls10Tzo...base64... +Sig: company-cache-1:aBcD...base64... +``` + +### Fields + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| StorePath | yes | store path | Full `/nix/store/-` | +| URL | yes | relative URL | Path to compressed NAR file | +| Compression | yes | enum | `none`, `xz`, `bzip2`, `zstd`, `br`, `lzip`, `lz4` | +| FileHash | yes | `:` | Hash of compressed file | +| FileSize | yes | u64 | Size of compressed file in bytes | +| NarHash | yes | `:` | Hash of uncompressed NAR stream | +| NarSize | yes | u64 | Size of uncompressed NAR in bytes | +| References | no | space-separated | Runtime dependencies (basename only, no `/nix/store/` prefix) | +| Deriver | no | string | The `.drv` that produced this path | +| Sig | no (repeatable) | `:` | Ed25519 signatures | +| CA | no | string | Content address (for CA paths) | + +### Parser + +```rust +pub struct NarInfo { + pub store_path: StorePath, + pub url: String, + pub compression: Compression, + pub file_hash: NixHash, + pub file_size: u64, + pub nar_hash: NixHash, + pub nar_size: u64, + pub references: Vec, // basenames only + pub deriver: Option, + pub signatures: Vec, + pub ca: Option, +} + +#[derive(Debug, Clone, Copy)] +pub enum Compression { + None, + Xz, + Bzip2, + Zstd, + Br, + Lzip, + Lz4, +} + +pub struct NarSignature { + pub key_name: String, // e.g. "cache.nixos.org-1" + pub signature: [u8; 64], // Ed25519 signature bytes +} + +impl NarInfo { + /// Parse from narinfo text content. + pub fn parse(input: &str) -> Result { + let mut store_path = None; + let mut url = None; + // ... field-by-field parsing + + for line in input.lines() { + let (key, value) = line.split_once(": ") + .ok_or(NarInfoError::MalformedLine)?; + match key { + "StorePath" => store_path = Some(StorePath::parse(value)?), + "URL" => url = Some(value.to_owned()), + "Compression" => compression = Some(Compression::parse(value)?), + "NarHash" => nar_hash = Some(NixHash::parse(value)?), + "NarSize" => nar_size = Some(value.parse::()?), + "FileHash" => file_hash = Some(NixHash::parse(value)?), + "FileSize" => file_size = Some(value.parse::()?), + "References" => { + references = value.split_whitespace() + .map(StorePathRef::parse) + .collect::>>()?; + } + "Sig" => { + signatures.push(NarSignature::parse(value)?); + } + "Deriver" => deriver = Some(value.to_owned()), + "CA" => ca = Some(value.to_owned()), + _ => {} // ignore unknown fields (forward compat) + } + } + + Ok(NarInfo { /* ... */ }) + } +} +``` + +--- + +## Nix hashing + +Nix uses SHA-256 with a custom base32 encoding. + +### Nix-base32 + +Alphabet: `0123456789abcdfghijklmnpqrsvwxyz` (32 chars, omits `e o t u`). + +Encoding is **reversed** compared to standard base32: the least significant +bits come first. + +```rust +const NIX_BASE32_CHARS: &[u8; 32] = b"0123456789abcdfghijklmnpqrsvwxyz"; + +/// Encode bytes to Nix base32. +pub fn to_nix_base32(input: &[u8]) -> String { + let len = (input.len() * 8 + 4) / 5; // ceil(bits / 5) + let mut out = String::with_capacity(len); + + for n in (0..len).rev() { + let b = n * 5; + let byte_idx = b / 8; + let bit_idx = b % 8; + + let mut c = (input[byte_idx] >> bit_idx) & 0x1f; + if bit_idx > 3 && byte_idx + 1 < input.len() { + c |= input[byte_idx + 1] << (8 - bit_idx); + c &= 0x1f; + } + out.push(NIX_BASE32_CHARS[c as usize] as char); + } + + out +} + +/// Decode Nix base32 to bytes. +pub fn from_nix_base32(input: &str) -> Result, NixHashError> { + // Reverse of the above +} +``` + +### NixHash type + +```rust +pub struct NixHash { + pub algo: HashAlgo, + pub digest: Vec, // raw bytes +} + +#[derive(Debug, Clone, Copy)] +pub enum HashAlgo { + Sha256, + Sha512, + Sha1, // legacy, some old paths use this + Md5, // legacy +} + +impl NixHash { + /// Parse "sha256:1impfh..." (Nix-base32 encoded). + pub fn parse(s: &str) -> Result { + let (algo_str, hash_str) = s.split_once(':') + .ok_or(NixHashError::MissingAlgo)?; + let algo = match algo_str { + "sha256" => HashAlgo::Sha256, + "sha512" => HashAlgo::Sha512, + "sha1" => HashAlgo::Sha1, + "md5" => HashAlgo::Md5, + _ => return Err(NixHashError::UnknownAlgo(algo_str.into())), + }; + let digest = from_nix_base32(hash_str)?; + Ok(NixHash { algo, digest }) + } + + /// Verify that data matches this hash. + pub fn verify(&self, data: &[u8]) -> Result<(), NixHashError> { + use sha2::{Sha256, Digest}; + match self.algo { + HashAlgo::Sha256 => { + let computed = Sha256::digest(data); + if computed.as_slice() != self.digest { + return Err(NixHashError::Mismatch); + } + } + // other algos... + } + Ok(()) + } +} +``` + +### Store path hash + +The 32-char hash in `/nix/store/-` is **not** a SHA-256 of +the contents. It is derived from the derivation inputs via a truncated +SHA-256, then Nix-base32 encoded. For Niphas, we don't need to compute +this -- we receive it from `nix eval`. We only need to parse and validate it. + +```rust +pub struct StorePath { + pub hash: [u8; 20], // 20 bytes = 32 Nix-base32 chars + pub name: String, // e.g. "hello-2.12.1" +} + +pub struct StorePathRef(String); // basename only, e.g. "abc123-hello-2.12.1" + +impl StorePath { + /// Parse "/nix/store/abc123...-hello-2.12.1" + pub fn parse(s: &str) -> Result { + let rest = s.strip_prefix("/nix/store/") + .ok_or(StorePathError::InvalidPrefix)?; + if rest.len() < 34 { // 32 hash + "-" + at least 1 char name + return Err(StorePathError::TooShort); + } + let hash_str = &rest[..32]; + let name = &rest[33..]; // skip the "-" + let hash_bytes = from_nix_base32(hash_str)?; + Ok(StorePath { + hash: hash_bytes.try_into().map_err(|_| StorePathError::InvalidHash)?, + name: name.to_owned(), + }) + } + + /// The 32-char Nix-base32 hash prefix, used for .narinfo lookup. + pub fn hash_str(&self) -> String { + to_nix_base32(&self.hash) + } +} +``` + +--- + +## Ed25519 signature verification + +### Fingerprint format + +The signature covers a "fingerprint" string: + +``` +1;;;; +``` + +Where: +- `1` is the fingerprint version +- `` is the full path (e.g. `/nix/store/abc123-hello-2.12.1`) +- `` is `sha256:` (the NarHash field from narinfo) +- `` is decimal NarSize +- `` is comma-separated sorted store paths of References + +Example: +``` +1;/nix/store/abc123-hello-2.12.1;sha256:1impfh...;45678;/nix/store/def456-glibc-2.38,/nix/store/ghi789-zlib-1.3.1 +``` + +### Verification + +Dependencies: `ed25519-dalek` (already audited, widely used). + +```rust +use ed25519_dalek::{Signature, VerifyingKey, Verifier}; + +pub struct TrustedKey { + pub name: String, // e.g. "cache.nixos.org-1" + pub pubkey: VerifyingKey, // 32-byte Ed25519 public key +} + +impl TrustedKey { + /// Parse "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" + pub fn parse(s: &str) -> Result { + let (name, key_b64) = s.split_once(':') + .ok_or(SignatureError::MalformedKey)?; + let key_bytes = base64_decode(key_b64)?; + let pubkey = VerifyingKey::from_bytes( + &key_bytes.try_into().map_err(|_| SignatureError::InvalidKeyLength)? + )?; + Ok(TrustedKey { name: name.to_owned(), pubkey }) + } +} + +/// Compute the fingerprint string that signatures cover. +fn compute_fingerprint(narinfo: &NarInfo) -> String { + let refs: Vec = narinfo.references.iter() + .map(|r| format!("/nix/store/{}", r.0)) + .collect(); + let mut refs_sorted = refs.clone(); + refs_sorted.sort(); + + format!( + "1;{};{};{};{}", + narinfo.store_path.to_string(), + narinfo.nar_hash.to_nix_string(), // "sha256:" + narinfo.nar_size, + refs_sorted.join(",") + ) +} + +/// Verify that at least one signature matches a trusted key. +pub fn verify_narinfo( + narinfo: &NarInfo, + trusted_keys: &[TrustedKey], +) -> Result<(), SignatureError> { + let fingerprint = compute_fingerprint(narinfo); + + for sig in &narinfo.signatures { + for key in trusted_keys { + if sig.key_name == key.name { + let signature = Signature::from_bytes(&sig.signature); + if key.pubkey.verify(fingerprint.as_bytes(), &signature).is_ok() { + return Ok(()); + } + } + } + } + + Err(SignatureError::NoTrustedSignature { + store_path: narinfo.store_path.to_string(), + signatures_present: narinfo.signatures.iter() + .map(|s| s.key_name.clone()) + .collect(), + }) +} +``` + +### Invariant + +**An unverified NAR never reaches a pod.** The verification chain: + +``` +1. Fetch .narinfo +2. Verify signature (fingerprint covers NarHash) +3. Fetch compressed NAR +4. Verify FileHash (hash of compressed bytes) +5. Decompress +6. Verify NarHash (hash of decompressed NAR stream) -- computed inline during parse +7. Extract to temp dir +8. Atomic rename to cache dir +``` + +If any step fails, the NAR is discarded. No partial state persists. + +--- + +## Binary cache HTTP client + +### Protocol + +``` +GET /nix-cache-info --> cache metadata +GET /.narinfo --> store path metadata +GET /nar/.nar.zstd --> compressed NAR archive +``` + +The `` is the 32-char Nix-base32 prefix from the +store path. E.g. for `/nix/store/abc123...-hello`, the request is +`GET /abc123....narinfo`. + +### Client design + +```rust +pub struct CacheClient { + http: reqwest::Client, + caches: Vec, // ordered by priority + trusted_keys: Vec, +} + +pub struct CacheConfig { + pub url: String, // e.g. "https://cache.nixos.org" + pub priority: u32, + pub public_keys: Vec, // key names trusted for this cache +} + +impl CacheClient { + /// Fetch narinfo for a store path. Tries caches in priority order. + pub async fn fetch_narinfo( + &self, + store_path: &StorePath, + ) -> Result<(NarInfo, String), CacheError> { + let hash = store_path.hash_str(); + + for cache in &self.caches { + let url = format!("{}/{}.narinfo", cache.url, hash); + match self.http.get(&url).send().await { + Ok(resp) if resp.status() == 200 => { + let text = resp.text().await?; + let narinfo = NarInfo::parse(&text)?; + verify_narinfo(&narinfo, &self.trusted_keys)?; + return Ok((narinfo, cache.url.clone())); + } + Ok(resp) if resp.status() == 404 => continue, + Ok(resp) => { + tracing::warn!(cache = %cache.url, status = %resp.status(), "unexpected status"); + continue; + } + Err(e) => { + tracing::warn!(cache = %cache.url, error = %e, "cache unreachable"); + continue; + } + } + } + + Err(CacheError::NotFound(store_path.to_string())) + } + + /// Download and verify a NAR. Returns path to extracted dir. + pub async fn fetch_nar( + &self, + narinfo: &NarInfo, + cache_url: &str, + cache_dir: &Path, + ) -> Result { + let nar_url = format!("{}/{}", cache_url, narinfo.url); + + // Stream download + let resp = self.http.get(&nar_url).send().await?; + let compressed_bytes = resp.bytes().await?; + + // Verify FileHash (compressed) + narinfo.file_hash.verify(&compressed_bytes)?; + + // Decompress + let decompressed = decompress(&compressed_bytes, narinfo.compression)?; + + // Extract NAR (NarHash verified inline during extraction) + let tmp_dir = cache_dir.join(format!(".tmp-{}", uuid())); + std::fs::create_dir_all(&tmp_dir)?; + + let extractor = NarExtractor::new(&tmp_dir, narinfo.nar_size); + let computed_hash = extractor.extract(&mut &decompressed[..]).await?; + + // Verify NarHash + if computed_hash != narinfo.nar_hash { + std::fs::remove_dir_all(&tmp_dir)?; + return Err(CacheError::NarHashMismatch); + } + + // Atomic rename + let final_dir = cache_dir.join(narinfo.store_path.basename()); + match std::fs::rename(&tmp_dir, &final_dir) { + Ok(()) => Ok(final_dir), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + // Race: another thread/process extracted the same path + std::fs::remove_dir_all(&tmp_dir)?; + Ok(final_dir) + } + Err(e) => Err(e.into()), + } + } +} +``` + +### Streaming for large NARs + +For NARs larger than a configurable threshold (e.g. 64 MB), avoid +loading the entire compressed body into memory: + +```rust +pub async fn fetch_nar_streaming( + &self, + narinfo: &NarInfo, + cache_url: &str, + cache_dir: &Path, +) -> Result { + let nar_url = format!("{}/{}", cache_url, narinfo.url); + let resp = self.http.get(&nar_url).send().await?; + let byte_stream = resp.bytes_stream(); + + // Pipeline: download --> hash(compressed) --> decompress --> hash(nar) --> extract + // + // FileHash is verified by hashing the compressed stream as it passes through. + // NarHash is verified by hashing the decompressed stream during extraction. + // If either hash fails, extraction is aborted and temp dir removed. + + let compressed_hasher = HashingReader::new(byte_stream, narinfo.file_hash.algo); + let decompressor = ZstdDecoder::new(compressed_hasher); // or xz, etc. + let nar_reader = NarReader::new(decompressor); + + let tmp_dir = cache_dir.join(format!(".tmp-{}", uuid())); + std::fs::create_dir_all(&tmp_dir)?; + + let extractor = NarExtractor::new(&tmp_dir, narinfo.nar_size); + let (computed_nar_hash, computed_file_hash) = extractor + .extract_streaming(nar_reader) + .await?; + + // Verify both hashes + if computed_file_hash != narinfo.file_hash { + std::fs::remove_dir_all(&tmp_dir)?; + return Err(CacheError::FileHashMismatch); + } + if computed_nar_hash != narinfo.nar_hash { + std::fs::remove_dir_all(&tmp_dir)?; + return Err(CacheError::NarHashMismatch); + } + + // Atomic rename + let final_dir = cache_dir.join(narinfo.store_path.basename()); + std::fs::rename(&tmp_dir, &final_dir)?; + Ok(final_dir) +} +``` + +--- + +## Closure resolution (pure HTTP, no Nix) + +The full closure (all transitive runtime dependencies) can be resolved +by recursively fetching `.narinfo` files from the binary cache. + +Each `.narinfo` has a `References` field listing immediate dependencies +(basenames only). Walk this graph to get the complete closure. + +```rust +use std::collections::HashSet; + +impl CacheClient { + /// Resolve the full closure of a store path by walking .narinfo References. + /// Returns all store paths in the closure, including the root. + pub async fn resolve_closure( + &self, + root: &StorePath, + ) -> Result, CacheError> { + let mut visited: HashSet = HashSet::new(); + let mut queue: Vec = vec![root.clone()]; + let mut closure: Vec = Vec::new(); + + while let Some(path) = queue.pop() { + let basename = path.basename(); + if visited.contains(&basename) { + continue; + } + visited.insert(basename.clone()); + + let (narinfo, _cache_url) = self.fetch_narinfo(&path).await?; + + // Enqueue unvisited references + for ref_basename in &narinfo.references { + if !visited.contains(&ref_basename.0) { + let ref_path = StorePath::from_basename(&ref_basename.0)?; + queue.push(ref_path); + } + } + + closure.push(narinfo); + } + + Ok(closure) + } + + /// Parallel closure resolution: fetch multiple narinfos concurrently. + pub async fn resolve_closure_parallel( + &self, + root: &StorePath, + concurrency: usize, // e.g. 16 + ) -> Result, CacheError> { + // BFS with bounded concurrency using tokio::sync::Semaphore + // or futures::stream::buffer_unordered + } +} +``` + +### When closure resolution happens + +``` +1. User creates NiphasWorkload CR +2. niphas-eval evaluates flake via Nix C API (in-process FFI) --> gets outPath +3. niphas-eval calls resolve_closure(outPath) via HTTP to binary cache +4. niphas-eval writes closure_paths to CRD status +5. operator reads closure_paths from status, passes to CSI via volumeAttributes +6. CSI driver fetches individual NARs (already knows the full list) +``` + +Both steps happen inside the niphas-eval process. Step 2 uses the Nix C API +via FFI (no process spawn, no Job). Step 3 is pure HTTP -- no Nix needed. + +--- + +## Decompression + +Support the compression formats Nix binary caches use: + +| Format | Crate | Notes | +|--------|-------|-------| +| zstd | `async-compression` (already in workspace) | Most common for modern caches | +| xz | `async-compression` + `xz2` feature | cache.nixos.org uses this | +| bzip2 | `async-compression` + `bzip2` feature | Legacy | +| br (brotli) | `async-compression` + `brotli` feature | Rare | +| none | passthrough | Uncompressed | + +```rust +fn decompress(data: &[u8], compression: Compression) -> Result> { + match compression { + Compression::None => Ok(data.to_vec()), + Compression::Zstd => { + let mut decoder = zstd::Decoder::new(data)?; + let mut out = Vec::new(); + decoder.read_to_end(&mut out)?; + Ok(out) + } + Compression::Xz => { + let mut decoder = xz2::read::XzDecoder::new(data); + let mut out = Vec::new(); + decoder.read_to_end(&mut out)?; + Ok(out) + } + // ... + } +} +``` + +For streaming, use `async-compression::tokio::bufread::*Decoder`. + +--- + +## Dependency summary + +All new deps for the Nix wire format implementation: + +| Crate | Purpose | Already in workspace? | +|-------|---------|-----------------------| +| `sha2` | SHA-256 for NarHash/FileHash | No, add | +| `ed25519-dalek` | Ed25519 signature verification | No, add | +| `base64` | Decode signature bytes and public keys | No, add | +| `reqwest` | HTTP client for binary cache | No, add | +| `uuid` | Temp dir naming | No, add (or use random bytes) | +| `async-compression` | zstd/xz/bzip2 decompression | Yes | +| `tokio` | Async runtime | Yes | +| `serde` | NarInfo struct serialization | Yes | +| `tracing` | Logging | Yes | +| `thiserror` | Error types | Yes | diff --git a/OPERATOR.md b/OPERATOR.md new file mode 100644 index 0000000..ff97639 --- /dev/null +++ b/OPERATOR.md @@ -0,0 +1,524 @@ +# Niphas -- Operator Design + +niphas-operator reconciles `NiphasWorkload` CRDs into running Kubernetes +workloads. It is the central control plane component. + +## Reconciliation state machine + +``` + ┌──────────────┐ + CRD created │ │ + ────────────>│ Pending │ + │ │ + └──────┬───────┘ + │ + set phase = Evaluating + call eval webhook + │ + ┌──────▼───────┐ + │ │ eval error + │ Evaluating │──────────────┐ + │ │ │ + └──────┬───────┘ │ + │ │ + eval returns EvalResult │ + set storePath, closurePaths │ + set phase = Provisioning │ + │ │ + ┌──────▼───────┐ │ + │ │ create error │ + │ Provisioning │──────────┐ │ + │ │ │ │ + └──────┬───────┘ │ │ + │ │ │ + child resources created │ │ + pods becoming ready │ │ + │ │ │ + ┌──────▼───────┐ │ │ + │ │ │ │ + │ Running │ │ │ + │ │ │ │ + └──────┬───────┘ │ │ + │ ┌─────▼────▼──┐ + spec changed │ │ + (generation++) │ Failed │ + ───────────────> │ │ + back to Evaluating └─────────────┘ +``` + +### Transition rules + +| From | To | Trigger | +|------|----|---------| +| (none) | Pending | CRD created | +| Pending | Evaluating | Operator sees new resource | +| Evaluating | Provisioning | Eval webhook returns success | +| Evaluating | Failed | Eval error (timeout, flake not allowed, eval crash) | +| Provisioning | Running | At least 1 replica is Ready | +| Provisioning | Failed | Child resource creation fails | +| Running | Evaluating | `observedGeneration < generation` (spec updated) | +| Running | Degraded | Some replicas not ready, NAR corruption detected | +| Failed | Evaluating | User updates spec (generation increments) | + +## Reconciler loop + +The operator uses kube-rs `Controller` with a single reconciler function. + +```rust +async fn reconcile( + workload: Arc, + ctx: Arc, +) -> Result { + let name = workload.name_any(); + let ns = workload.namespace().unwrap(); + let generation = workload.metadata.generation.unwrap_or(0); + + // 1. Check if we already processed this generation + let status = workload.status.as_ref(); + let observed = status.and_then(|s| s.observed_generation).unwrap_or(0); + + if observed >= generation && status.map(|s| s.phase.as_str()) == Some("Running") { + // Nothing to do. Requeue in 5 minutes for health check. + return Ok(Action::requeue(Duration::from_secs(300))); + } + + // 2. Evaluate (if needed) + let eval_result = if needs_eval(&workload, observed, generation) { + set_phase(&ctx, &workload, "Evaluating").await?; + match call_eval_webhook(&ctx, &workload).await { + Ok(result) => result, + Err(e) => { + set_failed(&ctx, &workload, "EvalFailed", &e.to_string()).await?; + return Ok(Action::requeue(Duration::from_secs(30))); + } + } + } else { + // Use cached eval result from status + EvalResult::from_status(status.unwrap()) + }; + + // 3. Provision child resources + set_phase(&ctx, &workload, "Provisioning").await?; + apply_child_resources(&ctx, &workload, &eval_result).await?; + + // 4. Check readiness + let ready = count_ready_replicas(&ctx, &workload).await?; + let desired = workload.spec.replicas.unwrap_or(1); + + let phase = if ready >= desired { "Running" } else { "Provisioning" }; + update_status(&ctx, &workload, phase, &eval_result, ready, generation).await?; + + // 5. Requeue + let interval = if ready < desired { + Duration::from_secs(5) // poll frequently while provisioning + } else { + Duration::from_secs(300) // stable, check every 5 min + }; + Ok(Action::requeue(interval)) +} +``` + +### needs_eval() + +```rust +fn needs_eval(workload: &NiphasWorkload, observed: i64, generation: i64) -> bool { + // New or updated resource + if observed < generation { return true; } + + // No store path yet (first reconciliation after crash) + let status = workload.status.as_ref(); + status.and_then(|s| s.store_path.as_ref()).is_none() +} +``` + +## Eval webhook integration + +The operator calls niphas-eval via HTTP (internal Service, not public): + +### Request + +``` +POST http://niphas-eval.niphas-system.svc:8443/evaluate +Content-Type: application/json +``` + +```json +{ + "flakeRef": "github:myorg/myapp", + "attribute": "packages.x86_64-linux.default", + "revision": "a1b2c3d4e5f6", + "binaryCache": "https://cache.company.com" +} +``` + +`revision` and `binaryCache` are optional. If `binaryCache` is omitted, +niphas-eval uses the caches from its own ConfigMap. + +### Response (success) + +```json +{ + "storePath": "/nix/store/abc123-myapp-1.0.0", + "name": "myapp-1.0.0", + "mainProgram": "myapp", + "closurePaths": [ + "/nix/store/abc123-myapp-1.0.0", + "/nix/store/def456-glibc-2.38", + "/nix/store/ghi789-openssl-3.1" + ] +} +``` + +### Response (error) + +```json +{ + "error": "StorePathNotCached", + "message": "/nix/store/abc123-myapp-1.0.0 not found in any binary cache" +} +``` + +Error codes: + +| Code | Meaning | Operator action | +|------|---------|-----------------| +| `FlakeNotAllowed` | flakeRef not in allowlist | Set Failed, reason=FlakeNotAllowed | +| `EvalFailed` | Nix evaluation error | Set Failed, reason=EvalFailed | +| `EvalTimeout` | Evaluation exceeded timeout | Set Failed, reason=EvalTimeout | +| `StorePathNotCached` | NAR not in any binary cache | Set Failed, reason=StorePathNotCached | +| `ClosureResolutionFailed` | Could not resolve full closure | Set Failed, reason=ClosureResolutionFailed | + +### Timeout + +The operator sets a per-request timeout of 300 seconds (configurable). +If the eval webhook does not respond within this window, the operator +cancels the request and sets `Failed` with reason `EvalTimeout`. + +### Multi-architecture + +When `spec.architectures` is set, the operator calls the eval webhook +once per architecture, substituting `{arch}` in the attribute: + +``` +POST /evaluate { attribute: "packages.x86_64-linux.default", ... } +POST /evaluate { attribute: "packages.aarch64-linux.default", ... } +``` + +These calls are made concurrently (`tokio::join!`). + +## Child resource generation + +After successful eval, the operator creates/updates these child resources: + +### Deployment + +Generated for every NiphasWorkload. If `spec.architectures` is set, +one Deployment per architecture. + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: # or - for multi-arch + namespace: + ownerReferences: [...] + labels: + niphas.io/workload: + niphas.io/managed-by: niphas-operator +spec: + replicas: + selector: + matchLabels: + niphas.io/workload: + template: + metadata: + labels: + niphas.io/workload: + niphas.io/store-hash: + spec: + nodeSelector: + niphas.io/store: "true" + # + spec.nodeSelector (merged) + # + kubernetes.io/arch: (if multi-arch) + + tolerations: + # fast failover (always injected) + - key: node.kubernetes.io/not-ready + operator: Exists + effect: NoExecute + tolerationSeconds: 30 + - key: node.kubernetes.io/unreachable + operator: Exists + effect: NoExecute + tolerationSeconds: 30 + # + spec.tolerations (appended) + + topologySpreadConstraints: # only if replicas >= 2 + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: DoNotSchedule + labelSelector: + matchLabels: + niphas.io/workload: + - maxSkew: 1 + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + niphas.io/workload: + + containers: + - name: app + image: ghcr.io/fullzer4/niphas-runner:latest + command: [] + args: + env: + ports: + resources: + livenessProbe: + readinessProbe: + startupProbe: + volumeMounts: + - name: nix-store + mountPath: /nix/store + readOnly: true + # + spec.extraVolumeMounts + + volumes: + - name: nix-store + csi: + driver: niphas.io.csi + volumeAttributes: + closurePaths: "" + # + spec.extraVolumes +``` + +### Service (conditional) + +Created only if `spec.service` is set. + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: + namespace: + ownerReferences: [...] +spec: + type: + selector: + niphas.io/workload: + ports: +``` + +### Ingress (conditional) + +Created only if `spec.ingress` is set and `spec.ingress.enabled` is true. + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: + namespace: + ownerReferences: [...] +spec: + ingressClassName: + rules: + tls: +``` + +### PodDisruptionBudget (conditional) + +Created only if `spec.replicas >= 2`. + +```yaml +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: -pdb + namespace: + ownerReferences: [...] +spec: + maxUnavailable: 1 + selector: + matchLabels: + niphas.io/workload: + unhealthyPodEvictionPolicy: AlwaysAllow +``` + +## Server-side apply + +The operator uses **server-side apply** (SSA) for all child resources: + +```rust +let patch = Patch::Apply(&deployment); +let params = PatchParams::apply("niphas-operator").force(); +deployments.patch(&name, ¶ms, &patch).await?; +``` + +Benefits: +- Idempotent: applying the same spec twice is a no-op +- Conflict detection: if another controller modified the resource, SSA + merges fields by field manager +- No read-modify-write race: atomic update + +## Update and rollout + +When the user changes `flakeRef`, `attribute`, `revision`, or any spec field: + +1. `metadata.generation` increments (K8s does this automatically) +2. Reconciler detects `observedGeneration < generation` +3. If `flakeRef`, `attribute`, or `revision` changed: re-evaluates +4. If store path changed: updates Deployment's CSI volumeAttributes + command +5. K8s performs standard rolling update (default strategy: `RollingUpdate`) +6. Old pods drain, new pods mount new closure via CSI +7. Operator watches replica readiness, updates status + +For spec-only changes (e.g. `replicas`, `resources`, `env`) that don't +affect the Nix closure, the operator skips eval and directly patches +the Deployment. + +```rust +fn needs_eval(workload: &NiphasWorkload, observed: i64, generation: i64) -> bool { + if observed < generation { + // Check if only non-closure fields changed + let prev = workload.status.as_ref(); + let same_flake = prev.map(|s| /* compare flakeRef, attribute, revision */); + return !same_flake.unwrap_or(false); + } + false +} +``` + +## Finalizer + +The operator adds `niphas.io/workload-cleanup` to every NiphasWorkload. + +### Deletion flow + +``` +1. User deletes NiphasWorkload +2. K8s sets deletionTimestamp (object is now Terminating) +3. Reconciler runs: + a. Sees deletionTimestamp is set + b. Child resources have ownerReferences --> K8s GC deletes them + c. Operator removes the finalizer +4. K8s completes deletion +``` + +The finalizer exists to ensure the reconciler runs at least once during +deletion (e.g. to emit events, update metrics). The actual resource +cleanup is handled by K8s garbage collection via ownerReferences. + +```rust +async fn reconcile(workload: Arc, ctx: Arc) -> Result { + // Handle deletion first + if workload.metadata.deletion_timestamp.is_some() { + // Emit deletion event + ctx.recorder.publish(Event { + type_: EventType::Normal, + reason: "Deleting".into(), + note: Some(format!("Cleaning up workload {}", workload.name_any())), + action: "Delete".into(), + secondary: None, + }).await?; + + // Remove finalizer (child resources cleaned up by ownerReferences) + finalizer::remove(&ctx.client, &workload).await?; + return Ok(Action::await_change()); + } + + // Ensure finalizer exists + if !finalizer::exists(&workload) { + finalizer::add(&ctx.client, &workload).await?; + return Ok(Action::requeue(Duration::from_secs(0))); + } + + // ... normal reconciliation +} +``` + +## Leader election + +The operator runs with 2-3 replicas for HA. Only the leader reconciles. + +```rust +let lease = LeaseLock::new(client.clone(), "niphas-operator", LeaseLockParams { + holder_id: pod_name, + lease_ttl: Duration::from_secs(15), + retry_period: Duration::from_secs(5), + renew_period: Duration::from_secs(5), +}); +``` + +| Parameter | Value | Rationale | +|-----------|-------|-----------| +| TTL | 15s | Time before a dead leader is considered gone | +| Renew | 5s | Leader heartbeat interval | +| Retry | 5s | Non-leader check interval | + +Failover takes at most ~15 seconds. Reconciliation is idempotent, +so brief dual-leader windows (unlikely but possible) are safe. + +## Error handling and retry + +| Error type | Retry strategy | +|------------|---------------| +| Eval webhook timeout | Requeue after 30s, retry up to 3 times, then set Failed | +| Eval webhook unreachable | Requeue after 10s, exponential backoff up to 5 min | +| Child resource conflict (SSA) | Immediate retry (SSA handles conflicts) | +| K8s API transient error | Requeue after 5s, exponential backoff | +| K8s API permanent error (403, 404) | Set Failed, do not retry | + +The kube-rs `Controller` provides built-in exponential backoff for +reconciler errors. The operator supplements this with domain-specific +retry logic for eval webhook calls. + +## Watches + +The controller watches these resources for changes: + +| Resource | Why | +|----------|-----| +| `NiphasWorkload` | Primary resource (triggers reconciliation) | +| `Deployment` (owned) | Detect readiness changes, rollout progress | +| `Pod` (owned) | Count ready replicas, detect failures | + +Watches use `ownerReferences` filtering: only Deployments/Pods owned +by a NiphasWorkload trigger reconciliation of that specific workload. + +```rust +Controller::new(workloads, Default::default()) + .owns(deployments, Default::default()) + .owns(pods, Default::default()) + .run(reconcile, error_policy, ctx) +``` + +## Health probes + +The operator exposes an Axum HTTP server on port 8080: + +| Endpoint | Purpose | +|----------|---------| +| `/healthz` | Liveness probe. Returns 200 if the process is alive | +| `/readyz` | Readiness probe. Returns 200 if the leader election is resolved and the controller is watching | +| `/metrics` | Prometheus metrics (reconciliation count, duration, errors) | + +## Events + +The operator emits Kubernetes Events for key lifecycle transitions: + +| Event | Type | Reason | +|-------|------|--------| +| Eval started | Normal | `EvalStarted` | +| Eval succeeded | Normal | `EvalSucceeded` | +| Eval failed | Warning | `EvalFailed` | +| Flake not allowed | Warning | `FlakeNotAllowed` | +| Store path not cached | Warning | `StorePathNotCached` | +| Child resources created | Normal | `Provisioned` | +| All replicas ready | Normal | `Available` | +| Replica degraded | Warning | `Degraded` | +| Workload deleting | Normal | `Deleting` | + +Events are visible via `kubectl describe niphasworkload `. diff --git a/PROBLEM.md b/PROBLEM.md new file mode 100644 index 0000000..960e10f --- /dev/null +++ b/PROBLEM.md @@ -0,0 +1,473 @@ +# Niphas -- Problemas Conhecidos e Plano de Correção + +Análise completa da codebase em 2026-06-09. 80 testes passando, 0 warnings. + +--- + +## Severidade: Crítico + +### P1. Injeção de expressão Nix via flake_ref/attribute + +**Arquivo:** `niphas-eval/src/evaluator.rs:92-98` + +O `flake_ref` e `attribute` do usuário são interpolados diretamente numa string Nix: + +```rust +let expr = format!( + r#"let drv = (builtins.getFlake "{pinned_ref}").{attribute}; in ..."# +); +``` + +Um flake ref contendo `"` ou escape sequences Nix pode injetar expressões arbitrárias. +O allowlist (glob matching) valida apenas o padrão, não o conteúdo de caracteres especiais. + +**Impacto:** Execução arbitrária de código Nix no node do eval service. + +**Correção:** + +1. Validar `flake_ref` com regex restrita: `^[a-zA-Z][a-zA-Z0-9+\-\.]*:[a-zA-Z0-9/_\-\.]+$` +2. Validar `attribute` com regex: `^[a-zA-Z_][a-zA-Z0-9_\-]*(\.[a-zA-Z_][a-zA-Z0-9_\-]*)*$` +3. Rejeitar qualquer input com `"`, `\`, `$`, `;`, `(`, `)` antes da interpolação +4. Adicionar funções `validate_flake_ref()` e `validate_attribute()` em `niphas-core/src/eval.rs` + +``` +niphas-core/src/eval.rs -- adicionar validate_flake_ref(), validate_attribute() +niphas-eval/src/evaluator.rs -- chamar validações antes de construir expr +``` + +--- + +### P2. Sem leader election no operator + +**Arquivo:** `niphas-operator/src/main.rs` + +Múltiplas réplicas do operator reconciliam simultaneamente, causando chamadas eval +duplicadas, conflitos de status, e race conditions no SSA. + +**Impacto:** Comportamento indefinido em HA. Recursos duplicados ou corrompidos. + +**Correção:** + +Usar `kube::runtime::Controller` com `LeaderElection` via `coordination.k8s.io/v1` Lease. +O `kube-runtime` já suporta isso nativamente. + +```rust +// main.rs +use kube::runtime::watcher::Config as WatcherConfig; + +let lease = kube::runtime::controller::LeaseConfig { + lease_name: "niphas-operator-leader".into(), + lease_namespace: std::env::var("POD_NAMESPACE").unwrap_or("niphas-system".into()), + identity: std::env::var("POD_NAME").unwrap_or_else(|_| hostname()), + lease_duration: Duration::from_secs(15), + renew_deadline: Duration::from_secs(10), + retry_period: Duration::from_secs(2), +}; +``` + +Alternativa mais simples (fase 1): usar `--replicas=1` no Deployment do operator e +documentar que HA requer leader election. Implementar Lease-based election como fase 2. + +``` +niphas-operator/src/main.rs -- adicionar leader election +niphas-operator/Cargo.toml -- verificar feature flags do kube-runtime +``` + +--- + +## Severidade: Alto + +### P3. Campo revision sem validação + +**Arquivo:** `niphas-core/src/crd.rs:32-33` + +Doc diz "Must be a 6-40 char hex string" mas nenhuma validação existe. +Qualquer string flui direto para `format!("{}/{}", flake_ref, rev)` no subprocess Nix. + +**Impacto:** Input malicioso pode manipular o flake ref resultante. + +**Correção:** + +Adicionar validação no `evaluate()` do eval service, antes de construir o pinned_ref: + +```rust +fn validate_revision(rev: &str) -> Result<(), NiphasError> { + if rev.len() < 6 || rev.len() > 40 { + return Err(NiphasError::InvalidInput("revision must be 6-40 chars".into())); + } + if !rev.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(NiphasError::InvalidInput("revision must be hex".into())); + } + Ok(()) +} +``` + +``` +niphas-core/src/eval.rs -- adicionar validate_revision() +niphas-eval/src/evaluator.rs:42 -- chamar antes de usar +``` + +--- + +### P4. Sem limite de concorrência no eval subprocess + +**Arquivo:** `niphas-eval/src/main.rs` + +Cada request POST /evaluate spawna um subprocess `nix eval` sem nenhum limite. +Sob carga, pode fork-bomb o node. + +**Impacto:** DoS no node do eval service. + +**Correção:** + +Adicionar `tokio::sync::Semaphore` ao `Evaluator` com permits configuráveis: + +```rust +pub struct Evaluator { + config: NiphasConfig, + cache_client: CacheClient, + warm: AtomicBool, + eval_semaphore: Semaphore, // novo +} + +// Em evaluate(): +let _permit = self.eval_semaphore.acquire().await + .map_err(|_| NiphasError::NixEval("eval service shutting down".into()))?; +``` + +Adicionar campo `max_concurrent_evals` ao `NiphasConfig` (default: 4). + +``` +niphas-core/src/config.rs -- adicionar max_concurrent_evals +niphas-eval/src/evaluator.rs -- adicionar Semaphore +``` + +--- + +### P5. NAR inteiro em memória (3x) + +**Arquivos:** `niphas-core/src/nix/nar.rs:28`, `niphas-csi/src/cache.rs:178-200` + +Fluxo atual: bytes comprimidos (mem) -> descomprimidos (mem) -> árvore parsed com +todos os conteúdos (mem) -> escrito em disco. Para pacotes grandes (gcc ~500MB), +consome ~1.5GB de RAM. + +**Impacto:** OOM kill em nodes com memória limitada. DoS via pacote grande. + +**Correção (fase 1 -- limitar):** + +Adicionar limite global ao `decompress_nar`: rejeitar NARs descomprimidos > 2GB. +Isso é simples e cobre o caso de DoS. + +**Correção (fase 2 -- streaming):** + +Redesenhar `NarReader` para streaming: em vez de retornar `NarNode` com `Vec`, +usar um visitor/callback pattern que escreve diretamente no disco: + +```rust +pub trait NarVisitor { + fn regular_file(&mut self, path: &Path, executable: bool, contents: &mut dyn AsyncRead) -> Result<()>; + fn symlink(&mut self, path: &Path, target: &str) -> Result<()>; + fn directory(&mut self, path: &Path) -> Result<()>; +} +``` + +Isso elimina a árvore em memória completamente. É um refactor grande que muda a API +do `NarReader`. Fazer depois do MVP. + +``` +niphas-csi/src/cache.rs -- fase 1: limite de 2GB no decompress +niphas-core/src/nix/nar.rs -- fase 2: NarVisitor streaming +``` + +--- + +### P6. Ready flag antes do controller iniciar + +**Arquivo:** `niphas-operator/src/main.rs:58` + +```rust +ready.store(true, Ordering::Relaxed); // linha 58 +Controller::new(workloads, ...) // linha 60 +``` + +O readiness probe retorna 200 antes do controller sequer começar a assistir. +Em rolling updates, requests podem ser roteados para uma instância que ainda +não está processando. + +**Impacto:** Window de indisponibilidade durante rolling updates. + +**Correção:** + +Mover o `ready.store(true)` para dentro do primeiro `for_each` callback, +ou melhor, usar o `Controller::graceful_shutdown_on` e setar ready após +o primeiro reconcile: + +```rust +// Setar ready quando o controller começar a receber eventos +let ready_flag = ready.clone(); +Controller::new(workloads, Default::default()) + .owns(deployments, Default::default()) + .owns(pods, Default::default()) + .run(reconciler::reconcile, reconciler::error_policy, ctx) + .for_each(|res| { + ready_flag.store(true, Ordering::Relaxed); + async move { /* ... */ } + }) + .await; +``` + +``` +niphas-operator/src/main.rs:58 -- mover ready flag para dentro do for_each +``` + +--- + +## Severidade: Médio + +### P7. Falhas silenciosas em resource building + +**Arquivo:** `niphas-operator/src/resources.rs` (10 ocorrências) + +`serde_json::to_value(...).unwrap_or_default()` insere `null` silenciosamente se +a serialização falhar. Pode criar Deployments quebrados sem nenhum log. + +**Correção:** + +Mudar a signature dos builders para `Result` e propagar o erro: + +```rust +pub(crate) fn build_deployment(...) -> Result { + // ... + if let Some(ref env) = workload.spec.env { + container_obj.insert("env".into(), serde_json::to_value(env)?); + } +} +``` + +Isso é seguro porque `serde_json::to_value` em tipos K8s que já implementam +Serialize nunca deveria falhar, mas se falhar, é melhor saber. + +``` +niphas-operator/src/resources.rs -- builders retornam Result +niphas-operator/src/reconciler.rs -- propagar ? no call site +``` + +--- + +### P8. Glob matching com complexidade exponencial + +**Arquivo:** `niphas-eval/src/allowlist.rs:49` + +A recursão do `matches_glob` para `*` pode ter comportamento exponencial com +patterns adversariais como `*a*a*a*a*b` contra `aaaaaaa...`. + +**Impacto:** Potencial ReDoS no path crítico de segurança (allowlist). + +**Correção:** + +Substituir pelo crate `glob-match` (zero-alloc, O(n) worst-case) ou implementar +matching iterativo com stack explícito: + +```rust +// Cargo.toml +glob-match = "0.2" + +// allowlist.rs +fn matches_glob(pattern: &str, value: &str) -> bool { + glob_match::glob_match(pattern, value) +} +``` + +``` +niphas-eval/Cargo.toml -- adicionar glob-match +niphas-eval/src/allowlist.rs -- substituir matches_glob +``` + +--- + +### P9. Lock map cresce sem limite + +**Arquivo:** `niphas-csi/src/cache.rs:27-28` + +`locks: HashMap>>` nunca é limpo. Em nodes de longa duração, +acumula uma entrada por store path já baixado. + +**Correção:** + +Limpar o lock do mapa após o download completar: + +```rust +// Após fetch_and_extract: +{ + let mut locks = self.locks.lock().await; + locks.remove(basename); +} +``` + +Cada entrada é ~100 bytes (String + Arc + Mutex), então mesmo 100k paths +são apenas ~10MB. Baixa prioridade, mas bom higiene. + +``` +niphas-csi/src/cache.rs:95 -- remover lock após uso +``` + +--- + +### P10. Imagem runner hardcoded com :latest + +**Arquivo:** `niphas-operator/src/resources.rs:14` + +```rust +const RUNNER_IMAGE: &str = "ghcr.io/fullzer4/niphas-runner:latest"; +``` + +`:latest` em produção é anti-pattern. Impede rollbacks, cache inconsistente, +e comportamento não-reproduzível. + +**Correção:** + +Adicionar `runner_image` ao `NiphasConfig` com default versionado: + +```rust +// config.rs +#[serde(default = "default_runner_image")] +pub runner_image: String, + +fn default_runner_image() -> String { + format!("ghcr.io/fullzer4/niphas-runner:v{}", env!("CARGO_PKG_VERSION")) +} +``` + +Permitir override por workload no CRD: + +```rust +// crd.rs +#[serde(default, skip_serializing_if = "Option::is_none")] +pub runner_image: Option, +``` + +``` +niphas-core/src/config.rs -- adicionar runner_image +niphas-core/src/crd.rs -- adicionar runner_image override +niphas-operator/src/resources.rs:14 -- usar config.runner_image +``` + +--- + +### P11. ownerReference com UID vazio + +**Arquivo:** `niphas-operator/src/resources.rs:447` + +```rust +"uid": workload.metadata.uid.as_deref().unwrap_or("") +``` + +UID vazio quebra garbage collection do K8s silenciosamente. Se o workload +não tiver UID, os child resources nunca serão limpos. + +**Correção:** + +Retornar erro se UID for None em vez de usar string vazia: + +```rust +fn owner_reference(workload: &NiphasWorkload) -> Result { + let uid = workload.metadata.uid.as_deref() + .ok_or_else(|| OperatorError::Internal("workload missing UID".into()))?; + Ok(json!({ + "uid": uid, + // ... + })) +} +``` + +``` +niphas-operator/src/resources.rs:442-451 -- retornar Result +``` + +--- + +## Severidade: Baixo + +### P12. Dependências workspace não usadas + +**Arquivo:** `Cargo.toml:40-80` + +Declaradas mas não usadas por nenhum crate ativo: +`libp2p`, `rkyv`, `smallvec`, `compact_str`, `bumpalo`, `memmap2`, +`lockfree-object-pool`, `garde`, `serde_yaml`. + +**Correção:** Remover do workspace `Cargo.toml`. Re-adicionar quando forem necessárias. + +--- + +### P13. niphas-mesh é stub vazio + +**Arquivo:** `niphas-mesh/src/main.rs` + +Crate inteiro é `fn main() { info!("starting"); }`. Puxa deps desnecessariamente. + +**Correção:** Remover do workspace até ter implementação real, ou manter apenas +com `niphas-core` e `tokio` como deps mínimas (já foi feito parcialmente). + +--- + +### P14. AppError sem Display/Error trait + +**Arquivo:** `niphas-eval/src/error.rs:5-13` + +`AppError` implementa `IntoResponse` mas não `Display` nem `Error`. +Não pode ser usado com `?` fora de handlers Axum. + +**Correção:** Adicionar `#[derive(Debug, thiserror::Error)]` com `#[error("...")]` em cada variante. + +--- + +### P15. humantime_serde hand-rolled + +**Arquivo:** `niphas-core/src/config.rs:288-333` + +Módulo `humantime_serde` reimplementa o que o crate `humantime-serde` já faz, +e suporta menos formatos ("300s", "5m", "1h" mas não "5m30s", "2h 30m"). + +**Correção:** Substituir pelo crate `humantime-serde = "1"`. + +--- + +### P16. Sem integration tests + +Nenhum crate tem diretório `tests/`. O fluxo operator -> eval -> CSI nunca é +testado end-to-end. + +**Correção:** Implementar conforme o plano de testes Tier 2 existente (kind cluster + nix binary). + +--- + +## Ordem de implementação sugerida + +``` +Fase 1 (segurança): + P1 Validação de flake_ref/attribute + P3 Validação de revision + P4 Semaphore no eval + P8 glob-match crate + +Fase 2 (correções operacionais): + P6 Ready flag + P11 UID vazio + P7 Builders retornam Result + P10 Runner image configurável + P12 Limpar deps não usadas + +Fase 3 (robustez): + P2 Leader election + P5 Limite de NAR size (fase 1) + P9 Cleanup do lock map + P14 AppError com thiserror + P15 humantime-serde crate + +Fase 4 (futuro): + P5 NAR streaming (fase 2) + P16 Integration tests + P13 Decidir sobre niphas-mesh +``` diff --git a/RESILIENCE.md b/RESILIENCE.md new file mode 100644 index 0000000..23f2400 --- /dev/null +++ b/RESILIENCE.md @@ -0,0 +1,372 @@ +# Niphas -- Resilience & Failure Handling + +Every design decision assumes the worst case: nodes die, networks partition, +caches go offline, and disks corrupt. Niphas follows K8s-native patterns +for all failure scenarios. + +## Node failure timeline + +When a node dies, K8s follows this sequence: + +| Time | Event | +|------|-------| +| T+0 | Node stops heartbeating | +| T+40s | `node-monitor-grace-period` expires. Node tainted `NotReady` + `unreachable:NoExecute` | +| T+40s + tolerationSeconds | Pods evicted. Default tolerationSeconds = 300s (5 min) | +| T+5m40s | Pods rescheduled on healthy nodes | + +Niphas workloads override the default toleration for faster failover: + +```yaml +tolerations: + - key: node.kubernetes.io/not-ready + operator: Exists + effect: NoExecute + tolerationSeconds: 30 + - key: node.kubernetes.io/unreachable + operator: Exists + effect: NoExecute + tolerationSeconds: 30 +``` + +Total failover: ~70 seconds instead of ~5m40s. + +## What happens when a pod is rescheduled + +``` +node-A dies + --> K8s evicts pods after tolerationSeconds + --> scheduler places pod on node-B + --> kubelet on node-B calls NodePublishVolume on niphas-csi + --> niphas-csi needs the closure + --> fetch chain: + 1. local cache /var/lib/niphas/cache/ (instant) + 2. niphas-mesh peers on other nodes (LAN, fast) + 3. binary cache HTTP (WAN, slower) + 4. fail --> kubelet retries with backoff +``` + +The pod enters `ContainerCreating` during fetch. kubelet retries +`NodePublishVolume` with exponential backoff up to 5 minutes, +identical to `ImagePullBackOff` behavior. + +## Fetch fallback chain + +The CSI driver tries sources in order. Each source is independent; +failure of one does not block the next. + +``` +1. Local NAR cache --> /var/lib/niphas/cache/-/ + instant, no network + always valid (content-addressed) + common case for workloads already running on this node + +2. Mesh P2P (libp2p) --> query local NAR index (gossipsub-populated) + "who has /nix/store/abc123?" + fetch NAR directly from peer (LAN speed) + timeout: 5s (configurable) + only available if mesh is enabled on this node + +3. Binary caches (HTTP) --> ordered by priority: + a. primary: company cache (e.g. Attic/Cachix) + b. secondary: cache.nixos.org + verify NarHash + signature + +4. gRPC Unavailable --> kubelet retries with backoff +``` + +Config via ConfigMap: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: niphas-csi-config + namespace: niphas-system +data: + config.yaml: | + binaryCaches: + - url: "https://cache.company.com" + priority: 1 + publicKey: "cache.company.com-1:..." + - url: "https://cache.nixos.org" + priority: 2 + publicKey: "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" + meshFetchEnabled: true + meshFetchTimeout: 5s + cache: + path: /var/lib/niphas/cache + highWatermark: 85 # start GC at 85% disk usage + lowWatermark: 75 # GC down to 75% +``` + +## Per-node lazy cache (no full replication) + +Niphas does **not** replicate closures to all nodes. Each node only +caches the NARs that its pods actually used. This is the standard +approach for storage drivers (Longhorn, OpenEBS, Rook-Ceph). + +When a pod is scheduled on a node, the CSI driver fetches the closure +through the fallback chain above. After extraction, the NAR stays in +the local cache at `/var/lib/niphas/cache/` for future use. + +Benefits: +- **Minimal resource usage**: nodes without Niphas workloads use zero + disk, zero RAM, zero CPU +- **No bandwidth overhead**: no background replication traffic +- **Scales to any cluster size**: 10 nodes or 10,000 nodes, same model +- **Simple**: no coordination, no anti-entropy, no cluster-wide sync + +The mesh (optional) accelerates fetches by allowing nodes to pull NARs +from peers that already have them (LAN speed vs WAN binary cache). +Nodes announce cached NARs via gossipsub `Have` messages so peers know +where to find them. + +Details: [`docs/MESH_PROTOCOL.md`](MESH_PROTOCOL.md) + +## Selective deployment + +CSI and mesh DaemonSets only run on nodes labeled `niphas.io/store=true`. +Nodes without this label have zero Niphas overhead. + +```bash +# Enable Niphas on specific nodes +kubectl label node worker-1 worker-2 worker-3 niphas.io/store=true + +# Mesh is optional -- enable per-node +kubectl label node worker-1 worker-2 worker-3 niphas.io/mesh=true +``` + +The operator adds `nodeSelector: { niphas.io/store: "true" }` to +generated Deployments so pods only land on prepared nodes. + +See the Helm chart section in [`docs/DEPLOY_FLOW.md`](DEPLOY_FLOW.md) +for installation details. + +## PodDisruptionBudget + +The operator creates a PDB for every NiphasWorkload with replicas >= 2: + +```yaml +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: -pdb + ownerReferences: + - apiVersion: niphas.io/v1alpha1 + kind: NiphasWorkload + name: + controller: true +spec: + maxUnavailable: 1 + selector: + matchLabels: + niphas.io/workload: + unhealthyPodEvictionPolicy: AlwaysAllow +``` + +- `maxUnavailable: 1` adapts automatically when replica count changes +- `unhealthyPodEvictionPolicy: AlwaysAllow` prevents broken pods from + blocking node drains (stable since K8s 1.31) +- No PDB for single-replica workloads (would block all voluntary disruptions) + +## Topology spread + +The operator injects topology constraints into workload pod templates: + +```yaml +topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: DoNotSchedule + labelSelector: + matchLabels: + niphas.io/workload: + - maxSkew: 1 + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + niphas.io/workload: +``` + +- Node spread: hard constraint (no two replicas on same node) +- Zone spread: best-effort (don't block scheduling if zones are unbalanced) +- If replicas > nodes, allows multiple pods per node gracefully + +## PriorityClass + +Infrastructure pods must survive resource pressure: + +```yaml +# CSI DaemonSet -- must be on every labeled node +priorityClassName: system-cluster-critical + +# Operator + Mesh -- important but below system-critical +apiVersion: scheduling.k8s.io/v1 +kind: PriorityClass +metadata: + name: niphas-infrastructure +value: 1000000 +preemptionPolicy: PreemptLowerPriority + +# User workloads +apiVersion: scheduling.k8s.io/v1 +kind: PriorityClass +metadata: + name: niphas-workload +value: 100000 +preemptionPolicy: PreemptLowerPriority +``` + +CSI uses `system-cluster-critical` because if the driver is evicted, +all pods on that node lose their volumes. Same pattern as EBS CSI, +GCE PD CSI, and every other production storage driver. + +## Leader election (operator) + +The operator runs with 2-3 replicas for HA. Only the leader reconciles. +Uses Lease-based election (coordination.k8s.io/v1): + +- Lease TTL: 15 seconds +- Renewal: every 5 seconds +- On leader death: new leader acquired within ~15s +- Reconciliation is idempotent, so brief dual-leader windows are safe + +```rust +// niphas-operator uses kube-leader-election crate +let lease = LeaseLock::new(client, "niphas-operator", LeaseLockParams { + holder_id: pod_name, + lease_ttl: Duration::from_secs(15), + retry_period: Duration::from_secs(5), + renew_period: Duration::from_secs(5), +}); +``` + +## Finalizers + +The operator adds `niphas.io/workload-cleanup` to every NiphasWorkload. +On deletion: +1. Operator sees `deletionTimestamp` is set +2. Cleans up: removes child resources (Deployment, Service, PDB) +3. Removes the finalizer +4. K8s completes deletion +5. CSI local cache GC eventually evicts unused NARs (LRU) + +If cleanup fails, the finalizer stays, the object remains in +`Terminating`, and the controller retries on next reconciliation. + +The operator manages the finalizer. No mesh-level finalizer needed -- +cache cleanup is handled by per-node LRU GC. + +## CSI error handling + +`NodePublishVolume` follows strict idempotency: + +``` +1. Already mounted correctly? --> return Ok (idempotent) +2. Partial mount from previous failure? --> cleanup first +3. Fetch closure (fallback chain) +4. Mount +5. On any failure: clean up partial state, return gRPC error +``` + +gRPC error codes: +- `Unavailable` -- transient (cache down, mesh timeout). kubelet retries aggressively +- `Internal` -- mount syscall failed. kubelet retries with backoff +- `InvalidArgument` -- bad storePath in volumeAttributes. User must fix CRD +- `NotFound` -- store path doesn't exist in any cache + +Critical: **never leave a partial mount at target_path on failure**. +kubelet may skip `NodePublishVolume` if it sees a filesystem at the +target path, assuming the mount succeeded. + +## Status conditions (eventual consistency) + +The CRD status follows K8s conventions for detecting desync: + +```yaml +status: + observedGeneration: 3 # matches metadata.generation when in sync + phase: Running + storePath: "/nix/store/abc123-hello-2.12.1" + closurePaths: # full closure for rescheduling + - "/nix/store/abc123-hello-2.12.1" + - "/nix/store/def456-glibc-2.38" + readyReplicas: 3 + conditions: + - type: Evaluated + status: "True" + reason: EvalSucceeded + message: "flake eval completed, store path resolved" + lastTransitionTime: "2026-06-05T12:00:00Z" + - type: ClosureCached + status: "True" + reason: CacheVerified + message: "closure available in binary cache" + lastTransitionTime: "2026-06-05T12:00:05Z" + - type: Available + status: "True" + reason: ReplicasReady + message: "3/3 replicas running" + lastTransitionTime: "2026-06-05T12:00:10Z" +``` + +Condition types: + +| Condition | True | False | +|-----------|------|-------| +| `Evaluated` | Nix eval succeeded | Eval failed or pending | +| `ClosureCached` | NAR available in binary cache | Fetch failed/pending | +| `Available` | >= 1 replica running | No replicas ready | +| `Progressing` | Rollout in progress | Stable | +| `Degraded` | Partial failure | Everything healthy | + +If `observedGeneration < metadata.generation`, the controller hasn't +processed the latest spec change. GitOps tools (Argo, Flux) use this +to detect drift. + +## Health checks + +### CSI DaemonSet + +```yaml +livenessProbe: + httpGet: + path: /healthz + port: 9808 + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 5 +``` + +The `livenessprobe` sidecar calls `Probe()` on the gRPC socket and +exposes `/healthz` HTTP. If the CSI driver hangs or crashes, kubelet +restarts it. + +### Closure integrity + +Store paths are content-addressed: the hash is the identity. The CSI +driver can verify integrity by re-hashing the cached NAR and comparing +against the expected NarHash. If corrupted: +1. Evict from local cache +2. Re-fetch from mesh or binary cache +3. Set `Degraded` condition on the NiphasWorkload + +## Failure scenario matrix + +| Failure | Impact | Recovery | +|---------|--------|----------| +| Single node dies | Pods rescheduled in ~70s. CSI on new node fetches closure: local cache (if previously used) -> mesh peers (LAN) -> binary cache (WAN) | Automatic | +| Binary cache down | No impact on existing workloads (local cache). New closures can still be fetched from mesh peers if any node has them | Automatic (if mesh or local cache available) | +| Binary cache + mesh both down | Existing closures still available on local cache. Only new, never-fetched closures fail | Partial manual | +| niphas-csi DaemonSet pod crashes | kubelet restarts it (liveness probe). Existing mounts survive | Automatic | +| niphas-operator pod dies | New leader elected in ~15s. Existing workloads continue | Automatic | +| niphas-eval pod dies | New workload evaluations fail. Existing workloads unaffected. Deployment restarts pod, eval cache on PVC survives | Automatic | +| Eval hangs (malicious flake) | Per-evaluation timeout (300s) enforced in Rust. Eval cancelled, workload marked Failed | Automatic | +| niphas-mesh pod dies | No P2P fetch from peers. CSI still reads local cache at `/var/lib/niphas/cache/` (shared hostPath). Falls back to binary cache HTTP for uncached paths. Mesh is optional -- CSI works standalone | Automatic | +| NAR corrupted on disk | CSI re-hashes, detects mismatch, evicts, re-fetches | Automatic | +| CRD deleted while pods running | Finalizer holds deletion. Cleanup runs. Then pods terminate | Automatic | +| etcd data loss | CRDs gone. Workloads stop. Must reapply from GitOps | Manual (GitOps redeploy) | +| All nodes die simultaneously | Total cluster failure. Standard K8s DR applies | Manual (cluster restore) | diff --git a/RUNTIME_MODEL.md b/RUNTIME_MODEL.md new file mode 100644 index 0000000..14f7301 --- /dev/null +++ b/RUNTIME_MODEL.md @@ -0,0 +1,384 @@ +# Niphas -- Runtime Model + +How a Nix package runs inside a Kubernetes pod without a container image. + +## The core insight + +A Nix closure is already more self-contained than an OCI image. +Every binary, every library, every data file lives at absolute +`/nix/store/--/` paths. No `/usr/lib`, no `/lib`, +no FHS paths. The closure contains everything needed to run the program. + +The OCI image is unnecessary. Niphas mounts the closure directly. + +## How Nix makes binaries self-contained + +### patchelf: the foundation + +When Nix builds a package, `patchelf` rewrites two ELF header fields: + +**1. PT_INTERP (the dynamic linker path)** + +Standard Linux binary: +``` +/lib64/ld-linux-x86-64.so.2 +``` + +Nix-built binary: +``` +/nix/store/4nlgxhb09sdr51nc9hdm8az5b08vzkgx-glibc-2.38/lib/ld-linux-x86-64.so.2 +``` + +**2. DT_RUNPATH (shared library search path)** + +Standard Linux binary: +``` +(empty, uses /lib, /usr/lib, ld.so.cache) +``` + +Nix-built binary: +``` +/nix/store/abc123-openssl-3.1/lib:/nix/store/def456-zlib-1.3/lib +``` + +The dynamic linker finds every `.so` via absolute paths. No system +library search. No `/etc/ld.so.cache`. No `LD_LIBRARY_PATH` needed. + +This happens automatically in stdenv's `postFixup` phase: +```bash +patchelf --set-interpreter "$(cat $NIX_CC/nix-support/dynamic-linker)" \ + --set-rpath "${lib.makeLibraryPath buildInputs}" \ + $out/bin/myprogram +``` + +### Closures: transitive dependency completeness + +After building, Nix scans the output for hash references to compute +runtime dependencies. If the binary contains the string +`4nlgxhb09sdr51nc9hdm8az5b08vzkgx` (the hash portion of a glibc store +path), that store path is marked as a runtime dependency. This is +recursive: each dependency's own references are included. + +The result is the **closure** -- every store path transitively referenced +by the root package. For a typical application: + +``` +/nix/store/abc123-myapp-1.0.0 # the application +/nix/store/def456-glibc-2.38 # C library + dynamic linker +/nix/store/ghi789-openssl-3.1 # TLS +/nix/store/jkl012-zlib-1.3 # compression +/nix/store/mno345-gcc-libs-13.2 # C++ runtime +/nix/store/pqr678-ca-certificates # root CAs +... # every transitive dependency +``` + +**Invariant**: copying the closure to another machine is sufficient to +run the program. No package manager, no install step, no dependency +resolution at runtime. + +### Static (musl) builds + +For `pkgsStatic` builds (musl libc, static linking): + +- No PT_INTERP (kernel loads the binary directly) +- No DT_RUNPATH (no shared libraries) +- Closure is just the single binary (+ data files if any) +- The simplest case: even `FROM scratch` works + +### patchShebangs + +Scripts get the same treatment. During `fixupPhase`, Nix rewrites: + +``` +#!/usr/bin/env python3 --> #!/nix/store/-python3-3.11/bin/python3 +#!/bin/sh --> #!/nix/store/-bash-5.2/bin/sh +``` + +No script references FHS paths after a Nix build. + +## How it runs in Kubernetes + +### The problem: K8s requires an image + +Every container in a Pod spec must have an `image` field. The API server +rejects pods without it. There is no escape from this requirement. + +### The solution: stub image + CSI volume + +``` ++---------------------------------------------+ +| Pod | +| | +| container: | +| image: niphas-runner:latest (~1 MB) | +| command: ["/nix/store/abc123-.../bin/app"]| +| | +| volumes: | +| - csi: | +| driver: niphas.io.csi | +| /nix/store (bind mount, read-only) | +| | | ++-------------|-------------------------------+ + | + v + /var/lib/niphas/cache/abc123-myapp-1.0.0/ + /var/lib/niphas/cache/def456-glibc-2.38/ + /var/lib/niphas/cache/ghi789-openssl-3.1/ + ... +``` + +The stub image satisfies K8s. The actual binary comes from the CSI volume. + +### The stub image: niphas-runner + +`niphas-runner` is a scratch-based OCI image (~1 MB) containing: + +``` +/etc/passwd # root:x:0:0:root:/root:/sbin/nologin + # nobody:x:65534:65534:nobody:/:/sbin/nologin +/etc/group # root:x:0: + # nobody:x:65534: +/etc/nsswitch.conf # passwd: files + # group: files + # hosts: files dns +/tmp/ # writable directory for programs that need it +``` + +That's it. No shell. No package manager. No libc. No dynamic linker. + +**Why no dynamic linker in the stub?** Because the Nix-built binary's +PT_INTERP already points to `/nix/store/-glibc-2.38/lib/ld-linux-x86-64.so.2`, +which is inside the CSI-mounted closure. The binary finds its own linker. + +**For musl-static binaries**: even the stub is overkill. A true `FROM scratch` +(0 bytes) image works because the binary has no PT_INTERP and the kernel +loads it directly. + +### The CSI volume: mounting the closure + +Niphas uses CSI inline ephemeral volumes (GA since K8s 1.25): + +```yaml +volumes: + - name: nix-closure + csi: + driver: niphas.io.csi + volumeAttributes: + closurePaths: "/nix/store/abc123-myapp-1.0.0,/nix/store/def456-glibc-2.38,..." +``` + +No PersistentVolume, no PersistentVolumeClaim, no StorageClass. The volume +lifecycle is tied to the pod. + +**Timing guarantee**: kubelet calls `NodePublishVolume` on the CSI driver +and waits for success *before* starting any container. There is no race +condition. The closure is fully mounted when the process starts. + +### Execution sequence + +``` +[1] kubelet reads pod spec, sees CSI volume + | + v +[2] kubelet calls NodePublishVolume on niphas-csi + | + v +[3] niphas-csi checks local cache at /var/lib/niphas/cache/ + for each store path in closurePaths: + - if cached: skip (content-addressed, always valid) + - if not cached: fetch via fallback chain: + mesh peers (LAN) -> binary cache (WAN) + - verify Ed25519 signature + - extract NAR to cache + | + v +[4] niphas-csi bind-mounts the cache directory at the target path + mount(source, target, NULL, + MS_BIND | MS_RDONLY | MS_NOSUID | MS_NODEV, NULL) + | + v +[5] kubelet sees NodePublishVolume returned OK + starts the container + | + v +[6] container runtime (containerd/CRI-O): + - creates rootfs from stub image (basically empty) + - adds /nix/store bind mount from CSI volume + - adds kubelet-managed mounts: /etc/resolv.conf, /etc/hosts, /proc, /sys + - execs the command: /nix/store/abc123-myapp-1.0.0/bin/myapp + | + v +[7] kernel loads the ELF binary + reads PT_INTERP: /nix/store/def456-glibc-2.38/lib/ld-linux-x86-64.so.2 + this file exists (it's in the mounted closure) + | + v +[8] dynamic linker (ld-linux) starts + reads DT_RUNPATH from the binary + loads all shared libraries from /nix/store/... paths + all exist (they're in the closure) + | + v +[9] program runs +``` + +For static (musl) binaries, steps 7-8 simplify: the kernel loads +the binary directly, no dynamic linker involved. + +## What kubelet provides automatically + +These are mounted into every container by the container runtime, +independent of the image or CSI volumes: + +| Path | Source | Writable | +|------|--------|----------| +| `/proc` | procfs | read-only (mostly) | +| `/sys` | sysfs | read-only | +| `/dev` | devtmpfs | device-specific | +| `/etc/resolv.conf` | kubelet (from node or pod dnsConfig) | no | +| `/etc/hosts` | kubelet (from pod spec + hostAliases) | no | +| `/etc/hostname` | kubelet | no | +| `/dev/termination-log` | kubelet | yes | + +Niphas does not need to provide any of these. + +## What the stub image provides + +| Path | Why | +|------|-----| +| `/etc/passwd` | `getpwuid()` calls (some programs check user identity) | +| `/etc/group` | `getgrgid()` calls | +| `/etc/nsswitch.conf` | tells glibc's NSS where to look for users/hosts | +| `/tmp/` | writable dir for programs that need temporary files | + +The operator also injects an `emptyDir` volume at `/tmp` in the generated +Deployment spec for programs that need writable temp space. + +## NSS and dlopen + +glibc uses `dlopen()` to load NSS modules (`libnss_files.so`, +`libnss_dns.so`) at runtime. This is a well-known challenge for Nix. + +**How it works in Niphas:** + +1. The Nix-patched glibc searches `$NIX_GLIBC_NSS_PATH` (a Nix extension) + for NSS modules +2. Basic NSS modules (`files`, `dns`) are shipped with glibc itself and + are included in every closure that depends on glibc +3. The stub image's `/etc/nsswitch.conf` restricts lookups to `files dns`, + which are the modules available in the closure + +For most server applications (HTTP APIs, databases, CLI tools), this is +sufficient. Complex NSS configurations (LDAP, SSSD) would require +additional packages in the closure. + +## Edge cases + +### Programs that hardcode /bin/sh + +C programs calling `system()` exec `/bin/sh`. Two solutions: + +1. **Package-level fix**: patch the source to use `execvp("sh", ...)` + instead of `system()` (the proper Nix approach) +2. **Stub image fallback**: include `/bin/sh` as a symlink to the + bash in the closure. Niphas can do this if `meta.needsShell = true` + +Well-packaged nixpkgs derivations already handle this via +`patchShebangs` and wrapper scripts. + +### GPU workloads (NVIDIA) + +GPU drivers have two components: + +- **Kernel modules** (`nvidia.ko`): run on the host, loaded by the + node's OS. Not part of any container. +- **Userspace libraries** (`libcuda.so`): must match the kernel module + version exactly. + +Niphas handles GPUs the same way as every other K8s storage driver: + +1. NVIDIA device plugin runs as a DaemonSet on GPU nodes +2. The device plugin injects userspace driver libraries via a volume mount +3. `LD_LIBRARY_PATH` is set to include the injected driver path +4. The Nix closure does not include GPU drivers (they're host-specific) + +This is the standard Kubernetes NVIDIA pattern. Niphas doesn't change it. + +### setuid binaries + +The Nix store cannot contain setuid binaries (security invariant). The +CSI volume is mounted with `MS_NOSUID`, so even if a setuid bit existed +it would not function. + +Programs that need elevated capabilities (e.g., `ping` needs +`CAP_NET_RAW`) should use Kubernetes `securityContext.capabilities.add` +instead of filesystem setuid bits. + +### dlopen for plugins + +Programs that `dlopen("libplugin.so")` by relative name need help +finding the library. Nix handles this via: + +1. **Patching dlopen calls**: the nixpkgs package patches source code + to use absolute `/nix/store/...` paths (most common approach) +2. **wrapProgram**: sets `LD_LIBRARY_PATH` in a wrapper script +3. **CRD env field**: the user can set `LD_LIBRARY_PATH` in + `spec.env` to include plugin paths from the closure + +Well-maintained nixpkgs packages already have dlopen paths patched. + +## Comparison with traditional containers + +| Aspect | OCI Container | Niphas (Nix closure) | +|--------|--------------|----------------------| +| **Image build** | Dockerfile, layer by layer | `nix build`, content-addressed | +| **Image size** | 50 MB - 2 GB typical | Closure: 50-500 MB, stub: ~1 MB | +| **Deduplication** | Per-layer (coarse, 256 max) | Per-store-path (fine-grained, unlimited) | +| **Reproducibility** | Best-effort (apt install = non-deterministic) | Guaranteed (same inputs = same hash) | +| **Registry** | Required (Docker Hub, ECR, GCR) | Not needed (binary cache serves NARs) | +| **Pull time** | Download all layers | Download only missing store paths | +| **Update granularity** | Re-push entire changed layers | Only new/changed store paths | +| **Rollback** | Re-tag or re-pull old image | Switch store path (atomic) | +| **Runtime deps** | Implicit (whatever is in the image) | Explicit (closure is the complete set) | +| **Security scanning** | Scan image layers for CVEs | Scan closure for CVEs (more precise) | + +## Why not just use dockerTools? + +nixpkgs provides `dockerTools.buildLayeredImage` which converts a Nix +closure into OCI layers. This works, but: + +1. **Adds a build step**: `nix build` -> OCI image -> push to registry -> pull +2. **Loses deduplication**: OCI layers are coarser than Nix store paths. + Two images sharing glibc but built at different times get different + layers (different hashes due to layer ordering) +3. **Requires a registry**: adds infrastructure dependency +4. **Layer limit**: OCI spec allows 128 layers. A closure with 300+ store + paths must merge some into fat layers, losing granularity +5. **Redundant wrapping**: the OCI image is just the closure in a different + format. Mounting the closure directly is more efficient + +Niphas skips the middleman. The binary cache *is* the registry. +The closure *is* the image. The CSI driver *is* the image puller. + +## Summary + +``` +Nix build patchelf closure binary cache + | | | | + v v v v +source code -> ELF binary -> all store paths -> NARs on HTTP + (absolute (glibc, ssl, (content- + /nix/store zlib, ...) addressed) + paths) + + CSI driver kubelet + | | + v v + fetch NARs -> extract to cache -> bind mount -> exec binary + /var/lib/niphas/ /nix/store /nix/store/ + cache/ (read-only) /bin/app +``` + +The entire chain is content-addressed. Same inputs produce the same +store path hash. Same hash fetches the same NAR. Same NAR extracts +the same files. Reproducible from source to running process. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4acd677 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,20 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability, please report it responsibly. + +- Email: security@niphas.io +- Expected response time: within 48 hours +- We will provide a fix within 30 days of disclosure + +## Supported Versions + +| Version | Supported | +|---------|-----------| +| 0.1.x | Yes | + +## Disclosure Policy + +We follow coordinated vulnerability disclosure. Please do not open public +issues for security vulnerabilities. diff --git a/SECURITY_DESIGN.md b/SECURITY_DESIGN.md new file mode 100644 index 0000000..039468f --- /dev/null +++ b/SECURITY_DESIGN.md @@ -0,0 +1,539 @@ +# Niphas -- Security Design + +## Threat model + +Niphas runs privileged infrastructure on K8s clusters. Attack surface: + +1. **Compromised binary cache** serves malicious NARs with valid signatures +2. **Malicious flakeRef** in a NiphasWorkload CRD runs arbitrary Nix code +3. **Rogue mesh peer** joins the P2P network and injects bad data +4. **Privilege escalation** from CSI driver mount operations +5. **Lateral movement** through overly permissive RBAC or network access + +Every component follows least privilege. Each gets its own ServiceAccount, +RBAC, and NetworkPolicy. + +## RBAC + +### niphas-operator + +Broadest permissions. Reconciles CRDs, creates Deployments and PDBs. + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: niphas-operator + namespace: niphas-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: niphas-operator +rules: + - apiGroups: ["niphas.io"] + resources: ["niphasworkloads"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["niphas.io"] + resources: ["niphasworkloads/status"] + verbs: ["get", "update", "patch"] + - apiGroups: ["niphas.io"] + resources: ["niphasworkloads/finalizers"] + verbs: ["update"] + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["policy"] + resources: ["poddisruptionbudgets"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] +--- +# Leader election (namespace-scoped) +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: niphas-operator-leader-election + namespace: niphas-system +rules: + - apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +``` + +### niphas-csi + +**Zero RBAC.** Communicates with kubelet over Unix domain socket only. +Never contacts the K8s API. + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: niphas-csi-node + namespace: niphas-system + annotations: + automountServiceAccountToken: "false" +``` + +### niphas-mesh + +Read-only access to discover peers and read closure info. + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: niphas-mesh +rules: + - apiGroups: ["niphas.io"] + resources: ["niphasworkloads"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["nodes"] + verbs: ["get", "list", "watch"] +``` + +### niphas-eval + +Evaluates flakes via Nix C API (in-process, no Jobs), updates workload status. + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: niphas-eval +rules: + - apiGroups: ["niphas.io"] + resources: ["niphasworkloads"] + verbs: ["get", "list", "watch"] + - apiGroups: ["niphas.io"] + resources: ["niphasworkloads/status"] + verbs: ["get", "update", "patch"] + - apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] +``` + +No batch/jobs or pods RBAC needed -- evaluation happens in-process +via C FFI, not in separate Jobs. + +## Pod Security + +### CSI DaemonSet (needs mount privileges) + +```yaml +securityContext: + privileged: true + runAsUser: 0 + readOnlyRootFilesystem: true +``` + +The CSI driver requires `privileged: true` for bind mount operations +with `mountPropagation: Bidirectional`. This is the same approach used +by every production CSI driver (EBS CSI, GCE PD, Ceph, Longhorn). + +`CAP_SYS_ADMIN` alone is insufficient because Bidirectional mount +propagation requires the `privileged` flag in most container runtimes +(containerd, CRI-O). Attempting `CAP_SYS_ADMIN` without `privileged` +fails silently on many K8s distributions. + +The blast radius is contained: the CSI driver has zero RBAC (no K8s +API access), read-only rootfs, and only communicates via Unix socket +with kubelet. + +### All other components (Restricted PSS) + +```yaml +securityContext: + runAsNonRoot: true + runAsUser: 65534 + runAsGroup: 65534 + seccompProfile: + type: RuntimeDefault +containers: + - securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] +``` + +### Namespace enforcement + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: niphas-system + labels: + pod-security.kubernetes.io/enforce: baseline + pod-security.kubernetes.io/enforce-version: latest + pod-security.kubernetes.io/warn: restricted + pod-security.kubernetes.io/warn-version: latest + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/audit-version: latest +``` + +Baseline enforced (CSI needs it). Restricted on warn/audit to track +which pods need elevation. + +## NetworkPolicy + +Default deny for the namespace, then explicit allow per component. + +```yaml +# Default deny all +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny-all + namespace: niphas-system +spec: + podSelector: {} + policyTypes: [Ingress, Egress] +``` + +### Per-component network access + +| Component | Ingress | Egress | +|-----------|---------|--------| +| operator | health probes (monitoring) | K8s API (443/6443), DNS | +| csi | liveness probe (kubelet) | binary cache HTTPS, mesh peer (4001), DNS | +| mesh | mesh peers (4001 TCP+UDP), CSI peer requests, monitoring | mesh peers (4001), K8s API, DNS | +| eval | webhook endpoint (8443 from operator) | git HTTPS (443), binary cache HTTPS, DNS | + +Each component has its own NetworkPolicy. The eval pod only needs +outbound HTTPS for fetching flake sources (git) and resolving closures +(binary cache .narinfo). No K8s API access needed beyond the minimal +RBAC for status updates. + +## NAR signature verification + +Every NAR fetched from any source (binary cache or mesh peer) must +be verified before extraction or use. + +### How Nix signatures work + +`.narinfo` contains: +``` +Sig: : +``` + +Signature covers the fingerprint: +``` +1;;;; +``` + +### Verification in niphas-csi + +```rust +use ed25519_dalek::{Signature, VerifyingKey, Verifier}; + +fn verify_narinfo( + narinfo: &NarInfo, + trusted_keys: &[TrustedKey], +) -> Result<(), NarVerificationError> { + let fingerprint = format!( + "1;{};{};{};{}", + narinfo.store_path, + narinfo.nar_hash, + narinfo.nar_size, + narinfo.references.join(",") + ); + + for sig in &narinfo.signatures { + for key in trusted_keys { + if sig.key_name == key.name { + let sig_bytes = base64_decode(&sig.signature)?; + let signature = Signature::from_bytes(&sig_bytes.try_into()?); + if key.pubkey.verify(fingerprint.as_bytes(), &signature).is_ok() { + return Ok(()); + } + } + } + } + + Err(NarVerificationError::NoTrustedSignature) +} +``` + +### On verification failure + +1. Reject the NAR. Never extract or cache it. +2. Log warning event with store path, cache URL, signatures present. +3. Set `Degraded` condition with reason `NarSignatureVerificationFailed`. +4. Fall back to next source in fetch chain. +5. If all sources fail: return gRPC `Unavailable`, kubelet retries. + +**Invariant: an unverified NAR never reaches a pod.** + +Mesh-fetched NARs go through the same verification. The mesh is a +transport layer, not a trust layer. + +### Trusted keys config + +```yaml +binaryCaches: + - url: "https://cache.nixos.org" + publicKey: "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" + requireSignature: true + - url: "https://cache.company.com" + publicKey: "cache.company.com-1:..." + requireSignature: true +``` + +## Eval sandboxing (highest risk) + +Nix evaluation executes arbitrary Nix code. A malicious flake can +read files, make network requests, and with IFD trigger builds. + +niphas-eval calls the Nix evaluator via C FFI (in-process, no Jobs). +The evaluator runs inside the niphas-eval Deployment pod with +multiple layers of defense. + +### Defense in depth (4 layers) + +**Layer 1: Nix evaluator settings (via C API)** + +```rust +// Applied before every evaluation via nix-bindings-rust +settings.sandbox = true; +settings.restrict_eval = true; +settings.allowed_uris = vec![ + "https://github.com", + "https://cache.nixos.org", +]; +settings.allow_import_from_derivation = false; // critical +settings.max_jobs = 0; // eval only, no builds +``` + +`allow-import-from-derivation = false` is critical: blocks IFD which +would allow executing derivations during eval. + +`max-jobs = 0` ensures no builds can happen even if IFD is somehow +bypassed. + +**Layer 2: Flake allowlist (deny-by-default)** + +```yaml +allowedFlakeOrigins: + - "github:myorg/*" + - "github:nixos/nixpkgs" +``` + +The eval webhook rejects any flakeRef not matching the allowlist +before the evaluator is invoked. Deny-by-default: if a flakeRef +does not match any entry, it is rejected. + +**Layer 3: Container isolation** + +```yaml +# niphas-eval Deployment pod +securityContext: + runAsNonRoot: true + runAsUser: 65534 + seccompProfile: + type: RuntimeDefault +containers: + - securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + resources: + limits: + cpu: "2" + memory: "4Gi" +``` + +The eval process runs as non-root, no capabilities, read-only rootfs. +The Nix store for eval is mounted on a writable volume (PVC or hostPath) +at `/nix/store` for caching evaluated derivations. + +Per-evaluation timeout enforced in Rust (e.g. `tokio::time::timeout(300s)`). + +**Layer 4: Network isolation** + +```yaml +# NetworkPolicy for niphas-eval pod +spec: + podSelector: + matchLabels: + app: niphas-eval + policyTypes: [Ingress, Egress] + ingress: + - from: + - podSelector: + matchLabels: + app: niphas-operator + ports: + - port: 8443 # webhook endpoint + egress: + - to: [] # git HTTPS (443) + DNS (53) + ports: + - port: 443 + protocol: TCP + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP +``` + +niphas-eval can reach git (HTTPS) and DNS only. Cannot reach K8s API +(no RBAC for sensitive resources), cannot reach other pods except +via the webhook endpoint. + +## Mount isolation + +### Symlink attack prevention + +NAR extraction validates every entry: + +```rust +fn validate_nar_entry(entry: &NarEntry) -> Result<()> { + match entry { + NarEntry::Symlink { target } => { + // Reject symlinks pointing outside /nix/store/ + if !target.starts_with("/nix/store/") && !is_relative_within_store(target) { + return Err(NarExtractionError::SymlinkEscape); + } + } + NarEntry::Directory { name } => { + // Reject path traversal + if name.contains("..") || name.contains('/') { + return Err(NarExtractionError::PathTraversal); + } + } + _ => {} + } + Ok(()) +} +``` + +### Mount flags + +All bind mounts use: +- `MS_BIND | MS_RDONLY` (read-only) +- `MS_NOSUID` (no setuid binaries) +- `MS_NODEV` (no device nodes) + +```rust +mount( + Some(cache_path), target_path, + None::<&str>, + MsFlags::MS_BIND | MsFlags::MS_RDONLY | MsFlags::MS_NOSUID | MsFlags::MS_NODEV, + None::<&str>, +)?; +``` + +### Target path validation + +Before mounting, verify target is within kubelet's pod directory: + +```rust +fn validate_target_path(target: &Path) -> Result<()> { + let canonical = target.canonicalize()?; + if !canonical.starts_with("/var/lib/kubelet/pods/") { + return Err(CsiError::InvalidTargetPath(canonical)); + } + Ok(()) +} +``` + +Prevents a compromised kubelet from tricking the CSI driver into +mounting at an arbitrary host path. + +## Mesh authentication + +libp2p Noise XX handshake provides: +- **Mutual authentication**: both peers prove identity key possession +- **Perfect forward secrecy**: ephemeral Curve25519 keys per session +- **Encryption**: ChaCha20-Poly1305 post-handshake + +### Closed mesh + +Only cluster members can participate. Each mesh pod registers its +PeerId via K8s API (Pod annotation). Peers reject connections from +unknown PeerIds. + +```rust +let allowed_peers: HashSet = discover_cluster_peers().await?; + +swarm.behaviour_mut().set_connection_handler(move |peer_id| { + if !allowed_peers.contains(peer_id) { + return Err(ConnectionDenied::new("peer not in cluster")); + } + Ok(()) +}); +``` + +### CSI-to-mesh communication + +Uses Unix domain socket at `/var/run/niphas/mesh.sock` for same-node +communication. No TLS needed (inherently node-scoped, no network +exposure). + +## Secrets management + +| Secret | Storage | +|--------|---------| +| Binary cache signing keys (Ed25519 private) | External Secrets Operator (Vault, AWS SM) | +| Binary cache trusted public keys | ConfigMap (public data) | +| Webhook TLS certs (eval) | cert-manager with internal CA | +| Mesh identity keys (libp2p Ed25519) | K8s Secret per node, rotated | +| Git SSH keys (private flake repos) | K8s Secret type `kubernetes.io/ssh-auth` | + +Encryption at rest must be enabled for K8s Secrets (`EncryptionConfiguration`). + +## Container images + +| Component | Base image | User | Root FS | +|-----------|-----------|------|---------| +| operator | `scratch` (static musl binary) | 65534 | read-only | +| csi | distroless or `scratch` + musl | 0 (root, for mount) | read-only | +| mesh | `scratch` (static musl binary) | 65534 | read-only | +| eval | Nix-based image (contains Nix libs for C FFI) | 65534 | read-only + PVC for /nix/store | + +Built via `dockerTools.buildLayeredImage` in Nix (no shell, no pkg manager). +The eval image includes `libexpr-c`, `libstore-c`, `libflake-c` shared +libraries for the Nix C API, but no `nix` CLI binary. + +## Audit logging + +K8s API server audit policy: + +| Resource | Verbs | Level | +|----------|-------|-------| +| niphasworkloads | create, update, patch, delete | RequestResponse | +| niphasworkloads | get, list, watch | Metadata | +| niphasworkloads/status | update, patch | Request | +| secrets (niphas-system) | all | Metadata | + +Each component also emits structured logs: +- **operator**: reconciliation actions + results +- **eval**: flake evaluation requests + pass/fail +- **csi**: NAR fetch source + signature verification result +- **mesh**: peer connections + NAR transfers + +## Summary + +| Area | Approach | +|------|----------| +| RBAC | Separate SA per component. CSI gets zero RBAC. | +| PSS | CSI: privileged (required for mount propagation). Others: Restricted. | +| Network | Default deny + explicit allow per component. | +| NAR integrity | Mandatory Ed25519 verification on all sources. | +| Eval sandbox | 4 layers: Nix C API settings (IFD=false), allowlist, container isolation, network policy. | +| Mount safety | Symlink validation, read-only + nosuid + nodev, target path validation. | +| Mesh auth | libp2p Noise XX, closed mesh (PeerId allowlist). | +| Secrets | External Secrets Operator for private keys. cert-manager for TLS. | +| Images | scratch/distroless, read-only rootfs, non-root where possible. | +| Audit | K8s audit policy + structured component logs. | diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..a1b7ba9 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,723 @@ +# Niphas -- Design de Testabilidade + +## Problema + +4.569 linhas de Rust, 32 testes cobrindo apenas parsing/crypto. Zero testes para operator, CSI, eval pipeline. A causa raiz nao e falta de testes -- e que o codigo mistura logica de negocio com IO, tornando impossivel testar sem infraestrutura real (cluster K8s, root, nix binary, rede). + +``` +Hoje: + reconcile() --> constroi JSON + chama K8s API + chama HTTP eval (tudo junto) + node_publish() --> valida input + chama cache HTTP + chama libc mount (tudo junto) + evaluate() --> valida allowlist + spawn nix + resolve closure HTTP (tudo junto) + +Queremos: + reconcile() --> build_deployment() [puro] + apply() [IO] + node_publish() --> validate() [puro] + cache.ensure() [injetavel] + mount [injetavel] + evaluate() --> validate() [puro] + eval [injetavel] + resolve [injetavel] +``` + +--- + +## Rust Patterns para Testabilidade + +### 1. Sans-IO: Separar logica de efeitos + +O pattern mais importante. A ideia: funcoes de negocio recebem dados e retornam dados. Nunca fazem IO diretamente. + +**Antes** (resources.rs atual): +```rust +async fn apply_deployment(ctx: &Context, workload: &NiphasWorkload, eval: &EvalResult) -> Result<()> { + // 200 linhas construindo JSON + let json = serde_json::json!({ /* ... */ }); + // IO misturado no fim + let api: Api = Api::namespaced(ctx.client.clone(), ns); + api.patch(name, ¶ms, &Patch::Apply(&json)).await?; + Ok(()) +} +``` + +**Depois**: +```rust +// PURO -- recebe dados, retorna dados. Testavel sem K8s. +fn build_deployment(workload: &NiphasWorkload, eval: &EvalResult, name: &str, ns: &str) -> serde_json::Value { + serde_json::json!({ /* ... */ }) +} + +// IO fino -- so aplica +async fn apply_resource(ctx: &Context, name: &str, ns: &str, manifest: &serde_json::Value) -> Result<()> { + let api: Api = Api::namespaced(ctx.client.clone(), ns); + api.patch(name, &PatchParams::apply(FIELD_MANAGER).force(), &Patch::Apply(manifest)).await?; + Ok(()) +} +``` + +Teste: +```rust +#[test] +fn deployment_always_has_nix_store_node_selector() { + let json = build_deployment(&workload, &eval, "app", "ns"); + assert_eq!(json["spec"]["template"]["spec"]["nodeSelector"]["niphas.io/store"], "true"); +} +``` + +**Onde aplicar no Niphas:** + +| Arquivo | Funcao atual | Separar em | +|---------|-------------|------------| +| `resources.rs` | `apply_deployment` | `build_deployment` (puro) + `apply_resource` (IO) | +| `resources.rs` | `apply_service` | `build_service` (puro) + `apply_resource` (IO) | +| `resources.rs` | `apply_ingress` | `build_ingress` (puro) + `apply_resource` (IO) | +| `resources.rs` | `apply_pdb` | `build_pdb` (puro) + `apply_resource` (IO) | +| `reconciler.rs` | `set_phase` | `build_status_patch` (puro) + `apply_status` (IO) | +| `reconciler.rs` | `set_failed` | `build_failed_patch` (puro) + `apply_status` (IO) | +| `evaluator.rs` | `nix_eval` (subprocess) | `build_nix_expr` (puro) + `run_nix` (IO) | + +--- + +### 2. Trait Injection: Generics com static dispatch + +Para dependencias que precisam ser substituidas em testes. Preferir generics sobre `dyn Trait` quando possivel (zero-cost, sem vtable). + +**Pattern: trait + generic no struct** + +```rust +// Definir a capability +#[async_trait] +pub trait BinaryCacheClient: Send + Sync { + async fn fetch_narinfo(&self, store_path: &StorePath) -> Result; + async fn fetch_nar(&self, narinfo: &NarInfo) -> Result; + async fn fetch_narinfo_by_hash(&self, hash: &str) -> Result; +} + +// Struct real implementa +impl BinaryCacheClient for CacheClient { /* reqwest calls */ } + +// Consumidores sao genericos com default +pub struct NarCache { + cache_dir: PathBuf, + client: C, + locks: Mutex>>>, +} + +// Em producao: NarCache (default, nao precisa anotar) +// Em testes: NarCache +``` + +**Tres traits a extrair no Niphas:** + +#### Trait 1: `BinaryCacheClient` (maior impacto) + +Desbloqueia testes de: `closure.rs`, `evaluator.rs`, CSI `cache.rs`. + +```rust +// niphas-core/src/nix/cache_client.rs +#[async_trait] +pub trait BinaryCacheClient: Send + Sync { + async fn fetch_narinfo(&self, store_path: &StorePath) -> Result; + async fn fetch_nar(&self, narinfo: &NarInfo) -> Result; + async fn fetch_narinfo_by_hash(&self, hash: &str) -> Result; +} +``` + +Consumidores mudam: +```rust +// closure.rs: antes +pub async fn resolve_closure(client: &CacheClient, ...) -> ... +// closure.rs: depois +pub async fn resolve_closure(client: &C, ...) -> ... +``` + +#### Trait 2: `MountOps` (CSI) + +Desbloqueia testes de: `node.rs` (12 branches de publish/unpublish). + +```rust +// niphas-csi/src/mount.rs +pub trait MountOps: Send + Sync { + fn is_mountpoint(&self, path: &str) -> bool; + fn setup_target_dir(&self, path: &str) -> io::Result<()>; + fn bind_mount_readonly(&self, source: &str, target: &str) -> io::Result<()>; + fn unmount(&self, target: &str) -> io::Result<()>; + fn cleanup_target(&self, target: &str) -> io::Result<()>; +} + +pub struct LinuxMountOps; +impl MountOps for LinuxMountOps { /* libc::mount calls */ } + +// NodeService fica generico +pub struct NodeService { + node_id: String, + cache: Arc, + mount: M, +} +``` + +#### Trait 3: `NixEval` (eval subprocess) + +Desbloqueia testes de: `evaluator.rs` (pipeline completo). + +```rust +// niphas-eval/src/evaluator.rs +#[async_trait] +pub trait NixEval: Send + Sync { + async fn eval(&self, pinned_ref: &str, attribute: &str) -> Result; +} + +// Producao: chama tokio::process::Command("nix") +pub struct SubprocessNixEval { timeout: Duration } +impl NixEval for SubprocessNixEval { /* spawn nix eval */ } + +// Evaluator generico +pub struct Evaluator { + config: NiphasConfig, + nix: E, + cache: C, + warm: AtomicBool, +} +``` + +--- + +### 3. Fakes sobre Mocks + +Preferir **fakes** (implementacoes simplificadas que funcionam) sobre **mocks** (verificam chamadas). Fakes sao mais robustos a refactoring e capturam bugs reais. + +```rust +// FAKE -- implementacao in-memory que funciona +struct FakeCacheClient { + entries: HashMap, +} + +#[async_trait] +impl BinaryCacheClient for FakeCacheClient { + async fn fetch_narinfo(&self, sp: &StorePath) -> Result { + self.entries.get(&sp.hash_str()) + .cloned() + .ok_or(NiphasError::StorePathNotCached(sp.hash_str())) + } + async fn fetch_nar(&self, _ni: &NarInfo) -> Result { + Ok(Bytes::from_static(b"fake")) + } + async fn fetch_narinfo_by_hash(&self, hash: &str) -> Result { + self.entries.get(hash) + .cloned() + .ok_or(NiphasError::StorePathNotCached(hash.into())) + } +} + +// FAKE mount -- sem syscalls, tracking de chamadas +struct FakeMountOps { + mounted: Mutex>, +} + +impl MountOps for FakeMountOps { + fn is_mountpoint(&self, path: &str) -> bool { + self.mounted.lock().unwrap().contains(path) + } + fn bind_mount_readonly(&self, _source: &str, target: &str) -> io::Result<()> { + self.mounted.lock().unwrap().insert(target.into()); + Ok(()) + } + fn unmount(&self, target: &str) -> io::Result<()> { + self.mounted.lock().unwrap().remove(target); + Ok(()) + } + fn setup_target_dir(&self, _: &str) -> io::Result<()> { Ok(()) } + fn cleanup_target(&self, _: &str) -> io::Result<()> { Ok(()) } +} +``` + +**Quando usar mock (mockall) em vez de fake:** +- Quando voce precisa verificar que uma funcao foi chamada N vezes +- Quando precisa verificar a ordem das chamadas +- No Niphas: apenas para testes do reconciler verificando que status patches especificos foram enviados + +--- + +### 4. Test Fixtures: Builder Pattern + +Construir CRDs e structs de teste e verboso. Builders com defaults sensiveis eliminam boilerplate. + +```rust +// niphas-core/src/testutils.rs (feature-gated) + +pub struct WorkloadBuilder { + name: String, + ns: String, + flake_ref: String, + replicas: i32, + phase: Option, + store_path: Option, + generation: i64, + finalizers: Vec, + deletion: bool, +} + +impl WorkloadBuilder { + pub fn new(name: &str) -> Self { + Self { + name: name.into(), + ns: "default".into(), + flake_ref: "github:test/repo#pkg".into(), + replicas: 1, + phase: None, + store_path: None, + generation: 1, + finalizers: vec![], + deletion: false, + } + } + + pub fn replicas(mut self, n: i32) -> Self { self.replicas = n; self } + pub fn phase(mut self, p: &str) -> Self { self.phase = Some(p.into()); self } + pub fn evaluated(mut self, store_path: &str) -> Self { + self.phase = Some("Evaluated".into()); + self.store_path = Some(store_path.into()); + self + } + pub fn with_finalizer(mut self) -> Self { + self.finalizers.push("niphas.io/workload-cleanup".into()); + self + } + pub fn deleting(mut self) -> Self { self.deletion = true; self } + pub fn generation(mut self, g: i64) -> Self { self.generation = g; self } + + pub fn build(self) -> NiphasWorkload { /* constroi o CRD */ } +} + +// Uso em testes: +let w = WorkloadBuilder::new("app").replicas(3).evaluated("/nix/store/abc-hello").build(); +``` + +**Cross-crate sharing**: usar feature flag `testutils` em vez de `#[cfg(test)]`: + +```toml +# niphas-core/Cargo.toml +[features] +testutils = [] + +# niphas-operator/Cargo.toml +[dev-dependencies] +niphas-core = { workspace = true, features = ["testutils"] } +``` + +Isso resolve o problema de `#[cfg(test)]` nao ser visivel entre crates (cargo issue #8379). + +--- + +### 5. Snapshot Testing com insta + +Para outputs complexos (JSON de K8s resources, respostas HTTP, schemas CRD), snapshots sao melhores que assertions manuais. Eles capturam regressoes que voce nao pensou em testar. + +```rust +use insta::assert_json_snapshot; + +#[test] +fn deployment_snapshot_basic() { + let w = WorkloadBuilder::new("hello").replicas(1).build(); + let e = EvalResultBuilder::new("/nix/store/abc-hello").build(); + let json = build_deployment(&w, &e, "hello", "default"); + + assert_json_snapshot!("deployment-basic", json); + // Primeira execucao: cria snapshots/deployment-basic.snap + // Proximas: compara. cargo insta review para aceitar mudancas. +} + +#[test] +fn deployment_snapshot_multi_replica() { + let w = WorkloadBuilder::new("hello").replicas(3).build(); + let e = EvalResultBuilder::new("/nix/store/abc-hello").build(); + let json = build_deployment(&w, &e, "hello", "default"); + + assert_json_snapshot!("deployment-multi-replica", json); + // Verifica: topologySpreadConstraints presente, PDB criado +} +``` + +**Redactions para campos nao-deterministicos:** +```rust +assert_json_snapshot!(status, { + ".lastTransitionTime" => "[timestamp]", + ".observedGeneration" => "[gen]", +}); +``` + +**Onde usar no Niphas:** + +| Alvo | Por que snapshot | +|------|-----------------| +| `build_deployment` JSON | 200+ linhas de construcao condicional, regressoes sutis | +| `build_service` JSON | Mapeamento de portas, tipo de servico | +| `build_ingress` JSON | TLS, paths, hosts -- facil quebrar | +| `build_pdb` JSON | Condicional em replicas >= 2 | +| `AppError::into_response()` | Status codes + JSON body por variante | +| CRD schema (`NiphasWorkload::crd()`) | Schema drift entre versoes | +| Status patches do reconciler | JSON patches complexos | + +--- + +### 6. Property Testing com proptest + +Para parsers de protocolo e codecs, property tests encontram edge cases que testes manuais nunca cobrem. Especialmente valioso para o formato NAR (binary protocol com invariantes estritas). + +```rust +use proptest::prelude::*; + +// Gerar caracteres validos de nix-base32 +fn nix_base32_char() -> impl Strategy { + prop::sample::select("0123456789abcdfghijklmnpqrsvwxyz".chars().collect::>()) +} + +// Gerar store paths validos +fn arb_store_path() -> impl Strategy { + ( + prop::collection::vec(nix_base32_char(), 32), + "[a-z][a-z0-9._-]{0,20}" + ).prop_map(|(hash, name)| { + let h: String = hash.into_iter().collect(); + format!("/nix/store/{h}-{name}") + }) +} + +proptest! { + // Roundtrip: parse(display(x)) == x + #[test] + fn store_path_roundtrip(path in arb_store_path()) { + let parsed = StorePath::parse(&path).unwrap(); + let roundtripped = StorePath::parse(&parsed.to_path_string()).unwrap(); + prop_assert_eq!(parsed.hash_str(), roundtripped.hash_str()); + prop_assert_eq!(parsed.name(), roundtripped.name()); + } + + // Nix-base32: decode(encode(x)) == x + #[test] + fn nix_base32_roundtrip(data in prop::collection::vec(any::(), 0..64)) { + let encoded = nix_base32_encode(&data); + let decoded = nix_base32_decode(&encoded).unwrap(); + prop_assert_eq!(data, decoded); + } + + // narinfo parser nunca faz panic em input arbitrario + #[test] + fn narinfo_no_panic(input in "\\PC*") { + let _ = NarInfo::parse(&input); // Err e ok, panic nao + } + + // NAR entry names: validate rejeita todos os padroes proibidos + #[test] + fn nar_entry_name_rejects_forbidden( + name in prop_oneof![ + Just("".to_string()), + Just(".".to_string()), + Just("..".to_string()), + ".*/.+", // contem / + ".*\0.+", // contem null + ] + ) { + prop_assert!(validate_entry_name(&name).is_err()); + } +} +``` + +**Invariantes property-testaveis no Niphas:** + +| Modulo | Invariante | Property | +|--------|-----------|----------| +| `hash.rs` | base32 roundtrip | `decode(encode(x)) == x` | +| `store_path.rs` | parse roundtrip | `parse(display(x)) == x` | +| `nar.rs` | padding e 0..7 bytes de zeros | `pad_len(n) == (8 - n%8) % 8` | +| `nar.rs` | entries devem ser sorted | entries fora de ordem -> Err | +| `nar.rs` | depth limit funciona | depth > max -> Err | +| `narinfo.rs` | no panic on arbitrary input | `parse(random) != panic` | +| `config.rs` | duration parse roundtrip | `parse(format(d)) == d` | + +--- + +### 7. kube-rs Operator Testing: tower_test + +O pattern canonico de [controller-rs](https://github.com/kube-rs/controller-rs). `kube::Client` aceita qualquer `tower::Service` -- injetamos um mock. + +```rust +use tower_test::mock; +use kube::client::Body; + +fn mock_client() -> (kube::Client, mock::Handle, http::Response>) { + let (svc, handle) = mock::pair(); + let client = kube::Client::new(svc, "default"); + (client, handle) +} + +// Verificador de cenario +struct ApiServerVerifier(mock::Handle, http::Response>); + +impl ApiServerVerifier { + async fn expect_status_patch(mut self, expected_phase: &str) -> Self { + let (req, send) = self.0.next_request().await.expect("expected API call"); + assert_eq!(req.method(), http::Method::PATCH); + assert!(req.uri().to_string().contains("/status")); + // Verificar body contem a phase esperada + send.send_response(http::Response::builder() + .body(Body::from("{}")) + .unwrap()); + self + } +} + +#[tokio::test] +async fn reconcile_new_workload_adds_finalizer() { + let (client, handle) = mock_client(); + let ctx = Arc::new(Context::test(client)); + let workload = WorkloadBuilder::new("app").build(); // sem finalizer + + let verifier = ApiServerVerifier(handle); + let mock_task = tokio::spawn(async move { + verifier.expect_finalizer_patch().await; + }); + + let result = reconcile(Arc::new(workload), ctx).await.unwrap(); + assert_eq!(result, Action::requeue(Duration::ZERO)); + mock_task.await.unwrap(); +} +``` + +--- + +### 8. Tonic gRPC Testing: Chamada direta + +Servicos tonic sao traits Rust. Chamar os metodos diretamente sem transporte de rede: + +```rust +use tonic::Request; +use crate::csi::identity_server::Identity; + +#[tokio::test] +async fn identity_returns_correct_name() { + let svc = IdentityService; + let resp = svc.get_plugin_info(Request::new(GetPluginInfoRequest {})) + .await + .unwrap(); + assert_eq!(resp.into_inner().name, "niphas.io.csi"); +} + +#[tokio::test] +async fn publish_rejects_empty_volume_id() { + let svc = NodeService::new("node-1".into(), cache, FakeMountOps::new()); + let req = NodePublishVolumeRequest { + volume_id: "".into(), + ..Default::default() + }; + let err = svc.node_publish_volume(Request::new(req)).await.unwrap_err(); + assert_eq!(err.code(), tonic::Code::InvalidArgument); +} +``` + +--- + +### 9. Axum Handler Testing: tower oneshot + +```rust +use axum::body::Body; +use http::Request; +use tower::ServiceExt; + +#[tokio::test] +async fn healthz_returns_200() { + let app = health_router(HealthState { ready: Arc::new(AtomicBool::new(false)) }); + let resp = app + .oneshot(Request::get("/healthz").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), 200); +} + +#[tokio::test] +async fn readyz_returns_503_when_cold() { + let app = health_router(HealthState { ready: Arc::new(AtomicBool::new(false)) }); + let resp = app + .oneshot(Request::get("/readyz").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), 503); +} +``` + +--- + +## Tiering + +### Tier 1 -- Fast (< 5s, todo commit) + +Sem rede, sem K8s, sem root, sem nix. Usa fakes, builders, snapshots. + +```bash +cargo test --workspace +``` + +### Tier 2 -- Integration (CI com setup) + +Precisa de infra. Marcados `#[ignore]`. + +```bash +cargo test --workspace -- --ignored +``` + +| Tier | Requer | Exemplos | +|------|--------|----------| +| T1 | Nada | builders puros, closure BFS com fake cache, CSI publish com fake mount | +| T2 | kind cluster | reconciler contra API real, CRD lifecycle | +| T2 | nix binary | nix eval real, closure resolution contra cache.nixos.org | +| T2 | root | bind mount + unmount real | + +--- + +## Organizacao de Arquivos + +``` +crates/ + niphas-core/ + src/ + testutils.rs # feature "testutils": builders, fakes, helpers + nix/ + cache_client.rs # BinaryCacheClient trait + CacheClient impl + closure.rs # generico sobre BinaryCacheClient, inline tests com fake + nar.rs # inline tests existentes + proptest + hash.rs # inline tests existentes + proptest roundtrip + narinfo.rs # inline tests existentes + store_path.rs # inline tests existentes + proptest roundtrip + signature.rs # inline tests existentes + config.rs # inline tests novos + Cargo.toml # [features] testutils = [] + + niphas-operator/ + src/ + reconciler.rs # inline tests para funcoes puras + resources.rs # build_* puras + inline tests + snapshots + eval.rs # inline tests para EvalResult::from_status, resolved_command + health.rs # inline tests com oneshot + context.rs # http_client adicionado + tests/ + reconciler_mock.rs # tower_test scenarios (T1) + kind_integration.rs # #[ignore] (T2) + Cargo.toml # [dev-dependencies] tower-test, insta, niphas-core/testutils + + niphas-eval/ + src/ + evaluator.rs # generico sobre NixEval + BinaryCacheClient + error.rs # inline tests + snapshots + handlers.rs # inline tests com oneshot + tests/ + eval_integration.rs # #[ignore] (T2) + + niphas-csi/ + src/ + mount.rs # MountOps trait + LinuxMountOps + node.rs # generico sobre MountOps, inline tests com FakeMountOps + identity.rs # inline tests (chamada direta tonic) + cache.rs # generico sobre BinaryCacheClient + tests/ + mount_integration.rs # #[ignore] (T2) +``` + +--- + +## Dependencias de Teste + +```toml +# Cargo.toml (workspace) +[workspace.dependencies] +async-trait = "0.1" +proptest = "1" +insta = { version = "1", features = ["yaml", "json"] } +tower-test = "0.4" +``` + +```toml +# niphas-core/Cargo.toml +[dependencies] +async-trait = { workspace = true } # BinaryCacheClient trait e publico + +[dev-dependencies] +proptest = { workspace = true } +insta = { workspace = true } +tokio = { workspace = true, features = ["test-util"] } +``` + +```toml +# niphas-operator/Cargo.toml +[dev-dependencies] +tower-test = { workspace = true } +tower = { workspace = true, features = ["util"] } +insta = { workspace = true } +niphas-core = { workspace = true, features = ["testutils"] } +http = "1" +``` + +```toml +# niphas-csi/Cargo.toml +[dev-dependencies] +niphas-core = { workspace = true, features = ["testutils"] } +``` + +```toml +# niphas-eval/Cargo.toml +[dev-dependencies] +tower = { workspace = true, features = ["util"] } +niphas-core = { workspace = true, features = ["testutils"] } +http = "1" +http-body-util = "0.1" +``` + +--- + +## Ordem de Execucao + +``` +Passo 1: Foundation + 1.1 async-trait + BinaryCacheClient trait em cache_client.rs + 1.2 Atualizar closure.rs para generico + 1.3 Feature "testutils" com WorkloadBuilder + FakeCacheClient + 1.4 Testes de closure com FakeCacheClient (8 testes) + +Passo 2: Operator + 2.1 Split build_*/apply_* em resources.rs + 2.2 Snapshot tests dos builders (insta) + 2.3 Testes puros: needs_eval, has_finalizer, error_policy + 2.4 Testes puros: EvalResult::from_status, resolved_command + 2.5 Health handler tests com oneshot + 2.6 HTTP client no Context + +Passo 3: CSI + 3.1 MountOps trait + LinuxMountOps + 3.2 NodeService generico + 3.3 FakeMountOps + testes node publish/unpublish + 3.4 Identity service tests (chamada direta) + +Passo 4: Eval + 4.1 NixEval trait + SubprocessNixEval + 4.2 Evaluator generico + 4.3 Error response tests + snapshots + 4.4 Handler tests com oneshot + +Passo 5: Property Tests + 5.1 proptest para hash roundtrip + 5.2 proptest para store_path roundtrip + 5.3 proptest para narinfo no-panic + 5.4 proptest para NAR entry name validation + +Passo 6: Tier 2 + 6.1 mount_integration.rs (#[ignore]) + 6.2 eval_integration.rs (#[ignore]) + 6.3 kind_integration.rs (#[ignore]) +``` + +## Resultado Esperado + +| Metrica | Antes | Depois | +|---------|-------|--------| +| Testes Tier 1 | 32 | ~100 | +| Crates com testes | 2/5 | 4/5 | +| Tempo Tier 1 | <1s | <5s | +| Traits extraidos | 0 | 3 | +| Snapshot tests | 0 | ~15 | +| Property tests | 0 | ~8 | +| Test builders | 0 | WorkloadBuilder, EvalResultBuilder, NarInfoBuilder | diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts new file mode 100644 index 0000000..973c704 --- /dev/null +++ b/docs/.vitepress/config.ts @@ -0,0 +1,74 @@ +import { defineConfig } from 'vitepress' + +export default defineConfig({ + title: 'niphas', + description: 'Nix-native platform for Kubernetes', + appearance: 'dark', + + themeConfig: { + nav: [ + { text: 'Architecture', link: '/architecture/' }, + { text: 'Components', link: '/components/' }, + { text: 'Reference', link: '/reference/' }, + ], + + sidebar: { + '/architecture/': [ + { + text: 'Architecture', + items: [ + { text: 'Overview', link: '/architecture/' }, + { text: 'Runtime Model', link: '/architecture/runtime-model' }, + { text: 'Deploy Flow', link: '/architecture/deploy-flow' }, + ], + }, + ], + '/components/': [ + { + text: 'Components', + items: [ + { text: 'Overview', link: '/components/' }, + { text: 'Operator', link: '/components/operator' }, + { text: 'Evaluator', link: '/components/eval' }, + { text: 'CSI Driver', link: '/components/csi-driver' }, + { text: 'Mesh Protocol', link: '/components/mesh-protocol' }, + ], + }, + ], + '/reference/': [ + { + text: 'API & Formats', + items: [ + { text: 'Overview', link: '/reference/' }, + { text: 'CRD Reference', link: '/reference/crd-reference' }, + { text: 'Nix Wire Format', link: '/reference/nix-wire' }, + ], + }, + { + text: 'Operations', + items: [ + { text: 'Security Design', link: '/reference/security-design' }, + { text: 'Resilience', link: '/reference/resilience' }, + { text: 'Testing', link: '/reference/testing' }, + ], + }, + { + text: 'Other', + items: [ + { text: 'Security Policy', link: '/reference/security' }, + { text: 'Known Issues', link: '/reference/problem' }, + ], + }, + ], + }, + + socialLinks: [ + { icon: 'github', link: 'https://github.com/fullzer4/niphas' }, + ], + + footer: { + message: 'Released under the MIT License.', + copyright: 'Copyright 2024-present niphas contributors', + }, + }, +}) diff --git a/docs/architecture/deploy-flow.md b/docs/architecture/deploy-flow.md new file mode 100644 index 0000000..0d470b8 --- /dev/null +++ b/docs/architecture/deploy-flow.md @@ -0,0 +1,113 @@ +# Deploy Flow + +From a single CRD to a running workload — no Dockerfile, no registry, no image tags. + +## User Experience + +```yaml +apiVersion: niphas.io/v1 +kind: NiphasWorkload +metadata: + name: hello +spec: + flakeRef: "github:NixOS/nixpkgs" + attribute: "hello" +``` + +That's it. The operator evaluates the flake reference, resolves the closure, creates a Deployment with CSI volumes, and the workload runs. + +## Production Example + +```yaml +apiVersion: niphas.io/v1 +kind: NiphasWorkload +metadata: + name: my-app +spec: + flakeRef: "github:myorg/myapp" + attribute: "packages.x86_64-linux.default" + revision: "abc123" + replicas: 3 + args: ["--port", "8080"] + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: db-credentials + key: url + ports: + - containerPort: 8080 + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "500m" + memory: "512Mi" + livenessProbe: + httpGet: + path: /healthz + port: 8080 + service: + type: ClusterIP + ports: + - port: 80 + targetPort: 8080 + ingress: + enabled: true + className: nginx + hosts: + - host: my-app.example.com + paths: + - path: / + pathType: Prefix +``` + +## Build Model + +**CI builds, the cluster evaluates and fetches** (never builds). + +1. CI runs `nix build` and pushes to a binary cache +2. `niphas-eval` runs `nix eval` to resolve the store path deterministically +3. If the path isn't cached, the workload fails with `StorePathNotCached` + +## End-to-End Sequence + +1. User creates `NiphasWorkload` CR +2. Operator detects new resource, sets phase `Evaluating` +3. Operator calls eval webhook with `flakeRef`, `attribute`, `revision` +4. Eval validates against flake allowlist (deny-by-default) +5. Eval resolves store path via Nix C FFI +6. Eval resolves full closure via binary cache `.narinfo` (parallel BFS) +7. Operator receives `storePath`, `closurePaths`, `mainProgram` +8. Operator creates Deployment, Service, Ingress (server-side apply) +9. Kubelet schedules pods on nodes with `niphas.io/store=true` label +10. CSI driver fetches NARs (cache → mesh → binary cache) +11. Pod starts with Nix binary as entrypoint +12. Operator sets phase `Running` + +## Command Resolution + +If `spec.command` is omitted: + +1. Eval checks `meta.mainProgram` in the derivation +2. If not set, lists `$out/bin/` +3. If exactly one binary found, uses it +4. Otherwise, fails with an error + +## Updates and Rollouts + +Changing the spec increments `metadata.generation`: + +1. Operator detects `observedGeneration < generation` +2. Re-evaluates if `flakeRef`, `attribute`, or `revision` changed +3. Patches Deployment with new CSI `volumeAttributes` and command +4. Kubernetes performs a rolling update + +## Multi-Architecture + +The `architectures` field triggers parallel evaluation: + +- `attribute` must contain `{arch}` placeholder (e.g., `packages.{arch}.default`) +- One Deployment created per architecture +- A single Service selects all pods via shared labels diff --git a/docs/architecture/index.md b/docs/architecture/index.md new file mode 100644 index 0000000..3fd93e8 --- /dev/null +++ b/docs/architecture/index.md @@ -0,0 +1,64 @@ +# Architecture & Project Structure + +Niphas is a Nix-native platform for Kubernetes, organized as a Rust workspace with five crates. + +## Directory Layout + +``` +niphas/ +├── crates/ +│ ├── niphas-core/ # shared library (CRDs, Nix formats, cache client) +│ ├── niphas-eval/ # Nix evaluator webhook (C FFI) +│ ├── niphas-operator/ # Kubernetes operator +│ ├── niphas-csi/ # CSI node driver +│ └── niphas-mesh/ # P2P NAR distribution (libp2p) +├── proto/ # vendored CSI gRPC proto +├── nix/ # flake-parts modules +├── chart/ # Helm chart +└── flake.nix # Nix build with Crane +``` + +## Core Crate + +`niphas-core` is the shared library used by all other crates: + +| Module | Purpose | +|--------|---------| +| `crd.rs` | `NiphasWorkload` CRD definition | +| `hash.rs` | Nix-base32 encoding, SHA-256, `NixHash` | +| `nar.rs` | NAR streaming parser and extractor | +| `narinfo.rs` | `.narinfo` parser | +| `store_path.rs` | Store path validation | +| `cache_client.rs` | Binary cache HTTP client | +| `signature.rs` | Ed25519 signature verification | +| `closure.rs` | Parallel closure resolution (BFS) | + +## Build System + +The Nix build uses [Crane](https://crane.dev) with a two-phase strategy: + +1. **`buildDepsOnly`** — cacheable dependency layer (only `Cargo.lock` and `Cargo.toml` files) +2. **`buildPackage`** — per-crate build on top of cached deps + +OCI images are produced via `dockerTools.buildLayeredImage` with minimal contents (static musl binaries). + +## Key Design Decisions + +- **Axum** over actix-web — native tower/hyper ecosystem, async-first +- **Vendored CSI proto** — sandboxed Nix builds can't fetch at build time +- **Selective libp2p** — only TCP, QUIC, Noise, Yamux, Gossipsub features enabled +- **Workspace dependency inheritance** — single source of truth in root `Cargo.toml` + +## Observability + +- Structured JSON logging via `tracing-subscriber` +- Optional OpenTelemetry integration (zero overhead without `OTEL_*` env vars) +- Distributed tracing via W3C TraceContext propagation + +## Input Validation + +Three-layer defense against Nix injection: + +1. **Regex validation** — `flake_ref`, `attribute`, `revision` validated before use +2. **Flake allowlist** — deny-by-default glob matching +3. **Nix sandbox** — `restrict_eval=true`, `allow_import_from_derivation=false`, `max_jobs=0` diff --git a/docs/architecture/runtime-model.md b/docs/architecture/runtime-model.md new file mode 100644 index 0000000..9e79fe7 --- /dev/null +++ b/docs/architecture/runtime-model.md @@ -0,0 +1,57 @@ +# Runtime Model + +How Nix-built binaries run inside Kubernetes without traditional container images. + +## The Core Insight + +Nix closures are more self-contained than OCI images. Every binary carries absolute `/nix/store` paths for all dependencies — the dynamic linker, shared libraries, and runtime data are all pinned. + +## How Nix Makes Binaries Self-Contained + +- **`patchelf`** rewrites `PT_INTERP` (dynamic linker) and `DT_RUNPATH` (shared lib search) to `/nix/store` paths +- **`patchShebangs`** fixes script interpreters (`#!/nix/store/...`) +- **Closures** are transitively complete — every referenced store path is included +- **Static builds** (musl) have no `PT_INTERP` or `DT_RUNPATH` at all + +## How It Runs in Kubernetes + +**Problem:** Kubernetes requires an `image` field on every container. + +**Solution:** A stub image (`niphas-runner`, ~1 MB) provides the minimal filesystem, while the actual binary comes from a CSI volume mount. + +The stub provides only: +- `/etc/passwd`, `/etc/group`, `/etc/nsswitch.conf` +- `/tmp` + +The CSI driver mounts the Nix closure at `/nix/store`, making the binary and all its dependencies available. + +### Execution Sequence + +1. Kubelet calls `NodePublishVolume` on the CSI driver +2. CSI checks local cache → mesh peers → binary cache HTTP +3. NAR is fetched, verified (Ed25519 + hash), and extracted +4. Bind-mount at the pod's volume target +5. Container starts with the Nix binary as entrypoint +6. Dynamic linker loads from `/nix/store` paths — no host dependencies needed + +Kubelet automatically provides `/proc`, `/sys`, `/dev`, `/etc/resolv.conf`, `/etc/hosts`. + +## Edge Cases + +| Scenario | Solution | +|----------|----------| +| Programs hardcoding `/bin/sh` | Fix at package level or symlink in stub | +| GPU workloads | Standard NVIDIA device plugin pattern | +| `setuid` binaries | CSI mount uses `MS_NOSUID` | +| `dlopen` plugins | `patchelf`, `wrapProgram`, or CRD env vars | + +## Comparison with Traditional Containers + +| Aspect | Traditional OCI | Niphas | +|--------|----------------|--------| +| Build | Dockerfile → layers | Nix derivation → NAR | +| Size | 50MB–1GB typical | Exact closure size | +| Deduplication | Per-layer | Per-store-path | +| Reproducibility | Best-effort | Bit-for-bit | +| Registry | Required | Binary cache (optional) | +| Update granularity | Full layer | Individual store paths | diff --git a/docs/components/csi-driver.md b/docs/components/csi-driver.md new file mode 100644 index 0000000..8d341e6 --- /dev/null +++ b/docs/components/csi-driver.md @@ -0,0 +1,94 @@ +# CSI Driver Design + +The niphas-csi driver mounts Nix store paths as ephemeral volumes inside Kubernetes pods. + +## Why CSI + +- Official Kubernetes storage abstraction +- Works under Pod Security Standards Restricted (consumer pods stay unprivileged) +- Functions on all managed Kubernetes providers +- CSI ephemeral inline volumes are GA since Kubernetes 1.25 + +## Architecture + +Node-only plugin — no Controller service. The driver runs as a DaemonSet on nodes labeled `niphas.io/store=true`. + +### gRPC Services + +**Identity Service:** +- `GetPluginInfo` — returns driver name and version +- `GetPluginCapabilities` — no optional capabilities +- `Probe` — readiness check + +**Node Service:** +- `NodePublishVolume` — fetch NAR and bind-mount at target +- `NodeUnpublishVolume` — unmount and clean up +- `NodeGetCapabilities` — reports supported features +- `NodeGetInfo` — returns node ID + +No Controller service is needed — everything is node-local. + +## Volume Lifecycle + +1. **Pod creation** → kubelet calls `NodePublishVolume` +2. Driver checks local cache → mesh peers → binary cache HTTP +3. NAR fetched, verified (Ed25519 signatures + hash), extracted +4. Bind-mount at target path +5. **Pod deletion** → kubelet calls `NodeUnpublishVolume` +6. Unmount, cached data persists for future pods + +Both publish and unpublish are idempotent. + +## NAR Fetch Chain + +``` +local cache ──miss──► mesh P2P ──miss──► binary cache HTTP ──miss──► gRPC Unavailable + (instant) (LAN, 5s) (WAN, HTTPS) (kubelet retries) +``` + +For each NAR: + +1. Fetch `.narinfo` → verify Ed25519 signature +2. Download compressed NAR → verify `FileHash` +3. Decompress → verify `NarHash` (inline streaming) +4. Extract to `.tmp-*` → atomic rename (CVE-2024-45593 mitigations) + +**Unverified NAR never reaches a pod.** + +## Node-Local Cache + +Location: `/var/lib/niphas/cache/` + +- Content-addressed storage +- Atomic population (`.tmp-*` then rename) +- Crash-safe — incomplete downloads are cleaned up +- LRU garbage collection (high watermark 85%, low watermark 75%) + +## Closure Handling + +The eval webhook pre-resolves the full closure and passes it via `volumeAttributes.closurePaths`. The CSI driver fetches individual NARs — it doesn't need `.narinfo` parsing for closure resolution. + +## DaemonSet + +The CSI DaemonSet requires: + +- **Privileged mode** — needed for `Bidirectional` mount propagation +- `hostPath` volumes for socket registration and NAR cache +- Sidecars: `node-driver-registrar` (required), `livenessprobe` (recommended) + +Consumer pods remain fully unprivileged — the CSI abstraction layer handles the privilege boundary. + +## CSIDriver Object + +```yaml +apiVersion: storage.k8s.io/v1 +kind: CSIDriver +metadata: + name: niphas.io.csi +spec: + attachRequired: false + podInfoOnMount: true + volumeLifecycleModes: + - Ephemeral + fsGroupPolicy: None +``` diff --git a/docs/components/eval.md b/docs/components/eval.md new file mode 100644 index 0000000..d0a33d8 --- /dev/null +++ b/docs/components/eval.md @@ -0,0 +1,111 @@ +# Eval Webhook Design + +The niphas-eval service evaluates Nix flake references and resolves closures via the Nix C FFI — no subprocess, no container. + +## Architecture + +``` + operator ──POST /evaluate──► niphas-eval + │ + ┌───────┼────────┐ + │ │ │ + validate nix eval closure + allowlist C FFI resolution + │ + binary cache + GET /.narinfo +``` + +## HTTP API + +### `POST /evaluate` + +**Request:** +```json +{ + "flakeRef": "github:myorg/myapp", + "attribute": "packages.x86_64-linux.default", + "revision": "abc123", + "binaryCache": "https://cache.example.com" +} +``` + +**Response (200):** +```json +{ + "storePath": "/nix/store/abc...-myapp-1.0", + "name": "myapp-1.0", + "mainProgram": "myapp", + "closurePaths": [ + "/nix/store/abc...-myapp-1.0", + "/nix/store/def...-glibc-2.38" + ] +} +``` + +**Error responses:** + +| Code | Error | Meaning | +|------|-------|---------| +| 403 | `FlakeNotAllowed` | Flake origin not in allowlist | +| 404 | `StorePathNotCached` | Path not found in binary cache | +| 408 | `EvalTimeout` | Evaluation exceeded timeout | +| 422 | `EvalFailed` | Nix evaluation error | +| 502 | `ClosureResolutionFailed` | Cannot resolve full closure | + +### Health Endpoints + +- `GET /healthz` — process running +- `GET /readyz` — evaluator initialized (503 during cold start) + +## Nix C FFI + +niphas-eval links directly against the Nix C libraries (`libexpr-c`, `libstore-c`, `libflake-c`) via `nix-bindings-rust`. The evaluator runs in-process. + +- `EvalState` initialized once, shared across requests (thread-safe C API) +- CPU-bound evaluation dispatched via `spawn_blocking` +- Nix C API serializes evaluations internally (internal mutex) + +## Eval Settings + +``` +sandbox = true +restrict_eval = true +allowed_uris = [github.com, cache.nixos.org] +allow_import_from_derivation = false # critical +max_jobs = 0 +``` + +## Flake Allowlist + +Deny-by-default glob matching. Patterns support `*` wildcards. Validated **before** the evaluator is invoked. + +## Closure Resolution + +After evaluation, the eval service resolves the full transitive closure via binary cache HTTP: + +1. Fetch `.narinfo` for the root store path +2. Parse `References` field +3. Recursively fetch `.narinfo` for each reference (parallel BFS) +4. Return complete closure list + +Uses `FuturesUnordered` with bounded concurrency (default 32). + +## Eval Cache + +The `/nix/store` PVC persists fetched flake inputs and evaluated derivations: + +| Scenario | Latency | +|----------|---------| +| Cold eval (first time) | 5–15s | +| Same revision | 1–3s | +| Same flake, cached inputs | less than 100ms | + +**PVC sizing:** 10–20 GB typical (nixpkgs alone is ~1 GB). + +## Deployment + +- 2 replicas recommended +- PVC for `/nix/store` (eval cache must persist) +- Non-root, read-only rootfs, no capabilities +- Resource requests: 500m CPU, 1Gi RAM diff --git a/docs/components/index.md b/docs/components/index.md new file mode 100644 index 0000000..ef240d7 --- /dev/null +++ b/docs/components/index.md @@ -0,0 +1,29 @@ +# Components + +Niphas is composed of four core components that work together to deliver Nix-built workloads on Kubernetes. + +``` + ┌──────────────┐ + NiphasWorkload │ Operator │──── eval webhook ────► Evaluator + CR ────────────►│ (reconcile) │ │ + └──────┬───────┘ nix eval C FFI + │ │ + create Deployment binary cache + + CSI volume closure resolve + │ + ┌──────▼───────┐ ┌─────────────┐ + kubelet ──►│ CSI Driver │◄──────►│ Mesh │ + │ (DaemonSet) │ socket │ (DaemonSet) │ + └──────────────┘ └─────────────┘ + fetch NAR P2P distribute + bind-mount gossipsub +``` + +## Overview + +| Component | Type | Role | +|-----------|------|------| +| [Operator](/components/operator) | Deployment | Watches CRDs, reconciles into Deployments/Services/Ingress | +| [Evaluator](/components/eval) | Deployment | Nix evaluation via C FFI, closure resolution | +| [CSI Driver](/components/csi-driver) | DaemonSet | Fetches NARs, mounts `/nix/store` into pods | +| [Mesh](/components/mesh-protocol) | DaemonSet | Optional P2P NAR distribution between nodes | diff --git a/docs/components/mesh-protocol.md b/docs/components/mesh-protocol.md new file mode 100644 index 0000000..cf1456e --- /dev/null +++ b/docs/components/mesh-protocol.md @@ -0,0 +1,94 @@ +# Mesh Protocol Design + +Optional P2P distribution of NARs between nodes via libp2p. An optimization for scale, speed, and resilience. + +## Overview + +The mesh is a DaemonSet providing peer-to-peer NAR distribution across cluster nodes. It's optional — the system works without it, falling back to binary cache HTTP. + +**When it helps:** +- Scale: 100+ nodes pulling the same NAR +- Speed: LAN transfer vs WAN binary cache +- Resilience: if binary cache is temporarily down + +## Architecture + +Every mesh pod is equal — no leader election needed. NARs are content-addressed and immutable, so any peer can serve any cached NAR. + +Deployed only on nodes with both `niphas.io/store=true` and `niphas.io/mesh=true` labels. Resource usage: ~40–60 MB RAM when idle. + +## libp2p Stack + +| Layer | Choice | +|-------|--------| +| Transport | TCP + QUIC | +| Encryption | Noise XX (mutual auth, PFS) | +| Multiplexing | Yamux | +| Discovery | DNS (Headless Service) + K8s API watch | +| Messaging | Gossipsub | +| Data transfer | libp2p streams | + +Port: 4001 (TCP + UDP) + +## Protocol Messages + +### Gossipsub (topic: `niphas/nar/v1`) + +- **Have** — announces a NAR is available locally (`nar_hash`, `store_path`, `nar_size`) +- **Evicted** — announces a NAR was garbage-collected (`nar_hash`) + +### Control Plane (CBOR) + +- **HasNar** / **QueryNarInfo** — point-to-point queries +- Responses: `Available`, `NarSize`, `NarInfo`, `NotFound`, `Busy` + +### Data Plane (libp2p streams) + +- `TransferHeader` (48 bytes fixed) + raw compressed NAR bytes +- For NARs larger than 64 MB: parallel range download (swarming) from multiple peers + +## NAR Index + +Each node maintains a local `HashMap` — "who has NAR X?" — populated entirely from gossipsub messages. O(1) lookup, ~240 KB for 3000 store paths. + +## Peer Discovery + +1. **Bootstrap:** Headless Service DNS resolution +2. **Dynamic:** K8s API watch for real-time membership changes + node labels + +## CSI Integration + +The CSI driver communicates with the mesh via a Unix socket at `/var/run/niphas/mesh.sock` using JSON-RPC: + +``` +CSI ──FetchNar──► mesh socket + │ + NAR index lookup + │ + fetch from best peer + (same zone preferred) + │ + verify + announce Have + │ + respond Fetched +``` + +If mesh is unavailable, CSI falls back to binary cache HTTP transparently. + +## Cache Management + +- **Per-node lazy caching** — each node only caches NARs for pods it runs +- No proactive replication — minimal bandwidth overhead +- **GC:** high watermark 85% → evict LRU unmounted NARs → low watermark 75% + +## Authentication + +- Noise XX handshake provides mutual authentication and perfect forward secrecy +- Closed mesh: PeerId allowlist populated from K8s API watch +- Keys generated on first boot, stored in K8s Secret per node + +## Bandwidth Control + +- Per-peer rate limiting via `governor` crate +- Priority queue: CSI mount requests take priority over peer-serving requests +- Configurable connection limits diff --git a/docs/components/operator.md b/docs/components/operator.md new file mode 100644 index 0000000..4e5e299 --- /dev/null +++ b/docs/components/operator.md @@ -0,0 +1,102 @@ +# Operator Design + +The niphas-operator watches `NiphasWorkload` custom resources and reconciles them into running Kubernetes workloads. + +## Reconciliation State Machine + +``` + ┌──────────┐ + create │ Pending │ + ────────► └────┬─────┘ + │ + ┌────▼─────┐ + eval ok │Evaluating│──── eval error ──► Failed + └────┬─────┘ + │ + ┌─────▼──────┐ + apply ok │Provisioning│──── apply error ─► Failed + └─────┬──────┘ + │ + ┌────▼─────┐ + ready │ Running │──── spec change ──► Evaluating + └──────────┘ +``` + +## Reconciler Loop + +Each reconciliation: + +1. Compare `observedGeneration` vs `metadata.generation` +2. If eval needed → call eval webhook (POST to niphas-eval) +3. On success → generate child resources (Deployment, Service, Ingress, PDB) +4. Apply via server-side apply (SSA) for idempotence +5. Check replica readiness → update phase +6. Requeue with interval + +## Eval Webhook Integration + +The operator calls `POST /evaluate` on niphas-eval: + +**Request:** +```json +{ + "flakeRef": "github:myorg/myapp", + "attribute": "packages.x86_64-linux.default", + "revision": "abc123", + "binaryCache": "https://cache.example.com" +} +``` + +**Response (200):** +```json +{ + "storePath": "/nix/store/abc...-myapp-1.0", + "name": "myapp-1.0", + "mainProgram": "myapp", + "closurePaths": ["/nix/store/abc...-myapp-1.0", "/nix/store/def...-glibc-2.38", "..."] +} +``` + +**Error codes:** `FlakeNotAllowed` (403), `EvalFailed` (422), `EvalTimeout` (408), `StorePathNotCached` (404), `ClosureResolutionFailed` (502) + +## Child Resources + +The operator generates and applies: + +- **Deployment** — with `nodeSelector` (`niphas.io/store=true`), CSI ephemeral volume, runner image, tolerations for fast failover +- **Service** — conditional, created only if `spec.service` is set +- **Ingress** — conditional, created only if `spec.ingress.enabled` is true +- **PodDisruptionBudget** — created when `replicas >= 2`, `maxUnavailable=1` + +All child resources use `ownerReferences` pointing back to the `NiphasWorkload`. + +## Leader Election + +Lease-based leader election with: +- 15s TTL +- 5s renewal interval +- 5s retry interval + +New leader acquired within ~15s on failure. Reconciliation is idempotent, so brief dual-leader windows are safe. + +## Error Handling + +| Error | Strategy | +|-------|----------| +| Eval timeout | Requeue after 30s | +| Eval unreachable | Exponential backoff | +| SSA conflict | Immediate retry | +| Transient K8s error | 5s + backoff | +| Permanent error | Set phase `Failed` | + +## Events + +The operator emits Kubernetes Events: + +`EvalStarted`, `EvalSucceeded`, `EvalFailed`, `FlakeNotAllowed`, `StorePathNotCached`, `Provisioned`, `Available`, `Degraded`, `Deleting` + +## Health Probes + +- `/healthz` — process alive +- `/readyz` — leader acquired and watching +- `/metrics` — Prometheus endpoint diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..22fb785 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,23 @@ +--- +layout: home + +hero: + name: niphas + text: Nix-native platform for Kubernetes + tagline: "νιφάς — declarative workloads, evaluated and delivered by Nix" + actions: + - theme: brand + text: Get Started + link: /architecture/ + - theme: alt + text: GitHub + link: https://github.com/fullzer4/niphas + +features: + - title: Nix Evaluator + details: Evaluates flake references into derivations and streams build artifacts directly into the cluster. + - title: CSI Driver + details: Mounts Nix store paths as volumes, enabling transparent access to build outputs for workloads. + - title: Mesh Protocol + details: Coordinates operator, evaluator, and CSI driver through a resilient gRPC mesh. +--- diff --git a/docs/package-lock.json b/docs/package-lock.json new file mode 100644 index 0000000..9dfaa89 --- /dev/null +++ b/docs/package-lock.json @@ -0,0 +1,2550 @@ +{ + "name": "niphas-docs", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "niphas-docs", + "devDependencies": { + "vitepress": "^1.6.3" + } + }, + "node_modules/@algolia/abtesting": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.21.0.tgz", + "integrity": "sha512-kGvHfBa9oQCvZh0YXeguSToBD9GNJ+gzUZQ9KPTg+KSsM36obYcsKPoX0NnlJtPflHXu7RkMaIi44xs9meR6Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.0", + "@algolia/requester-browser-xhr": "5.55.0", + "@algolia/requester-fetch": "5.55.0", + "@algolia/requester-node-http": "5.55.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/autocomplete-core": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", + "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", + "@algolia/autocomplete-shared": "1.17.7" + } + }, + "node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", + "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" + } + }, + "node_modules/@algolia/autocomplete-preset-algolia": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", + "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/autocomplete-shared": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", + "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/client-abtesting": { + "version": "5.55.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.55.0.tgz", + "integrity": "sha512-Zt2GjIm7vsaf7K23tk5JmtcVNc38G9p0C2L2Lrm06miyLE/NL2etHtHInvuLc1DjxTp7Y2nId4X/tzwo372K8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.0", + "@algolia/requester-browser-xhr": "5.55.0", + "@algolia/requester-fetch": "5.55.0", + "@algolia/requester-node-http": "5.55.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "5.55.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.55.0.tgz", + "integrity": "sha512-7BueMuWYg/KBA2EX9zsQ+3OAleEyrJcB+SV5Al/9pLjMQq5mXB/8M5HaUPqZwN812g5kLzj9j43VThlZgWq0hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.0", + "@algolia/requester-browser-xhr": "5.55.0", + "@algolia/requester-fetch": "5.55.0", + "@algolia/requester-node-http": "5.55.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "5.55.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.55.0.tgz", + "integrity": "sha512-pJZIyhvUrs+B7c5Lw0iP5yP/NsqJMda7pKRYbfG4KtfGIVSMcAalZhdqL5UX8Z9DOC4KxO9tKV5RDeVjZU0VfQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-insights": { + "version": "5.55.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.55.0.tgz", + "integrity": "sha512-RydkKDhx0GWTYuw0ndTXHGM8hD8hgwftKE65FfnJZb5bPc9CevOqv3qNPUQiviAwkqT9hQNH31uDGeV3yZkgfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.0", + "@algolia/requester-browser-xhr": "5.55.0", + "@algolia/requester-fetch": "5.55.0", + "@algolia/requester-node-http": "5.55.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "5.55.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.55.0.tgz", + "integrity": "sha512-XiS7gdFq/COWiwdWXZ8+RHuewfEo03TkGESk44zU8zTc/Z6R8fm4DNmV52swJKkeB2N9iC7NKpgpM22OOkcgTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.0", + "@algolia/requester-browser-xhr": "5.55.0", + "@algolia/requester-fetch": "5.55.0", + "@algolia/requester-node-http": "5.55.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-query-suggestions": { + "version": "5.55.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.55.0.tgz", + "integrity": "sha512-LBEJ/q+hn1nJ0aYg5IcWgLNCPjWHTahWmpHNx1qUZMho+9CyWM6LaEnhac45UHjQm/j0m374HP685VrpL133lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.0", + "@algolia/requester-browser-xhr": "5.55.0", + "@algolia/requester-fetch": "5.55.0", + "@algolia/requester-node-http": "5.55.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "5.55.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.55.0.tgz", + "integrity": "sha512-2/9jUXKH4IcdU5qxH6cbDH46ZBe46G7xr+MrcHwgEXZcUfdAvUgLSH53MAWuMgxvw0G5yoqiWMifHc62Os0fiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.0", + "@algolia/requester-browser-xhr": "5.55.0", + "@algolia/requester-fetch": "5.55.0", + "@algolia/requester-node-http": "5.55.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/ingestion": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.55.0.tgz", + "integrity": "sha512-80tKsQgxXWo+jK0v4YGCHqyTEXawhAKYyr3kOdN51ElfRqUFjZNPVhZk6vRiqSqXfvrH85ytacT3cbJR6+qolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.0", + "@algolia/requester-browser-xhr": "5.55.0", + "@algolia/requester-fetch": "5.55.0", + "@algolia/requester-node-http": "5.55.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/monitoring": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.55.0.tgz", + "integrity": "sha512-4UjmAL8ywGW4rCfK6Qmgw3wIjbrO2wl2s4Eq56JTiN40L2t0XTv0HZkYAmr6nfeiXO0he/2crvZRX6SATSepag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.0", + "@algolia/requester-browser-xhr": "5.55.0", + "@algolia/requester-fetch": "5.55.0", + "@algolia/requester-node-http": "5.55.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/recommend": { + "version": "5.55.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.55.0.tgz", + "integrity": "sha512-LMpJPtIkfDsHIx5Ga+baNr22ntYbY+e2wT7MSIc/FjAnu9wnBFhx1H/GfhmP/c5/IvbThDX+3ilxPRjSfCI8aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.0", + "@algolia/requester-browser-xhr": "5.55.0", + "@algolia/requester-fetch": "5.55.0", + "@algolia/requester-node-http": "5.55.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.55.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.55.0.tgz", + "integrity": "sha512-tDymJ7nFOAoUuecma3usK6o94dp8m4HYFDGh4ByYQXWkv14cpmDn+nWdylmcZO0Qvco107vqDo4+Anksnl8w1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-fetch": { + "version": "5.55.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.55.0.tgz", + "integrity": "sha512-6IDSB5o5dkDPQ4LdOW0Yuw/qy5MdWlO2xDHgPVZgW4YDjbxvnX5PAiV7/WWZdWyVObScZZnnHpPbiqfYs/zBLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-node-http": { + "version": "5.55.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.55.0.tgz", + "integrity": "sha512-Yyyne4l//vDSdg4MhYJkaVne+KEPi833eCj3/T/87ernTwrvP6j9biXXZELsN8sLI/f2ndV/vugDIy2jdJQB6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@docsearch/css": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", + "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@docsearch/js": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", + "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/react": "3.8.2", + "preact": "^10.0.0" + } + }, + "node_modules/@docsearch/react": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", + "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-core": "1.17.7", + "@algolia/autocomplete-preset-algolia": "1.17.7", + "@docsearch/css": "3.8.2", + "algoliasearch": "^5.14.2" + }, + "peerDependencies": { + "@types/react": ">= 16.8.0 < 19.0.0", + "react": ">= 16.8.0 < 19.0.0", + "react-dom": ">= 16.8.0 < 19.0.0", + "search-insights": ">= 1 < 3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "search-insights": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@iconify-json/simple-icons": { + "version": "1.2.86", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.86.tgz", + "integrity": "sha512-t3jck5qPQuK1qy+bRn9eCoDQhIB7XSazKz1Fjp8hcan3XOAsTI5Mq/s3F0ekOKSvMQqkVORYK6ns6o6T9f5EMA==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", + "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.4" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", + "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^3.1.0" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", + "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", + "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", + "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/transformers": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", + "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/types": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", + "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.38.tgz", + "integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.38", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz", + "integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz", + "integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.38", + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz", + "integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz", + "integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.38.tgz", + "integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz", + "integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/runtime-core": "3.5.38", + "@vue/shared": "3.5.38", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz", + "integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "vue": "3.5.38" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.38.tgz", + "integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", + "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/integrations": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", + "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vueuse/core": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", + "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", + "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/algoliasearch": { + "version": "5.55.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.55.0.tgz", + "integrity": "sha512-af+rI+tUVeS9KWHPAZQHIHPOIC3StPRR6IwQu2nz1aQoTL6Gs5Ty3KsHCgbXMHOpoh9QqSjq8F3KJ8xmaCZSBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.21.0", + "@algolia/client-abtesting": "5.55.0", + "@algolia/client-analytics": "5.55.0", + "@algolia/client-common": "5.55.0", + "@algolia/client-insights": "5.55.0", + "@algolia/client-personalization": "5.55.0", + "@algolia/client-query-suggestions": "5.55.0", + "@algolia/client-search": "5.55.0", + "@algolia/ingestion": "1.55.0", + "@algolia/monitoring": "1.55.0", + "@algolia/recommend": "5.55.0", + "@algolia/requester-browser-xhr": "5.55.0", + "@algolia/requester-fetch": "5.55.0", + "@algolia/requester-node-http": "5.55.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/focus-trap": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", + "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tabbable": "^6.4.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mark.js": { + "version": "8.11.1", + "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", + "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minisearch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", + "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", + "dev": true, + "license": "MIT" + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oniguruma-to-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", + "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex-xs": "^1.0.0", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", + "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/search-insights": { + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", + "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/shiki": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", + "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/langs": "2.5.0", + "@shikijs/themes": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "dev": true, + "license": "MIT" + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitepress": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", + "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/css": "3.8.2", + "@docsearch/js": "3.8.2", + "@iconify-json/simple-icons": "^1.2.21", + "@shikijs/core": "^2.1.0", + "@shikijs/transformers": "^2.1.0", + "@shikijs/types": "^2.1.0", + "@types/markdown-it": "^14.1.2", + "@vitejs/plugin-vue": "^5.2.1", + "@vue/devtools-api": "^7.7.0", + "@vue/shared": "^3.5.13", + "@vueuse/core": "^12.4.0", + "@vueuse/integrations": "^12.4.0", + "focus-trap": "^7.6.4", + "mark.js": "8.11.1", + "minisearch": "^7.1.1", + "shiki": "^2.1.0", + "vite": "^5.4.14", + "vue": "^3.5.13" + }, + "bin": { + "vitepress": "bin/vitepress.js" + }, + "peerDependencies": { + "markdown-it-mathjax3": "^4", + "postcss": "^8" + }, + "peerDependenciesMeta": { + "markdown-it-mathjax3": { + "optional": true + }, + "postcss": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz", + "integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-sfc": "3.5.38", + "@vue/runtime-dom": "3.5.38", + "@vue/server-renderer": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..bd00392 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,13 @@ +{ + "name": "niphas-docs", + "private": true, + "type": "module", + "scripts": { + "dev": "vitepress dev", + "build": "vitepress build", + "preview": "vitepress preview" + }, + "devDependencies": { + "vitepress": "^1.6.3" + } +} diff --git a/docs/reference/crd-reference.md b/docs/reference/crd-reference.md new file mode 100644 index 0000000..1ff3a7f --- /dev/null +++ b/docs/reference/crd-reference.md @@ -0,0 +1,121 @@ +# CRD Reference + +Complete API reference for the `NiphasWorkload` custom resource. + +## Spec — Core Fields + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `flakeRef` | string | yes | — | Nix flake reference | +| `attribute` | string | yes | — | Flake output attribute | +| `revision` | string | no | latest | Git revision (6–40 hex chars) | +| `replicas` | integer | no | 1 | Number of pod replicas | +| `binaryCache` | string | no | ConfigMap | Binary cache URL | + +## Spec — Container Fields + +| Field | Type | Description | +|-------|------|-------------| +| `command` | string | Entrypoint (auto-detected from `meta.mainProgram` if omitted) | +| `args` | list of strings | Arguments passed to the entrypoint | +| `env` | list of EnvVar | Environment variables (standard K8s schema) | +| `ports` | list of ContainerPort | Container port definitions | + +## Spec — Resource Management + +| Field | Type | Description | +|-------|------|-------------| +| `resources` | ResourceRequirements | CPU/memory requests and limits | + +## Spec — Health Checks + +| Field | Type | Description | +|-------|------|-------------| +| `livenessProbe` | Probe | Liveness probe (standard K8s schema) | +| `readinessProbe` | Probe | Readiness probe | +| `startupProbe` | Probe | Startup probe | + +## Spec — Scheduling + +| Field | Type | Description | +|-------|------|-------------| +| `nodeSelector` | map | Merged with `niphas.io/store=true` | +| `tolerations` | list | Appended to failover tolerations | +| `affinity` | Affinity | Standard K8s affinity rules | + +## Spec — Service Exposure + +| Field | Type | Description | +|-------|------|-------------| +| `service` | ServiceSpec | Service type and ports | +| `ingress` | IngressSpec | `enabled`, `className`, `hosts`, `tls` | + +## Spec — Multi-Architecture + +| Field | Type | Description | +|-------|------|-------------| +| `architectures` | list of strings | Target Nix systems | + +When set, `attribute` must contain `{arch}` placeholder. One Deployment per architecture, single Service selects all. + +## Status + +| Field | Type | Description | +|-------|------|-------------| +| `observedGeneration` | integer | Last reconciled generation | +| `phase` | string | `Pending`, `Evaluating`, `Provisioning`, `Running`, `Failed` | +| `storePath` | string | Resolved Nix store path | +| `resolvedCommand` | string | Resolved entrypoint binary | +| `closurePaths` | list of strings | Full transitive closure | +| `lastEval` | timestamp | Last successful evaluation time | +| `readyReplicas` | integer | Number of ready replicas | +| `conditions` | list of Condition | Detailed status conditions | + +## Conditions + +| Type | True | False | +|------|------|-------| +| `Evaluated` | Eval succeeded | Eval failed or pending | +| `ClosureCached` | All paths available | Missing paths | +| `Available` | Minimum replicas ready | Insufficient replicas | +| `Progressing` | Rollout in progress | Stable | +| `Degraded` | Partial failure | Fully healthy | + +## Validation Rules + +- `flakeRef` must match allowed regex pattern +- `attribute` must not be empty +- `replicas` must be >= 0 +- `revision` must be 6–40 hex characters +- Port names must be unique +- `architectures` must be valid Nix system strings +- `{arch}` required in `attribute` when `architectures` is set + +## Defaults Injected by Operator + +- `nodeSelector` always includes `niphas.io/store=true` +- Tolerations injected for fast failover (30s vs default 300s) +- `topologySpreadConstraints` for `replicas >= 2` +- CSI volume for `/nix/store` +- Runner image as container image +- Command resolved from eval if not specified + +## Labels on Child Resources + +``` +niphas.io/workload: +niphas.io/managed-by: niphas-operator +niphas.io/store-path: +niphas.io/flake-ref: +app.kubernetes.io/name: +app.kubernetes.io/managed-by: niphas +``` + +## kubectl Output + +``` +NAME FLAKE PHASE READY +hello github:NixOS/nixpkgs Running 1/1 +``` + +Short names: `nw`, `niphas` diff --git a/docs/reference/index.md b/docs/reference/index.md new file mode 100644 index 0000000..288600c --- /dev/null +++ b/docs/reference/index.md @@ -0,0 +1,25 @@ +# Reference + +Technical reference documentation for niphas internals, APIs, and operational concerns. + +## API & Formats + +| Document | Description | +|----------|-------------| +| [CRD Reference](/reference/crd-reference) | Complete `NiphasWorkload` API spec — fields, status, conditions, validation | +| [Nix Wire Format](/reference/nix-wire) | NAR format, `.narinfo`, hashing, Ed25519 signatures, binary cache protocol | + +## Operations + +| Document | Description | +|----------|-------------| +| [Security Design](/reference/security-design) | Threat model, RBAC, pod security, network policies, eval sandboxing | +| [Resilience](/reference/resilience) | Failure handling, fallback chains, PDB, topology spread, priority classes | +| [Testing](/reference/testing) | Test strategy, sans-IO patterns, trait injection, tiers | + +## Other + +| Document | Description | +|----------|-------------| +| [Security Policy](/reference/security) | Vulnerability reporting and disclosure policy | +| [Known Issues](/reference/problem) | Tracked problems and planned fixes | diff --git a/docs/reference/nix-wire.md b/docs/reference/nix-wire.md new file mode 100644 index 0000000..5ddcee6 --- /dev/null +++ b/docs/reference/nix-wire.md @@ -0,0 +1,101 @@ +# Nix Wire Formats & Binary Cache Protocol + +How niphas-core implements NAR parsing, `.narinfo` handling, Nix hashing, signature verification, and binary cache interaction. + +## Module Layout + +All wire format code lives in `niphas-core`: + +| Module | Purpose | +|--------|---------| +| `nar.rs` | NAR streaming parser and extractor | +| `narinfo.rs` | `.narinfo` text parser | +| `hash.rs` | Nix-base32 encoding, SHA-256 | +| `signature.rs` | Ed25519 signature verification | +| `store_path.rs` | Store path validation | +| `cache_client.rs` | Binary cache HTTP client | +| `closure.rs` | Parallel closure resolution | + +## NAR Format + +NAR (Nix ARchive) is a deterministic serialization format for file system objects. + +**Wire primitives:** +- 64-bit unsigned little-endian integers +- Length-prefixed strings padded to 8-byte alignment + +**Node types:** `regular` (files), `symlink`, `directory` (with sorted entries) + +**Constraints:** +- Magic: `nix-archive-1` +- Directory entries must be strictly ascending (no duplicates) +- No `/` or `\0` in entry names +- Depth limit: 256 +- Zero padding, no trailing data + +**Extractor safety (CVE-2024-45593):** +- Atomic extraction to `.tmp-*` then rename +- Symlink validation via `O_NOFOLLOW` and `*at()` syscalls +- No symlink following during extraction + +## `.narinfo` Format + +Plain text `field=value` pairs: + +``` +StorePath: /nix/store/abc...-hello-2.12 +URL: nar/abc....nar.zst +Compression: zstd +FileHash: sha256:abc... +FileSize: 12345 +NarHash: sha256:def... +NarSize: 67890 +References: abc...-glibc-2.38 def...-hello-2.12 +Sig: cache.nixos.org-1:base64signature... +``` + +## Nix Hashing + +SHA-256 with a custom base32 encoding: + +- **Nix-base32 alphabet:** `0123456789abcdfghijklmnpqrsvwxyz` (omits `e`, `o`, `t`, `u`) +- Encoding is reversed (LSB first) +- Store path hash: 32-char Nix-base32 in `/nix/store/-` + +## Ed25519 Signature Verification + +Fingerprint format: + +``` +1;;;; +``` + +At least one signature must validate against trusted public keys. Unverified NARs are rejected. + +## Verification Chain + +Every NAR goes through a complete verification pipeline: + +1. Fetch `.narinfo` → verify Ed25519 signature +2. Download compressed NAR → verify `FileHash` +3. Decompress → verify `NarHash` (inline streaming) +4. Extract → atomic rename + +**Unverified NAR never reaches a pod.** + +## Binary Cache HTTP Client + +**Endpoints:** +- `GET /nix-cache-info` — cache metadata +- `GET /.narinfo` — store path info +- `GET /nar/.nar.zst` — compressed NAR data + +The client supports priority-ordered cache lists and streams large NARs without loading them entirely into memory. + +## Closure Resolution + +Pure HTTP, no Nix needed. Recursive BFS of `.narinfo` `References` fields with parallel resolution (configurable concurrency, default 32). + +## Decompression + +Supported formats: `zstd` (most common), `xz`, `bzip2`, `br` (brotli), `none`. Uses `async-compression` for streaming decompression. diff --git a/docs/reference/problem.md b/docs/reference/problem.md new file mode 100644 index 0000000..0e1af76 --- /dev/null +++ b/docs/reference/problem.md @@ -0,0 +1,89 @@ +# Known Issues + +Tracked problems and their planned fixes, ordered by priority. + +## Critical + +### P1 — Nix Expression Injection + +`flake_ref` and `attribute` are interpolated into Nix expressions without character validation. A crafted input could execute arbitrary Nix code. + +**Fix:** Regex validation + reject special characters before interpolation. + +### P2 — No Leader Election + +Running multiple operator replicas causes duplicate evaluations and SSA conflicts. + +**Fix:** Lease-based leader election via `kube-leader-election`, or single replica for MVP. + +## High + +### P3 — Revision Field Not Validated + +The `revision` field accepts any string. Should be constrained to 6–40 hex characters. + +### P4 — No Eval Concurrency Limit + +No limit on concurrent eval subprocesses — potential fork-bomb under load. + +**Fix:** Semaphore with configurable `max_concurrent_evals`. + +### P5 — NAR Buffered in Memory + +Large NARs are held in memory 3x during processing (1.5 GB for a 500 MB package like gcc). + +**Fix phase 1:** 2 GB decompressed size limit. **Phase 2:** Streaming `NarVisitor`. + +### P6 — Readiness Race Condition + +Ready flag set before the controller starts watching, causing a brief window where the probe reports ready prematurely. + +**Fix:** Set ready flag inside `for_each()` callback. + +## Medium + +### P7 — Silent Serialization Failures + +`unwrap_or_default()` on serialization errors produces null instead of an error. + +### P8 — Glob Matching ReDoS + +Current glob implementation has potential quadratic complexity. + +**Fix:** Use `glob-match` crate with O(n) worst-case. + +### P9 — Lock Map Grows Unbounded + +Download lock map entries are never cleaned up after completion. + +### P10 — Runner Image Hardcoded + +The runner image tag is hardcoded to `:latest`. + +**Fix:** Make configurable + per-workload override. + +### P11 — ownerReference with Empty UID + +If the workload UID is `None`, child resources get an empty ownerReference, breaking garbage collection. + +## Low + +### P12 — Unused Workspace Dependencies + +`libp2p`, `rkyv`, `smallvec`, and others are declared but not used. + +### P13 — niphas-mesh is a Stub + +The mesh crate has no implementation yet. + +### P14 — AppError Missing Traits + +`AppError` doesn't implement `Display` or `Error`. + +### P15 — Manual humantime_serde + +Duration serialization is hand-rolled instead of using `humantime_serde`. + +### P16 — No Integration Tests + +Zero integration tests — only unit tests for parsing and crypto. diff --git a/docs/reference/resilience.md b/docs/reference/resilience.md new file mode 100644 index 0000000..027a778 --- /dev/null +++ b/docs/reference/resilience.md @@ -0,0 +1,103 @@ +# Resilience & Failure Handling + +How niphas handles node failures, cache misses, component crashes, and maintains availability. + +## Node Failure Timeline + +``` +T+0s Node stops heartbeating +T+40s Taint: NotReady + unreachable:NoExecute +T+70s Pods evicted (Niphas tolerationSeconds=30s, not default 300s) +T+70s Pods rescheduled to healthy nodes +``` + +Niphas overrides the default 300s toleration to 30s for faster failover. + +## Fetch Fallback Chain + +When a pod is rescheduled, the CSI driver fetches NARs through a 4-source fallback: + +``` +1. Local cache ─ instant, always valid + │ miss +2. Mesh P2P ─ LAN transfer, 5s timeout (optional) + │ miss +3. Binary cache ─ WAN HTTPS, priority-ordered + │ miss +4. gRPC Unavailable ─ kubelet retries with backoff +``` + +## Per-Node Lazy Cache + +Each node only caches NARs for pods it actually runs — no proactive replication. + +**Benefits:** +- Minimal resource usage +- No bandwidth overhead +- Scales to any cluster size +- No coordination needed + +## PodDisruptionBudget + +Created automatically for workloads with `replicas >= 2`: + +- `maxUnavailable: 1` +- `unhealthyPodEvictionPolicy: AlwaysAllow` (K8s 1.31+) + +## Topology Spread + +- **Hard constraint:** no two replicas on the same node +- **Soft constraint:** prefer spreading across zones (don't block scheduling) + +## PriorityClass + +| Component | Priority | Class | +|-----------|----------|-------| +| CSI DaemonSet | system-cluster-critical | Must survive resource pressure | +| Operator, Mesh | 1,000,000 | `niphas-infrastructure` | +| Workloads | 100,000 | `niphas-workload` | + +## Leader Election + +Operator uses Lease-based leader election: + +- 15s TTL, 5s renewal +- New leader acquired within ~15s on failure +- Reconciliation is idempotent — brief dual-leader windows are safe + +## CSI Error Handling + +| gRPC Code | Meaning | Action | +|-----------|---------|--------| +| `Unavailable` | Transient error | kubelet retries | +| `Internal` | Mount failure | kubelet retries | +| `InvalidArgument` | User configuration error | Requires fix | +| `NotFound` | Store path doesn't exist | Requires fix | + +Never leaves a partial mount — operations are atomic. + +## Status Conditions + +Five condition types track the workload state: + +- **Evaluated** — eval succeeded or failed +- **ClosureCached** — all paths available in cache +- **Available** — minimum replicas ready +- **Progressing** — rollout in progress +- **Degraded** — partial failure + +All tracked via `observedGeneration` for eventual consistency. + +## Failure Scenario Matrix + +| Failure | Impact | Recovery | +|---------|--------|----------| +| Node dies | Pods rescheduled in ~70s | CSI re-fetches NARs | +| Binary cache down | New pods can't start | Mesh + local cache still work | +| Mesh + cache down | New pods can't start | Wait for binary cache | +| CSI pod crashes | DaemonSet restarts it | Cached data persists | +| Operator dies | No reconciliation | Leader re-elected in ~15s | +| Eval pod dies | Evals fail | Deployment restarts, PVC preserves cache | +| NAR corrupted | Re-hash detects | Evict and re-fetch | +| CRD deleted | Finalizer cleanup | ownerReferences cascade delete | +| All nodes die | Total outage | Restore from binary cache | diff --git a/docs/reference/security-design.md b/docs/reference/security-design.md new file mode 100644 index 0000000..3be5aea --- /dev/null +++ b/docs/reference/security-design.md @@ -0,0 +1,88 @@ +# Security Design + +Niphas security architecture: threat model, RBAC, pod security, network policies, and defense in depth. + +## Threat Model + +| Threat | Mitigation | +|--------|-----------| +| Compromised binary cache | Ed25519 signature verification on every NAR | +| Malicious flakeRef (arbitrary Nix code) | Flake allowlist + eval sandbox (4 layers) | +| Rogue mesh peer | Noise XX mutual auth + PeerId allowlist | +| Privilege escalation from CSI mount | Read-only bind mounts, `MS_NOSUID`, `MS_NODEV` | +| Lateral movement via RBAC | Minimal per-component RBAC, least privilege | + +## RBAC + +Each component has the minimum RBAC required: + +| Component | Scope | Access | +|-----------|-------|--------| +| niphas-operator | ClusterRole | NiphasWorkload CRUD, Deployments, PDBs, Pods, Events | +| niphas-eval | ClusterRole | NiphasWorkload read/update, Events | +| niphas-csi | **None** | Zero K8s API access | +| niphas-mesh | ClusterRole | Read-only NiphasWorkload, Pods, Nodes | + +## Pod Security + +**CSI DaemonSet** (only privileged component): +- `privileged=true` — required for Bidirectional mount propagation +- `runAsUser=0` +- `readOnlyRootFilesystem=true` + +**All other components:** +- `runAsNonRoot=true`, `runAsUser=65534` +- `allowPrivilegeEscalation=false` +- `readOnlyRootFilesystem=true` +- `capabilities: drop: [ALL]` +- `seccompProfile: RuntimeDefault` + +## Network Policies + +Default deny all. Explicit allow per component: + +| Component | Allowed Egress | +|-----------|---------------| +| operator | K8s API (443/6443), DNS | +| eval | Git HTTPS (443), binary cache HTTPS, DNS | +| csi | Binary cache HTTPS, mesh peer (4001), DNS | +| mesh | Mesh peers (4001 TCP+UDP), CSI socket, K8s API, DNS | + +## Eval Sandboxing (4 Layers) + +1. **Nix evaluator settings:** `sandbox=true`, `restrict_eval=true`, `allow_import_from_derivation=false`, `max_jobs=0` +2. **Flake allowlist:** deny-by-default glob matching before evaluator invoked +3. **Container isolation:** non-root, no capabilities, read-only rootfs, resource limits +4. **Network isolation:** NetworkPolicy allows only git/binary cache HTTPS and DNS + +`allow_import_from_derivation=false` is critical — it prevents evaluation from triggering builds. + +## Mount Isolation + +- Symlink validation: rejects symlinks pointing outside `/nix/store/` or relative escapes +- Mount flags: `MS_BIND | MS_RDONLY | MS_NOSUID | MS_NODEV` +- Target path validation: must be within `/var/lib/kubelet/pods/` + +## Mesh Authentication + +- libp2p Noise XX handshake (mutual authentication, perfect forward secrecy) +- Closed mesh: PeerId allowlist from K8s API watch +- Keys generated on first boot, stored in K8s Secret per node + +## Secrets Management + +| Secret | Storage | +|--------|---------| +| Binary cache private keys | External Secrets Operator (Vault, AWS SM) | +| Binary cache public keys | ConfigMap | +| TLS certificates | cert-manager | +| Mesh identity keys | K8s Secret per node | + +Kubernetes Secret encryption at rest is required. + +## Container Images + +- Scratch/distroless base (static musl binaries) +- Read-only root filesystem +- Non-root where possible +- No shell, no package manager diff --git a/docs/reference/security.md b/docs/reference/security.md new file mode 100644 index 0000000..71bf2a8 --- /dev/null +++ b/docs/reference/security.md @@ -0,0 +1,21 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report security vulnerabilities via email to **security@niphas.io**. + +- Expect a response within 48 hours +- Fix targeted within 30 days +- Coordinated vulnerability disclosure + +**Do not open public GitHub issues for security vulnerabilities.** + +## Supported Versions + +| Version | Supported | +|---------|-----------| +| 0.1.x | Yes | + +## Disclosure Policy + +We follow coordinated vulnerability disclosure. After a fix is released, the vulnerability details will be published with credit to the reporter. diff --git a/docs/reference/testing.md b/docs/reference/testing.md new file mode 100644 index 0000000..5d80695 --- /dev/null +++ b/docs/reference/testing.md @@ -0,0 +1,93 @@ +# Testing Strategy + +Testability patterns and test architecture for the niphas codebase. + +## Current State + +~4,500 lines of Rust, 32 tests covering only parsing and crypto. Zero tests for operator, CSI, and eval pipeline. Root cause: logic mixed with IO, making it untestable without real infrastructure. + +## Approach: Sans-IO + Trait Injection + +Separate pure logic from side effects. Extract traits for IO boundaries, use fakes (not mocks) in tests. + +### Three Core Traits + +```rust +// Binary cache interaction +trait BinaryCacheClient { + async fn fetch_narinfo(&self, hash: &str) -> Result; + async fn fetch_nar(&self, url: &str) -> Result; +} + +// CSI mount operations +trait MountOps { + fn bind_mount(&self, source: &Path, target: &Path) -> Result<()>; + fn unmount(&self, target: &Path) -> Result<()>; +} + +// Nix evaluation +trait NixEval { + async fn evaluate(&self, req: &EvalRequest) -> Result; +} +``` + +Fakes implement simplified logic — they work rather than just verify calls. More robust to refactoring. + +## Test Tiers + +### Tier 1 — Fast (every commit) + +- No network, no K8s, no root, no nix +- Fakes, builders, snapshots, property tests +- Target: less than 5s, ~100 tests +- Run: `cargo test --workspace` + +### Tier 2 — Integration (CI only) + +- Requires kind cluster, nix binary, root +- Real CSI mounts, actual eval calls +- Run: `cargo test --workspace -- --ignored` + +## Patterns + +### Test Fixtures — Builder Pattern + +```rust +WorkloadBuilder::new("test-app") + .flake_ref("github:test/app") + .attribute("packages.x86_64-linux.default") + .replicas(3) + .build() +``` + +Sensible defaults, chainable methods, feature-gated `testutils` module. + +### Snapshot Testing (insta) + +Capture complex outputs (JSON Kubernetes resources) as snapshots. First run creates the snapshot, subsequent runs compare. Use redactions for non-deterministic fields (UIDs, timestamps). + +### Property Testing (proptest) + +Generate valid inputs and test invariants: +- Roundtrip: `parse(display(x)) == x` +- No-panic: random inputs don't crash parsers +- Forbidden patterns: outputs never contain injection vectors + +### kube-rs Operator Testing + +Use `tower_test` mock client. Inject `mock::Handle` into `kube::Client`. Verify API calls in an async task. + +### Axum Handler Testing + +Use `tower::ServiceExt::oneshot` to call handlers directly without HTTP transport. + +## Expected Results + +| Metric | Before | After | +|--------|--------|-------| +| Tier 1 tests | 32 | ~100 | +| Crates with tests | 2/5 | 4/5 | +| Tier 1 runtime | less than 1s | less than 5s | +| Traits extracted | 0 | 3 | +| Snapshot tests | 0 | ~15 | +| Property tests | 0 | ~8 | diff --git a/hack/test-app/flake.nix b/hack/test-app/flake.nix deleted file mode 100644 index 99c5490..0000000 --- a/hack/test-app/flake.nix +++ /dev/null @@ -1,129 +0,0 @@ -{ - description = "Niphas test application — tiny HTTP server with status page"; - - inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; - - outputs = { nixpkgs, ... }: - let - system = "x86_64-linux"; - pkgs = nixpkgs.legacyPackages.${system}; - - staticSite = pkgs.writeTextDir "index.html" '' - - - - - - niphas - - - -
- -
nix-native workload platform for kubernetes
-
-
- status - running -
-
- source - nix flake -
-
- delivery - CSI volume mount -
-
- binary cache - cache.nixos.org -
-
- server - darkhttpd -
-
-
- served from /nix/store via niphas CSI driver -
-
- - - ''; - in - { - packages.${system}.default = pkgs.writeShellApplication { - name = "niphas-test-app"; - runtimeInputs = [ pkgs.darkhttpd ]; - text = '' - echo "niphas-test-app: serving on port ''${PORT:-8080}" - exec darkhttpd ${staticSite} --port "''${PORT:-8080}" --no-listing - ''; - }; - }; -} diff --git a/hack/test-e2e-real.sh b/hack/test-e2e-real.sh deleted file mode 100755 index 218e324..0000000 --- a/hack/test-e2e-real.sh +++ /dev/null @@ -1,176 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ═══════════════════════════════════════════════════════════ -# niphas — real E2E test -# validates: flake eval → closure → CSI mount → running pod -# -# usage: nix develop -c bash hack/test-e2e-real.sh -# ═══════════════════════════════════════════════════════════ - -CLUSTER="niphas-e2e" -NS="niphas-system" -APP_NS="niphas-e2e" -PORT=18080 - -# ── colors ────────────────────────────────────────────── -bold='\033[1m' -dim='\033[2m' -blue='\033[34m' -green='\033[32m' -red='\033[31m' -cyan='\033[36m' -reset='\033[0m' - -step() { echo -e "\n${bold}${blue}[$1/${TOTAL}]${reset} ${bold}$2${reset}"; } -ok() { echo -e " ${green}✓${reset} $*"; } -info() { echo -e " ${dim}$*${reset}"; } -fail() { echo -e " ${red}✗ $*${reset}"; dump_debug; exit 1; } -TOTAL=10 - -dump_debug() { - echo -e "\n${bold}${red}── debug ──${reset}" - echo -e "${dim}" - kubectl -n "$APP_NS" get niphasworkload test-app -o yaml 2>/dev/null | tail -30 || true - echo "---" - kubectl -n "$APP_NS" get pods -o wide 2>/dev/null || true - echo "---" - kubectl -n "$APP_NS" describe pods 2>/dev/null | tail -40 || true - echo "---" - kubectl -n "$NS" logs -l app.kubernetes.io/name=niphas-operator --tail=20 2>/dev/null || true - echo "---" - kubectl -n "$NS" logs -l app.kubernetes.io/name=niphas-eval --tail=20 2>/dev/null || true - echo "---" - kubectl -n "$NS" logs -l app.kubernetes.io/name=niphas-csi --all-containers --tail=20 2>/dev/null || true - echo -e "${reset}" -} - -cleanup() { kind delete cluster --name "$CLUSTER" 2>/dev/null || true; } -trap cleanup EXIT - -echo -e "${bold}${cyan}" -echo " ┌─────────────────────────────────────────┐" -echo " │ niphas · real E2E test │" -echo " │ flake eval → closure → CSI → service │" -echo " └─────────────────────────────────────────┘" -echo -e "${reset}" - -# ── 1. build ──────────────────────────────────────────── -step 1 "building OCI images" -nix build .#all-images -o result-images --no-link 2>&1 | tail -3 -nix build .#all-images -o result-images -ok "5 images built" - -# ── 2. cluster ────────────────────────────────────────── -step 2 "creating kind cluster" -kind delete cluster --name "$CLUSTER" 2>/dev/null || true -kind create cluster --name "$CLUSTER" --config hack/kind-config.yaml --wait 60s 2>&1 | tail -1 -ok "cluster ready (2 nodes)" - -# ── 3. images ─────────────────────────────────────────── -step 3 "loading images into kind" -for img in operator eval csi runner mock-eval; do - kind load image-archive "result-images/${img}.tar.gz" --name "$CLUSTER" 2>/dev/null - ok "$img" -done - -# ── 4. nodes ──────────────────────────────────────────── -step 4 "labeling nodes for CSI" -kubectl label nodes --all niphas.io/store=true --overwrite >/dev/null -ok "all nodes labeled niphas.io/store=true" - -# ── 5. deploy ─────────────────────────────────────────── -step 5 "installing niphas (real eval, not mock)" -helm install niphas charts/niphas \ - --namespace "$NS" \ - --create-namespace \ - --set operator.image.pullPolicy=Never \ - --set operator.image.tag=dev \ - --set eval.image.pullPolicy=Never \ - --set eval.image.tag=dev \ - --set csi.image.pullPolicy=Never \ - --set csi.image.tag=dev \ - --set runner.image.tag=dev \ - --wait --timeout 180s >/dev/null -ok "operator, eval, csi running" - -# ── 6. workload ───────────────────────────────────────── -step 6 "creating NiphasWorkload" -kubectl create namespace "$APP_NS" 2>/dev/null || true -kubectl apply -f hack/test-workload.yaml >/dev/null -info "flakeRef: github:fullzer4/niphas?dir=hack/test-app" -info "attribute: packages.x86_64-linux.default" -ok "workload created" - -# ── 7. wait for phases ────────────────────────────────── -step 7 "waiting for workload lifecycle" -LAST_PHASE="" -for i in $(seq 1 120); do - PHASE=$(kubectl -n "$APP_NS" get niphasworkload test-app \ - -o jsonpath='{.status.phase}' 2>/dev/null || echo "Pending") - - if [ "$PHASE" != "$LAST_PHASE" ]; then - [ -n "$LAST_PHASE" ] && echo "" - case "$PHASE" in - Pending) ok "phase: Pending" ;; - Evaluating) ok "phase: Evaluating (nix eval in progress...)" ;; - Provisioning) ok "phase: Provisioning (CSI fetching NARs...)" ;; - Running) ok "phase: Running"; break ;; - Failed) fail "phase: Failed" ;; - esac - LAST_PHASE="$PHASE" - else - printf "." - fi - sleep 5 -done -echo "" -[ "$PHASE" = "Running" ] || fail "stuck at phase: $PHASE (timeout after 10min)" - -# ── 8. pod check ──────────────────────────────────────── -step 8 "verifying pod" -POD=$(kubectl -n "$APP_NS" get pods -l niphas.io/workload=test-app \ - -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) -[ -n "$POD" ] || fail "no pod found" - -STORE_PATH=$(kubectl -n "$APP_NS" get niphasworkload test-app \ - -o jsonpath='{.status.storePath}' 2>/dev/null || echo "?") -CLOSURE_SIZE=$(kubectl -n "$APP_NS" get niphasworkload test-app \ - -o jsonpath='{.status.closurePaths}' 2>/dev/null | tr ',' '\n' | wc -l) - -ok "pod: $POD" -ok "store path: $STORE_PATH" -ok "closure size: $CLOSURE_SIZE paths" - -# ── 9. nix store mount ────────────────────────────────── -step 9 "checking /nix/store inside container" -MOUNT_COUNT=$(kubectl -n "$APP_NS" exec "$POD" -- ls /nix/store/ 2>/dev/null | wc -l) -ok "$MOUNT_COUNT store paths mounted via CSI" - -# ── 10. http test ─────────────────────────────────────── -step 10 "testing HTTP service" -kubectl -n "$APP_NS" port-forward "svc/test-app" "$PORT:80" &>/dev/null & -PF_PID=$! -sleep 3 - -HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/" 2>/dev/null || echo "000") -BODY=$(curl -s "http://localhost:$PORT/" 2>/dev/null | head -1 || true) -kill "$PF_PID" 2>/dev/null || true - -[ "$HTTP_CODE" = "200" ] || fail "HTTP $HTTP_CODE (expected 200)" -ok "HTTP 200 — page served from /nix/store" -info "open http://localhost:$PORT to see the page (port-forward stopped)" - -# ── done ──────────────────────────────────────────────── -echo "" -echo -e "${bold}${green}" -echo " ┌─────────────────────────────────────────┐" -echo " │ all checks passed │" -echo " │ │" -echo " │ flake eval ✓ │" -echo " │ closure resolve ✓ │" -echo " │ CSI NAR fetch ✓ │" -echo " │ pod mount ✓ │" -echo " │ HTTP service ✓ │" -echo " └─────────────────────────────────────────┘" -echo -e "${reset}" diff --git a/hack/test-workload.yaml b/hack/test-workload.yaml deleted file mode 100644 index cc94747..0000000 --- a/hack/test-workload.yaml +++ /dev/null @@ -1,32 +0,0 @@ -apiVersion: niphas.io/v1alpha1 -kind: NiphasWorkload -metadata: - name: test-app - namespace: niphas-e2e -spec: - flakeRef: github:fullzer4/niphas?dir=hack/test-app - attribute: packages.x86_64-linux.default - replicas: 1 - ports: - - name: http - containerPort: 8080 - protocol: TCP - service: - type: ClusterIP - ports: - - name: http - port: 80 - targetPort: "8080" - protocol: TCP - readinessProbe: - tcpSocket: - port: 8080 - initialDelaySeconds: 3 - periodSeconds: 3 - resources: - requests: - cpu: 50m - memory: 32Mi - limits: - cpu: 200m - memory: 64Mi diff --git a/nix/devShell.nix b/nix/devShell.nix index e10fd52..e1503d8 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -21,6 +21,7 @@ pkgs.kubectl pkgs.kubernetes-helm pkgs.kind + pkgs.nodejs ]; buildInputs = [ diff --git a/nix/docs.nix b/nix/docs.nix new file mode 100644 index 0000000..eace05e --- /dev/null +++ b/nix/docs.nix @@ -0,0 +1,55 @@ +{ self, ... }: +{ + perSystem = { pkgs, ... }: + let + docs-src = pkgs.stdenvNoCC.mkDerivation { + name = "niphas-docs-src"; + src = self; + phases = [ "unpackPhase" "installPhase" ]; + installPhase = '' + mkdir -p $out + # Copy docs directory (resolving symlinks) + cp -rL docs/. $out/ + ''; + }; + + docs-site = pkgs.buildNpmPackage { + pname = "niphas-docs"; + version = "0.1.0"; + src = docs-src; + npmDepsHash = "sha256-WGOob022dZ0sEgEtY3WJAeVs04LQ/FlFSpA/9B7MHwU="; + buildPhase = '' + npx vitepress build + ''; + installPhase = '' + mkdir -p $out + cp -r .vitepress/dist/* $out/ + ''; + }; + in + { + packages = { + niphas-docs = pkgs.writeShellApplication { + name = "niphas-docs"; + runtimeInputs = [ pkgs.darkhttpd ]; + text = '' + PORT="''${PORT:-8080}" + echo "niphas docs → http://localhost:$PORT" + exec darkhttpd ${docs-site} --port "$PORT" + ''; + }; + + niphas-docs-site = docs-site; + + image-niphas-docs = pkgs.dockerTools.buildLayeredImage { + name = "ghcr.io/fullzer4/niphas-docs"; + tag = "dev"; + contents = [ pkgs.darkhttpd docs-site pkgs.cacert ]; + config = { + Entrypoint = [ "${pkgs.darkhttpd}/bin/darkhttpd" "${docs-site}" "--port" "8080" ]; + ExposedPorts = { "8080/tcp" = {}; }; + }; + }; + }; + }; +} From 51bbf14d3e7920396698e5a12555bffdacb45f60 Mon Sep 17 00:00:00 2001 From: fullzer4 Date: Tue, 16 Jun 2026 18:52:22 -0300 Subject: [PATCH 22/24] feat: replace VitePress with mdBook for docs site VitePress hangs indefinitely after build due to orphaned esbuild processes (vuejs/vitepress#64, never fixed upstream). Switch to mdBook which builds instantly as a native Rust binary with zero sandbox issues. - Replace VitePress project with mdBook (book.toml + src/ structure) - Simplify nix/docs.nix to use pkgs.mdbook (no Node.js needed) - Replace nodejs with mdbook in devShell - Update .gitignore for mdBook output --- .gitignore | 4 +- docs/.vitepress/config.ts | 74 - docs/book.toml | 14 + docs/index.md | 23 - docs/package-lock.json | 2550 ----------------- docs/package.json | 13 - docs/src/SUMMARY.md | 29 + .../index.md => src/architecture/README.md} | 0 docs/{ => src}/architecture/deploy-flow.md | 0 docs/{ => src}/architecture/runtime-model.md | 0 .../index.md => src/components/README.md} | 0 docs/{ => src}/components/csi-driver.md | 0 docs/{ => src}/components/eval.md | 0 docs/{ => src}/components/mesh-protocol.md | 0 docs/{ => src}/components/operator.md | 0 .../index.md => src/reference/README.md} | 0 docs/{ => src}/reference/crd-reference.md | 0 docs/{ => src}/reference/nix-wire.md | 0 docs/{ => src}/reference/problem.md | 0 docs/{ => src}/reference/resilience.md | 0 docs/{ => src}/reference/security-design.md | 0 docs/{ => src}/reference/security.md | 0 docs/{ => src}/reference/testing.md | 0 nix/devShell.nix | 2 +- nix/docs.nix | 26 +- 25 files changed, 51 insertions(+), 2684 deletions(-) delete mode 100644 docs/.vitepress/config.ts create mode 100644 docs/book.toml delete mode 100644 docs/index.md delete mode 100644 docs/package-lock.json delete mode 100644 docs/package.json create mode 100644 docs/src/SUMMARY.md rename docs/{architecture/index.md => src/architecture/README.md} (100%) rename docs/{ => src}/architecture/deploy-flow.md (100%) rename docs/{ => src}/architecture/runtime-model.md (100%) rename docs/{components/index.md => src/components/README.md} (100%) rename docs/{ => src}/components/csi-driver.md (100%) rename docs/{ => src}/components/eval.md (100%) rename docs/{ => src}/components/mesh-protocol.md (100%) rename docs/{ => src}/components/operator.md (100%) rename docs/{reference/index.md => src/reference/README.md} (100%) rename docs/{ => src}/reference/crd-reference.md (100%) rename docs/{ => src}/reference/nix-wire.md (100%) rename docs/{ => src}/reference/problem.md (100%) rename docs/{ => src}/reference/resilience.md (100%) rename docs/{ => src}/reference/security-design.md (100%) rename docs/{ => src}/reference/security.md (100%) rename docs/{ => src}/reference/testing.md (100%) diff --git a/.gitignore b/.gitignore index 4d2082c..6fa57d0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,4 @@ result-* *.swp *.swo *~ -docs/.vitepress/dist/ -docs/.vitepress/cache/ -docs/node_modules/ +docs/book/ diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts deleted file mode 100644 index 973c704..0000000 --- a/docs/.vitepress/config.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { defineConfig } from 'vitepress' - -export default defineConfig({ - title: 'niphas', - description: 'Nix-native platform for Kubernetes', - appearance: 'dark', - - themeConfig: { - nav: [ - { text: 'Architecture', link: '/architecture/' }, - { text: 'Components', link: '/components/' }, - { text: 'Reference', link: '/reference/' }, - ], - - sidebar: { - '/architecture/': [ - { - text: 'Architecture', - items: [ - { text: 'Overview', link: '/architecture/' }, - { text: 'Runtime Model', link: '/architecture/runtime-model' }, - { text: 'Deploy Flow', link: '/architecture/deploy-flow' }, - ], - }, - ], - '/components/': [ - { - text: 'Components', - items: [ - { text: 'Overview', link: '/components/' }, - { text: 'Operator', link: '/components/operator' }, - { text: 'Evaluator', link: '/components/eval' }, - { text: 'CSI Driver', link: '/components/csi-driver' }, - { text: 'Mesh Protocol', link: '/components/mesh-protocol' }, - ], - }, - ], - '/reference/': [ - { - text: 'API & Formats', - items: [ - { text: 'Overview', link: '/reference/' }, - { text: 'CRD Reference', link: '/reference/crd-reference' }, - { text: 'Nix Wire Format', link: '/reference/nix-wire' }, - ], - }, - { - text: 'Operations', - items: [ - { text: 'Security Design', link: '/reference/security-design' }, - { text: 'Resilience', link: '/reference/resilience' }, - { text: 'Testing', link: '/reference/testing' }, - ], - }, - { - text: 'Other', - items: [ - { text: 'Security Policy', link: '/reference/security' }, - { text: 'Known Issues', link: '/reference/problem' }, - ], - }, - ], - }, - - socialLinks: [ - { icon: 'github', link: 'https://github.com/fullzer4/niphas' }, - ], - - footer: { - message: 'Released under the MIT License.', - copyright: 'Copyright 2024-present niphas contributors', - }, - }, -}) diff --git a/docs/book.toml b/docs/book.toml new file mode 100644 index 0000000..3a35962 --- /dev/null +++ b/docs/book.toml @@ -0,0 +1,14 @@ +[book] +title = "niphas" +description = "νιφάς — Nix-native platform for Kubernetes" +authors = ["fullzer4"] +language = "en" +src = "src" + +[build] +build-dir = "book" + +[output.html] +default-theme = "ayu" +preferred-dark-theme = "ayu" +git-repository-url = "https://github.com/fullzer4/niphas" diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 22fb785..0000000 --- a/docs/index.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -layout: home - -hero: - name: niphas - text: Nix-native platform for Kubernetes - tagline: "νιφάς — declarative workloads, evaluated and delivered by Nix" - actions: - - theme: brand - text: Get Started - link: /architecture/ - - theme: alt - text: GitHub - link: https://github.com/fullzer4/niphas - -features: - - title: Nix Evaluator - details: Evaluates flake references into derivations and streams build artifacts directly into the cluster. - - title: CSI Driver - details: Mounts Nix store paths as volumes, enabling transparent access to build outputs for workloads. - - title: Mesh Protocol - details: Coordinates operator, evaluator, and CSI driver through a resilient gRPC mesh. ---- diff --git a/docs/package-lock.json b/docs/package-lock.json deleted file mode 100644 index 9dfaa89..0000000 --- a/docs/package-lock.json +++ /dev/null @@ -1,2550 +0,0 @@ -{ - "name": "niphas-docs", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "niphas-docs", - "devDependencies": { - "vitepress": "^1.6.3" - } - }, - "node_modules/@algolia/abtesting": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.21.0.tgz", - "integrity": "sha512-kGvHfBa9oQCvZh0YXeguSToBD9GNJ+gzUZQ9KPTg+KSsM36obYcsKPoX0NnlJtPflHXu7RkMaIi44xs9meR6Zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.55.0", - "@algolia/requester-browser-xhr": "5.55.0", - "@algolia/requester-fetch": "5.55.0", - "@algolia/requester-node-http": "5.55.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/autocomplete-core": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", - "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", - "@algolia/autocomplete-shared": "1.17.7" - } - }, - "node_modules/@algolia/autocomplete-plugin-algolia-insights": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", - "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-shared": "1.17.7" - }, - "peerDependencies": { - "search-insights": ">= 1 < 3" - } - }, - "node_modules/@algolia/autocomplete-preset-algolia": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", - "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-shared": "1.17.7" - }, - "peerDependencies": { - "@algolia/client-search": ">= 4.9.1 < 6", - "algoliasearch": ">= 4.9.1 < 6" - } - }, - "node_modules/@algolia/autocomplete-shared": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", - "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@algolia/client-search": ">= 4.9.1 < 6", - "algoliasearch": ">= 4.9.1 < 6" - } - }, - "node_modules/@algolia/client-abtesting": { - "version": "5.55.0", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.55.0.tgz", - "integrity": "sha512-Zt2GjIm7vsaf7K23tk5JmtcVNc38G9p0C2L2Lrm06miyLE/NL2etHtHInvuLc1DjxTp7Y2nId4X/tzwo372K8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.55.0", - "@algolia/requester-browser-xhr": "5.55.0", - "@algolia/requester-fetch": "5.55.0", - "@algolia/requester-node-http": "5.55.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-analytics": { - "version": "5.55.0", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.55.0.tgz", - "integrity": "sha512-7BueMuWYg/KBA2EX9zsQ+3OAleEyrJcB+SV5Al/9pLjMQq5mXB/8M5HaUPqZwN812g5kLzj9j43VThlZgWq0hg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.55.0", - "@algolia/requester-browser-xhr": "5.55.0", - "@algolia/requester-fetch": "5.55.0", - "@algolia/requester-node-http": "5.55.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-common": { - "version": "5.55.0", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.55.0.tgz", - "integrity": "sha512-pJZIyhvUrs+B7c5Lw0iP5yP/NsqJMda7pKRYbfG4KtfGIVSMcAalZhdqL5UX8Z9DOC4KxO9tKV5RDeVjZU0VfQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-insights": { - "version": "5.55.0", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.55.0.tgz", - "integrity": "sha512-RydkKDhx0GWTYuw0ndTXHGM8hD8hgwftKE65FfnJZb5bPc9CevOqv3qNPUQiviAwkqT9hQNH31uDGeV3yZkgfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.55.0", - "@algolia/requester-browser-xhr": "5.55.0", - "@algolia/requester-fetch": "5.55.0", - "@algolia/requester-node-http": "5.55.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-personalization": { - "version": "5.55.0", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.55.0.tgz", - "integrity": "sha512-XiS7gdFq/COWiwdWXZ8+RHuewfEo03TkGESk44zU8zTc/Z6R8fm4DNmV52swJKkeB2N9iC7NKpgpM22OOkcgTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.55.0", - "@algolia/requester-browser-xhr": "5.55.0", - "@algolia/requester-fetch": "5.55.0", - "@algolia/requester-node-http": "5.55.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-query-suggestions": { - "version": "5.55.0", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.55.0.tgz", - "integrity": "sha512-LBEJ/q+hn1nJ0aYg5IcWgLNCPjWHTahWmpHNx1qUZMho+9CyWM6LaEnhac45UHjQm/j0m374HP685VrpL133lA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.55.0", - "@algolia/requester-browser-xhr": "5.55.0", - "@algolia/requester-fetch": "5.55.0", - "@algolia/requester-node-http": "5.55.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-search": { - "version": "5.55.0", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.55.0.tgz", - "integrity": "sha512-2/9jUXKH4IcdU5qxH6cbDH46ZBe46G7xr+MrcHwgEXZcUfdAvUgLSH53MAWuMgxvw0G5yoqiWMifHc62Os0fiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.55.0", - "@algolia/requester-browser-xhr": "5.55.0", - "@algolia/requester-fetch": "5.55.0", - "@algolia/requester-node-http": "5.55.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/ingestion": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.55.0.tgz", - "integrity": "sha512-80tKsQgxXWo+jK0v4YGCHqyTEXawhAKYyr3kOdN51ElfRqUFjZNPVhZk6vRiqSqXfvrH85ytacT3cbJR6+qolA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.55.0", - "@algolia/requester-browser-xhr": "5.55.0", - "@algolia/requester-fetch": "5.55.0", - "@algolia/requester-node-http": "5.55.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/monitoring": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.55.0.tgz", - "integrity": "sha512-4UjmAL8ywGW4rCfK6Qmgw3wIjbrO2wl2s4Eq56JTiN40L2t0XTv0HZkYAmr6nfeiXO0he/2crvZRX6SATSepag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.55.0", - "@algolia/requester-browser-xhr": "5.55.0", - "@algolia/requester-fetch": "5.55.0", - "@algolia/requester-node-http": "5.55.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/recommend": { - "version": "5.55.0", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.55.0.tgz", - "integrity": "sha512-LMpJPtIkfDsHIx5Ga+baNr22ntYbY+e2wT7MSIc/FjAnu9wnBFhx1H/GfhmP/c5/IvbThDX+3ilxPRjSfCI8aA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.55.0", - "@algolia/requester-browser-xhr": "5.55.0", - "@algolia/requester-fetch": "5.55.0", - "@algolia/requester-node-http": "5.55.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-browser-xhr": { - "version": "5.55.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.55.0.tgz", - "integrity": "sha512-tDymJ7nFOAoUuecma3usK6o94dp8m4HYFDGh4ByYQXWkv14cpmDn+nWdylmcZO0Qvco107vqDo4+Anksnl8w1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.55.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-fetch": { - "version": "5.55.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.55.0.tgz", - "integrity": "sha512-6IDSB5o5dkDPQ4LdOW0Yuw/qy5MdWlO2xDHgPVZgW4YDjbxvnX5PAiV7/WWZdWyVObScZZnnHpPbiqfYs/zBLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.55.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-node-http": { - "version": "5.55.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.55.0.tgz", - "integrity": "sha512-Yyyne4l//vDSdg4MhYJkaVne+KEPi833eCj3/T/87ernTwrvP6j9biXXZELsN8sLI/f2ndV/vugDIy2jdJQB6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.55.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@docsearch/css": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", - "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@docsearch/js": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", - "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@docsearch/react": "3.8.2", - "preact": "^10.0.0" - } - }, - "node_modules/@docsearch/react": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", - "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-core": "1.17.7", - "@algolia/autocomplete-preset-algolia": "1.17.7", - "@docsearch/css": "3.8.2", - "algoliasearch": "^5.14.2" - }, - "peerDependencies": { - "@types/react": ">= 16.8.0 < 19.0.0", - "react": ">= 16.8.0 < 19.0.0", - "react-dom": ">= 16.8.0 < 19.0.0", - "search-insights": ">= 1 < 3" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "search-insights": { - "optional": true - } - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@iconify-json/simple-icons": { - "version": "1.2.86", - "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.86.tgz", - "integrity": "sha512-t3jck5qPQuK1qy+bRn9eCoDQhIB7XSazKz1Fjp8hcan3XOAsTI5Mq/s3F0ekOKSvMQqkVORYK6ns6o6T9f5EMA==", - "dev": true, - "license": "CC0-1.0", - "dependencies": { - "@iconify/types": "*" - } - }, - "node_modules/@iconify/types": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", - "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", - "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", - "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", - "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", - "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", - "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", - "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", - "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", - "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", - "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", - "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", - "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", - "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", - "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", - "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", - "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", - "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", - "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", - "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", - "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", - "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", - "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", - "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", - "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", - "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", - "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@shikijs/core": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", - "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/engine-javascript": "2.5.0", - "@shikijs/engine-oniguruma": "2.5.0", - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4", - "hast-util-to-html": "^9.0.4" - } - }, - "node_modules/@shikijs/engine-javascript": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", - "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^3.1.0" - } - }, - "node_modules/@shikijs/engine-oniguruma": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", - "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2" - } - }, - "node_modules/@shikijs/langs": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", - "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "2.5.0" - } - }, - "node_modules/@shikijs/themes": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", - "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "2.5.0" - } - }, - "node_modules/@shikijs/transformers": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", - "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/core": "2.5.0", - "@shikijs/types": "2.5.0" - } - }, - "node_modules/@shikijs/types": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", - "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, - "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/markdown-it": { - "version": "14.1.2", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", - "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/linkify-it": "^5", - "@types/mdurl": "^2" - } - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/web-bluetooth": { - "version": "0.0.21", - "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", - "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", - "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@vitejs/plugin-vue": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0", - "vue": "^3.2.25" - } - }, - "node_modules/@vue/compiler-core": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.38.tgz", - "integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@vue/shared": "3.5.38", - "entities": "^7.0.1", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz", - "integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-core": "3.5.38", - "@vue/shared": "3.5.38" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz", - "integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@vue/compiler-core": "3.5.38", - "@vue/compiler-dom": "3.5.38", - "@vue/compiler-ssr": "3.5.38", - "@vue/shared": "3.5.38", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.21", - "postcss": "^8.5.15", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz", - "integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.38", - "@vue/shared": "3.5.38" - } - }, - "node_modules/@vue/devtools-api": { - "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", - "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/devtools-kit": "^7.7.9" - } - }, - "node_modules/@vue/devtools-kit": { - "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", - "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/devtools-shared": "^7.7.9", - "birpc": "^2.3.0", - "hookable": "^5.5.3", - "mitt": "^3.0.1", - "perfect-debounce": "^1.0.0", - "speakingurl": "^14.0.1", - "superjson": "^2.2.2" - } - }, - "node_modules/@vue/devtools-shared": { - "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", - "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "rfdc": "^1.4.1" - } - }, - "node_modules/@vue/reactivity": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz", - "integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/shared": "3.5.38" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.38.tgz", - "integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.38", - "@vue/shared": "3.5.38" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz", - "integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.38", - "@vue/runtime-core": "3.5.38", - "@vue/shared": "3.5.38", - "csstype": "^3.2.3" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz", - "integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-ssr": "3.5.38", - "@vue/shared": "3.5.38" - }, - "peerDependencies": { - "vue": "3.5.38" - } - }, - "node_modules/@vue/shared": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.38.tgz", - "integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vueuse/core": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", - "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/web-bluetooth": "^0.0.21", - "@vueuse/metadata": "12.8.2", - "@vueuse/shared": "12.8.2", - "vue": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@vueuse/integrations": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", - "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vueuse/core": "12.8.2", - "@vueuse/shared": "12.8.2", - "vue": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "async-validator": "^4", - "axios": "^1", - "change-case": "^5", - "drauu": "^0.4", - "focus-trap": "^7", - "fuse.js": "^7", - "idb-keyval": "^6", - "jwt-decode": "^4", - "nprogress": "^0.2", - "qrcode": "^1.5", - "sortablejs": "^1", - "universal-cookie": "^7" - }, - "peerDependenciesMeta": { - "async-validator": { - "optional": true - }, - "axios": { - "optional": true - }, - "change-case": { - "optional": true - }, - "drauu": { - "optional": true - }, - "focus-trap": { - "optional": true - }, - "fuse.js": { - "optional": true - }, - "idb-keyval": { - "optional": true - }, - "jwt-decode": { - "optional": true - }, - "nprogress": { - "optional": true - }, - "qrcode": { - "optional": true - }, - "sortablejs": { - "optional": true - }, - "universal-cookie": { - "optional": true - } - } - }, - "node_modules/@vueuse/metadata": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", - "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@vueuse/shared": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", - "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "vue": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/algoliasearch": { - "version": "5.55.0", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.55.0.tgz", - "integrity": "sha512-af+rI+tUVeS9KWHPAZQHIHPOIC3StPRR6IwQu2nz1aQoTL6Gs5Ty3KsHCgbXMHOpoh9QqSjq8F3KJ8xmaCZSBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/abtesting": "1.21.0", - "@algolia/client-abtesting": "5.55.0", - "@algolia/client-analytics": "5.55.0", - "@algolia/client-common": "5.55.0", - "@algolia/client-insights": "5.55.0", - "@algolia/client-personalization": "5.55.0", - "@algolia/client-query-suggestions": "5.55.0", - "@algolia/client-search": "5.55.0", - "@algolia/ingestion": "1.55.0", - "@algolia/monitoring": "1.55.0", - "@algolia/recommend": "5.55.0", - "@algolia/requester-browser-xhr": "5.55.0", - "@algolia/requester-fetch": "5.55.0", - "@algolia/requester-node-http": "5.55.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/birpc": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", - "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/copy-anything": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", - "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-what": "^5.2.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/emoji-regex-xs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", - "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", - "dev": true, - "license": "MIT" - }, - "node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/focus-trap": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", - "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tabbable": "^6.4.0" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/hast-util-to-html": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", - "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-whitespace": "^3.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "stringify-entities": "^4.0.0", - "zwitch": "^2.0.4" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hookable": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", - "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/html-void-elements": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", - "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-what": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", - "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mark.js": { - "version": "8.11.1", - "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", - "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/minisearch": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", - "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", - "dev": true, - "license": "MIT" - }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/oniguruma-to-es": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", - "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex-xs": "^1.0.0", - "regex": "^6.0.1", - "regex-recursion": "^6.0.2" - } - }, - "node_modules/perfect-debounce": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/preact": { - "version": "10.29.2", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", - "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, - "node_modules/property-information": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", - "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", - "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-recursion": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", - "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-utilities": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", - "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", - "dev": true, - "license": "MIT" - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, - "node_modules/rollup": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", - "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.0", - "@rollup/rollup-android-arm64": "4.62.0", - "@rollup/rollup-darwin-arm64": "4.62.0", - "@rollup/rollup-darwin-x64": "4.62.0", - "@rollup/rollup-freebsd-arm64": "4.62.0", - "@rollup/rollup-freebsd-x64": "4.62.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", - "@rollup/rollup-linux-arm-musleabihf": "4.62.0", - "@rollup/rollup-linux-arm64-gnu": "4.62.0", - "@rollup/rollup-linux-arm64-musl": "4.62.0", - "@rollup/rollup-linux-loong64-gnu": "4.62.0", - "@rollup/rollup-linux-loong64-musl": "4.62.0", - "@rollup/rollup-linux-ppc64-gnu": "4.62.0", - "@rollup/rollup-linux-ppc64-musl": "4.62.0", - "@rollup/rollup-linux-riscv64-gnu": "4.62.0", - "@rollup/rollup-linux-riscv64-musl": "4.62.0", - "@rollup/rollup-linux-s390x-gnu": "4.62.0", - "@rollup/rollup-linux-x64-gnu": "4.62.0", - "@rollup/rollup-linux-x64-musl": "4.62.0", - "@rollup/rollup-openbsd-x64": "4.62.0", - "@rollup/rollup-openharmony-arm64": "4.62.0", - "@rollup/rollup-win32-arm64-msvc": "4.62.0", - "@rollup/rollup-win32-ia32-msvc": "4.62.0", - "@rollup/rollup-win32-x64-gnu": "4.62.0", - "@rollup/rollup-win32-x64-msvc": "4.62.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/search-insights": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", - "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/shiki": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", - "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/core": "2.5.0", - "@shikijs/engine-javascript": "2.5.0", - "@shikijs/engine-oniguruma": "2.5.0", - "@shikijs/langs": "2.5.0", - "@shikijs/themes": "2.5.0", - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/speakingurl": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", - "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "dev": true, - "license": "MIT", - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/superjson": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", - "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "copy-anything": "^4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tabbable": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", - "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", - "dev": true, - "license": "MIT" - }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vitepress": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", - "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@docsearch/css": "3.8.2", - "@docsearch/js": "3.8.2", - "@iconify-json/simple-icons": "^1.2.21", - "@shikijs/core": "^2.1.0", - "@shikijs/transformers": "^2.1.0", - "@shikijs/types": "^2.1.0", - "@types/markdown-it": "^14.1.2", - "@vitejs/plugin-vue": "^5.2.1", - "@vue/devtools-api": "^7.7.0", - "@vue/shared": "^3.5.13", - "@vueuse/core": "^12.4.0", - "@vueuse/integrations": "^12.4.0", - "focus-trap": "^7.6.4", - "mark.js": "8.11.1", - "minisearch": "^7.1.1", - "shiki": "^2.1.0", - "vite": "^5.4.14", - "vue": "^3.5.13" - }, - "bin": { - "vitepress": "bin/vitepress.js" - }, - "peerDependencies": { - "markdown-it-mathjax3": "^4", - "postcss": "^8" - }, - "peerDependenciesMeta": { - "markdown-it-mathjax3": { - "optional": true - }, - "postcss": { - "optional": true - } - } - }, - "node_modules/vue": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz", - "integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.38", - "@vue/compiler-sfc": "3.5.38", - "@vue/runtime-dom": "3.5.38", - "@vue/server-renderer": "3.5.38", - "@vue/shared": "3.5.38" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - } -} diff --git a/docs/package.json b/docs/package.json deleted file mode 100644 index bd00392..0000000 --- a/docs/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "niphas-docs", - "private": true, - "type": "module", - "scripts": { - "dev": "vitepress dev", - "build": "vitepress build", - "preview": "vitepress preview" - }, - "devDependencies": { - "vitepress": "^1.6.3" - } -} diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md new file mode 100644 index 0000000..0f87737 --- /dev/null +++ b/docs/src/SUMMARY.md @@ -0,0 +1,29 @@ +# Summary + +# Architecture + +- [Overview](./architecture/README.md) +- [Runtime Model](./architecture/runtime-model.md) +- [Deploy Flow](./architecture/deploy-flow.md) + +# Components + +- [Overview](./components/README.md) +- [Operator](./components/operator.md) +- [Evaluator](./components/eval.md) +- [CSI Driver](./components/csi-driver.md) +- [Mesh Protocol](./components/mesh-protocol.md) + +# Reference + +- [Overview](./reference/README.md) +- [CRD Reference](./reference/crd-reference.md) +- [Nix Wire Format](./reference/nix-wire.md) +- [Security Design](./reference/security-design.md) +- [Resilience](./reference/resilience.md) +- [Testing](./reference/testing.md) + +--- + +- [Security Policy](./reference/security.md) +- [Known Issues](./reference/problem.md) diff --git a/docs/architecture/index.md b/docs/src/architecture/README.md similarity index 100% rename from docs/architecture/index.md rename to docs/src/architecture/README.md diff --git a/docs/architecture/deploy-flow.md b/docs/src/architecture/deploy-flow.md similarity index 100% rename from docs/architecture/deploy-flow.md rename to docs/src/architecture/deploy-flow.md diff --git a/docs/architecture/runtime-model.md b/docs/src/architecture/runtime-model.md similarity index 100% rename from docs/architecture/runtime-model.md rename to docs/src/architecture/runtime-model.md diff --git a/docs/components/index.md b/docs/src/components/README.md similarity index 100% rename from docs/components/index.md rename to docs/src/components/README.md diff --git a/docs/components/csi-driver.md b/docs/src/components/csi-driver.md similarity index 100% rename from docs/components/csi-driver.md rename to docs/src/components/csi-driver.md diff --git a/docs/components/eval.md b/docs/src/components/eval.md similarity index 100% rename from docs/components/eval.md rename to docs/src/components/eval.md diff --git a/docs/components/mesh-protocol.md b/docs/src/components/mesh-protocol.md similarity index 100% rename from docs/components/mesh-protocol.md rename to docs/src/components/mesh-protocol.md diff --git a/docs/components/operator.md b/docs/src/components/operator.md similarity index 100% rename from docs/components/operator.md rename to docs/src/components/operator.md diff --git a/docs/reference/index.md b/docs/src/reference/README.md similarity index 100% rename from docs/reference/index.md rename to docs/src/reference/README.md diff --git a/docs/reference/crd-reference.md b/docs/src/reference/crd-reference.md similarity index 100% rename from docs/reference/crd-reference.md rename to docs/src/reference/crd-reference.md diff --git a/docs/reference/nix-wire.md b/docs/src/reference/nix-wire.md similarity index 100% rename from docs/reference/nix-wire.md rename to docs/src/reference/nix-wire.md diff --git a/docs/reference/problem.md b/docs/src/reference/problem.md similarity index 100% rename from docs/reference/problem.md rename to docs/src/reference/problem.md diff --git a/docs/reference/resilience.md b/docs/src/reference/resilience.md similarity index 100% rename from docs/reference/resilience.md rename to docs/src/reference/resilience.md diff --git a/docs/reference/security-design.md b/docs/src/reference/security-design.md similarity index 100% rename from docs/reference/security-design.md rename to docs/src/reference/security-design.md diff --git a/docs/reference/security.md b/docs/src/reference/security.md similarity index 100% rename from docs/reference/security.md rename to docs/src/reference/security.md diff --git a/docs/reference/testing.md b/docs/src/reference/testing.md similarity index 100% rename from docs/reference/testing.md rename to docs/src/reference/testing.md diff --git a/nix/devShell.nix b/nix/devShell.nix index e1503d8..66cefc6 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -21,7 +21,7 @@ pkgs.kubectl pkgs.kubernetes-helm pkgs.kind - pkgs.nodejs + pkgs.mdbook ]; buildInputs = [ diff --git a/nix/docs.nix b/nix/docs.nix index eace05e..68dd3e8 100644 --- a/nix/docs.nix +++ b/nix/docs.nix @@ -2,28 +2,14 @@ { perSystem = { pkgs, ... }: let - docs-src = pkgs.stdenvNoCC.mkDerivation { - name = "niphas-docs-src"; + docs-site = pkgs.stdenvNoCC.mkDerivation { + name = "niphas-docs"; src = self; - phases = [ "unpackPhase" "installPhase" ]; + nativeBuildInputs = [ pkgs.mdbook ]; + buildPhase = "mdbook build docs"; installPhase = '' mkdir -p $out - # Copy docs directory (resolving symlinks) - cp -rL docs/. $out/ - ''; - }; - - docs-site = pkgs.buildNpmPackage { - pname = "niphas-docs"; - version = "0.1.0"; - src = docs-src; - npmDepsHash = "sha256-WGOob022dZ0sEgEtY3WJAeVs04LQ/FlFSpA/9B7MHwU="; - buildPhase = '' - npx vitepress build - ''; - installPhase = '' - mkdir -p $out - cp -r .vitepress/dist/* $out/ + cp -r docs/book/* $out/ ''; }; in @@ -34,7 +20,7 @@ runtimeInputs = [ pkgs.darkhttpd ]; text = '' PORT="''${PORT:-8080}" - echo "niphas docs → http://localhost:$PORT" + echo "niphas docs: http://localhost:$PORT" exec darkhttpd ${docs-site} --port "$PORT" ''; }; From 0d8cb10acdca669b5b1b37963fba134dd5d375e6 Mon Sep 17 00:00:00 2001 From: fullzer4 Date: Tue, 16 Jun 2026 19:38:59 -0300 Subject: [PATCH 23/24] chore: remove root documentation files All documentation now lives in docs/src/ and is served via mdBook. README.md stays in root as the GitHub landing page. --- ARCHITECTURE.md | 447 ------------------------ CRD_REFERENCE.md | 302 ---------------- CSI_DRIVER.md | 344 ------------------ DEPLOY_FLOW.md | 565 ------------------------------ EVAL.md | 544 ----------------------------- MESH_PROTOCOL.md | 688 ------------------------------------ NIX_WIRE.md | 846 --------------------------------------------- OPERATOR.md | 524 ---------------------------- PROBLEM.md | 473 ------------------------- RESILIENCE.md | 372 -------------------- RUNTIME_MODEL.md | 384 -------------------- SECURITY.md | 20 -- SECURITY_DESIGN.md | 539 ----------------------------- TESTING.md | 723 -------------------------------------- 14 files changed, 6771 deletions(-) delete mode 100644 ARCHITECTURE.md delete mode 100644 CRD_REFERENCE.md delete mode 100644 CSI_DRIVER.md delete mode 100644 DEPLOY_FLOW.md delete mode 100644 EVAL.md delete mode 100644 MESH_PROTOCOL.md delete mode 100644 NIX_WIRE.md delete mode 100644 OPERATOR.md delete mode 100644 PROBLEM.md delete mode 100644 RESILIENCE.md delete mode 100644 RUNTIME_MODEL.md delete mode 100644 SECURITY.md delete mode 100644 SECURITY_DESIGN.md delete mode 100644 TESTING.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index abb4b46..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,447 +0,0 @@ -# Niphas – Architecture & Project Structure - -## Directory Layout - -``` -niphas/ -├── Cargo.toml # Workspace root -├── Cargo.lock # Shared lockfile -├── flake.nix # Nix flake (crane-based) -├── flake.lock -├── rust-toolchain.toml # Pin Rust version -├── .cargo/ -│ └── config.toml # Cargo build settings -├── deny.toml # cargo-deny (licenses, advisories) -├── proto/ -│ └── csi/ -│ └── v1/ -│ └── csi.proto # CSI spec protobuf (vendored) -├── crates/ -│ ├── niphas-core/ # Shared library -│ │ └── src/ -│ │ ├── lib.rs -│ │ ├── crd.rs # NiphasWorkload CRD (kube-derive) -│ │ ├── nix/ -│ │ │ ├── mod.rs -│ │ │ ├── nar.rs # NAR archive parser + extractor -│ │ │ ├── narinfo.rs # .narinfo text parser -│ │ │ ├── hash.rs # Nix hashing (SHA-256, Nix-base32) -│ │ │ ├── signature.rs # Ed25519 signature verification -│ │ │ ├── store_path.rs # Store path parsing + validation -│ │ │ ├── cache_client.rs # Binary cache HTTP client -│ │ │ └── closure.rs # Recursive closure resolution -│ │ ├── config.rs # Shared config loading -│ │ ├── eval.rs # Eval request/response types + input validation -│ │ ├── error.rs # Error types (thiserror) -│ │ ├── telemetry.rs # Tracing init with opt-in OTEL (TelemetryGuard) -│ │ └── testutils.rs # Test fixtures (feature-gated) -│ ├── niphas-eval/ # Webhook binary (Nix eval via subprocess) -│ │ └── src/ -│ │ ├── main.rs -│ │ ├── handlers.rs # Axum route handlers -│ │ ├── evaluator.rs # Nix eval subprocess + closure resolution -│ │ ├── allowlist.rs # Flake origin validation -│ │ └── error.rs # Eval-specific error types -│ ├── niphas-operator/ # Operator binary -│ │ └── src/ -│ │ ├── main.rs -│ │ ├── reconciler.rs # Reconciler for NiphasWorkload -│ │ ├── resources.rs # K8s resource construction (Deployment, Service, etc.) -│ │ ├── context.rs # Shared operator context (kube client, config) -│ │ ├── eval.rs # Eval webhook client -│ │ ├── health.rs # Health/readiness probes (Axum) -│ │ └── error.rs # Operator-specific error types -│ ├── niphas-csi/ # CSI driver binary -│ │ ├── build.rs # tonic-build for CSI proto -│ │ └── src/ -│ │ ├── main.rs -│ │ ├── identity.rs # CSI Identity service -│ │ ├── node.rs # CSI Node service (mount/unmount) -│ │ ├── mount.rs # Nix closure mount logic -│ │ └── cache.rs # NAR cache management -│ └── niphas-mesh/ # P2P binary (early stage) -│ └── src/ -│ └── main.rs -└── manifests/ # K8s manifests - ├── crd.yaml # Generated from niphas-core - ├── operator.yaml - ├── csi-driver.yaml - └── eval-webhook.yaml -``` - -## Workspace Cargo.toml - -```toml -[workspace] -resolver = "2" -members = [ - "crates/niphas-core", - "crates/niphas-eval", - "crates/niphas-operator", - "crates/niphas-csi", - "crates/niphas-mesh", -] - -[workspace.package] -version = "0.1.0" -edition = "2024" -license = "Apache-2.0" -repository = "https://github.com/fullzer4/niphas" -rust-version = "1.85" - -[workspace.dependencies] -# Kubernetes -kube = { version = "3.1", features = ["runtime", "derive", "client"] } -k8s-openapi = { version = "0.27", features = ["latest", "schemars"] } -kube-runtime = "3.1" -kube-leader-election = "0.43" - -# Async -tokio = { version = "1.52", features = ["rt-multi-thread", "net", "time", "signal", "macros", "process"] } - -# Web -axum = "0.8" -tower = "0.5" -tower-http = { version = "0.6", features = ["trace"] } - -# gRPC (CSI) -tonic = "0.14" -tonic-build = "0.14" -tonic-prost-build = "0.14" -tonic-prost = "0.14" -prost = "0.14" -prost-types = "0.14" - -# P2P -libp2p = { version = "0.56", features = [ - "tcp", "quic", "noise", "yamux", - "dns", "identify", "gossipsub", "request-response", - "tokio", "macros", -] } - -# Serialization -serde = { version = "1", features = ["derive"] } -serde_json = "1" -serde_yaml = "0.9" - -# Zero-copy (P2P protocol) -rkyv = "0.8" - -# Cryptography / hashing -sha2 = "0.10" -ed25519-dalek = { version = "2", features = ["pkcs8"] } -base64 = "0.22" - -# HTTP client -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "gzip"] } - -# Error handling -thiserror = "2" -anyhow = "1" - -# Observability -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "registry"] } -tracing-opentelemetry = "0.30" -opentelemetry = "0.29" -opentelemetry_sdk = { version = "0.29", features = ["rt-tokio"] } -opentelemetry-otlp = { version = "0.29", features = ["grpc-tonic", "trace", "logs"] } -opentelemetry-appender-tracing = "0.29" - -# Schema / CRD -schemars = "1" -garde = { version = "0.23", features = ["derive", "serde"] } - -# Config -figment = { version = "0.10", features = ["env", "toml", "yaml"] } - -# Allocator -tikv-jemallocator = { version = "0.7", features = ["profiling", "stats"] } - -# Performance primitives -smallvec = "1" -compact_str = "0.9" -bumpalo = { version = "3", features = ["collections"] } -memmap2 = "0.9" -bytes = "1" - -# Object pooling -lockfree-object-pool = "0.1" - -# Async utilities -futures = "0.3" -async-compression = { version = "0.4", features = ["tokio", "zstd", "xz", "bzip2"] } - -# Time -time = { version = "0.3", features = ["formatting"] } - -# Internal -niphas-core = { path = "crates/niphas-core" } -``` - -## niphas-core (shared crate) - -Contains everything that two or more binaries need: - -| Module | Contents | Consumers | -|---|---|---| -| `crd.rs` | `NiphasWorkload` CRD struct (`#[derive(CustomResource)]`) | operator, eval, CSI | -| `nix/nar.rs` | NAR archive streaming parser + extractor | CSI, mesh | -| `nix/narinfo.rs` | `.narinfo` text parser | CSI, mesh, eval | -| `nix/hash.rs` | Nix hashing (SHA-256, Nix-base32 encoding) | CSI, mesh, eval | -| `nix/signature.rs` | Ed25519 NAR signature verification | CSI, mesh | -| `nix/store_path.rs` | `StorePath` parsing + validation | all | -| `nix/cache_client.rs` | Binary cache HTTP client | CSI, eval | -| `nix/closure.rs` | Recursive closure resolution via HTTP | eval | -| `eval.rs` | Eval request/response types, input validation (flake_ref, attribute, revision) | operator, eval | -| `error.rs` | `NiphasError` enum (thiserror) | all | -| `config.rs` | Shared config loading (figment) | all | -| `telemetry.rs` | `init_tracing()` with opt-in OTEL (traces + logs), `TelemetryGuard` | all | -| `testutils.rs` | Test fixtures: `WorkloadBuilder`, `FakeCacheClient` (feature-gated) | all (dev) | - -Uses a `runtime` feature flag so lightweight consumers (CSI, mesh) can depend on it without pulling in `kube-runtime`: - -```toml -[features] -default = [] -runtime = ["kube/runtime"] -``` - -## CRD Definition (crd.rs) - -```rust -#[derive(CustomResource, Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[kube( - group = "niphas.io", - version = "v1alpha1", - kind = "NiphasWorkload", - namespaced, - status = "NiphasWorkloadStatus", -)] -pub struct NiphasWorkloadSpec { - pub flake_ref: String, - pub attribute: String, - pub replicas: Option, -} - -pub struct NiphasWorkloadStatus { - pub observed_generation: Option, // detect desync - pub phase: String, - pub store_path: Option, - pub closure_paths: Option>, // full closure for rescheduling - pub ready_replicas: Option, - pub conditions: Option>, -} - -pub struct NiphasCondition { - pub type_: String, // Evaluated, ClosureCached, Available, Progressing, Degraded - pub status: String, // True, False, Unknown - pub reason: String, // EvalSucceeded, NarFetchFailed, ReplicasReady - pub message: String, - pub last_transition_time: String, - pub observed_generation: Option, -} -``` - -`closure_paths` is critical for resilience: when a pod is rescheduled to a new -node, the CSI driver knows exactly which store paths to fetch without depending -on `.narinfo` from an external cache. The CSI driver checks local cache first, -then mesh peers (if available), then binary cache HTTP. See `docs/RESILIENCE.md`. - -## Per-Crate Key Dependencies - -### niphas-eval -`niphas-core` (runtime), `kube`, `kube-runtime`, `k8s-openapi`, `axum`, `tower`, `tokio`, `serde`, `tracing`, `anyhow`, `reqwest` (closure resolution via binary cache HTTP). Nix evaluation runs via `tokio::process::Command("nix")` subprocess with sandbox flags. Future: in-process FFI via Nix C API. - -### niphas-operator -`niphas-core` (runtime), `kube`, `kube-runtime`, `k8s-openapi`, `kube-leader-election` (Lease-based leader election), `axum` (health probes), `tokio`, `serde`, `tracing`, `anyhow`, `reqwest` (eval webhook client). - -### niphas-csi -`niphas-core`, `tonic`, `prost`, `prost-types`, `tokio`, `tracing`, `anyhow`, `reqwest` (binary cache fallback), `sha2`, `ed25519-dalek`, `async-compression` (zstd/xz/bzip2 decompression). -Build dep: `tonic-build`, `tonic-prost-build` (compiles vendored CSI proto). - -### niphas-mesh -`niphas-core`, `libp2p`, `tokio`, `tracing`, `anyhow`, `serde`, `async-compression` (zstd, NAR streaming), `futures`. Early stage -- only `main.rs` exists currently. - -## Nix Build (flake.nix with Crane) - -Stack: **rust-overlay + crane + flake-utils + nix-direnv** - -Crane's two-phase strategy: -1. `buildDepsOnly` – builds all workspace dependencies (cacheable via Cachix/Attic) -2. `buildPackage` per crate – builds only source code against cached deps - -Each crate produces a binary and an OCI image (`dockerTools.buildLayeredImage`). - -The devShell includes: `kubectl`, `helm`, `cargo-deny`, `cargo-nextest`, `cargo-watch`, `protobuf`. - -## Key Design Decisions - -**Axum over actix-web** – tower middleware is composable and shared with kube-rs's own tower-based client. Axum is the framework used in kube-rs examples. - -**Proto vendoring** – CSI protobuf lives in `proto/csi/` rather than being fetched at build time. Nix builds are sandboxed (no network access). Alternative: use the `k8s-csi` crate for pre-generated bindings. - -**libp2p feature selection** – only `tcp`, `quic`, `noise`, `yamux`, `gossipsub`, `request-response`, `stream`. The full feature set pulls a massive dep tree. `stream` gives raw `AsyncRead`/`AsyncWrite` substreams for bulk NAR transfers. - -**Workspace dependency inheritance** – every dep used by 2+ crates goes in `[workspace.dependencies]`. Members use `dep.workspace = true`. Prevents version drift. - -**Single Cargo.lock** – all crates build against identical dependency versions. - -## Observability - -### Structured logging - -All binaries emit JSON-structured logs on stdout via `tracing-subscriber`. Log level -is controlled by `RUST_LOG` env var (default: `info`). Every log line includes -timestamp, level, target, span context, and structured fields. - -### OpenTelemetry (opt-in) - -Telemetry is initialized in `niphas-core/src/telemetry.rs` via `init_tracing()`. -OTEL support is **opt-in** -- controlled entirely by standard OTEL SDK env vars. -Without them, behavior is identical to plain JSON logging with zero OTEL overhead. - -| Env var | Effect | -|---------|--------| -| (none) | JSON logs on stdout only | -| `OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4317` | Adds OTLP trace exporter (spans) + OTLP log exporter | -| `OTEL_SERVICE_NAME=custom-name` | Overrides the default service name in the OTEL resource | -| `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf` | Uses HTTP instead of gRPC for OTLP export | - -Any env var documented in the [OTEL SDK specification](https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/) is supported. - -### Architecture - -``` -tracing-subscriber::registry - | - +-- fmt::layer().json() (always: JSON logs on stdout) - +-- OpenTelemetryLayer (opt-in: OTLP trace spans) - +-- OpenTelemetryTracingBridge (opt-in: OTLP log export) -``` - -`init_tracing()` returns a `TelemetryGuard` that each binary holds until shutdown. -The guard flushes pending spans and logs on drop via `SdkTracerProvider::shutdown()` -and `SdkLoggerProvider::shutdown()`, preventing data loss on graceful exit. - -```rust -// Every main.rs: -let _telemetry = niphas_core::telemetry::init_tracing("niphas-operator"); -// guard lives until end of main, ensuring flush -``` - -### Distributed tracing - -With OTEL enabled, every reconciliation, eval request, and CSI mount operation -produces trace spans that propagate across component boundaries. This enables -end-to-end visibility: operator -> eval webhook -> closure resolution -> CSI mount. - -W3C TraceContext propagation between operator and eval can be added via -`tower-http` tracing layer + `opentelemetry-http` (future enhancement). - -### Metrics (future) - -The health server in each binary can expose a `/metrics` endpoint for Prometheus -scraping via `opentelemetry-prometheus`. The OTEL layer architecture supports -adding a metrics layer without changing the existing setup. - -## Input Validation - -### Threat: Nix expression injection - -The eval service constructs Nix expressions by interpolating user-provided fields -(`flake_ref`, `attribute`, `revision`) into a `format!()` string that is passed -to `nix eval`. Without validation, characters like `"`, `\`, `$`, `;`, `(`, `)` -can break out of the string literal and inject arbitrary Nix code. - -```rust -// evaluator.rs -- the interpolation point: -let expr = format!( - r#"let drv = (builtins.getFlake "{pinned_ref}").{attribute}; in ..."# -); -``` - -The flake allowlist (glob matching) validates the **pattern** of the flake ref -but does not reject dangerous characters. A ref like -`github:myorg/app" ++ builtins.readFile /etc/shadow ++ "` could pass a -`github:myorg/*` glob and still inject code. - -### Defense: strict input validation - -Validation functions in `niphas-core/src/eval.rs` enforce character-level -restrictions before any interpolation happens: - -| Field | Regex | Rejects | -|-------|-------|---------| -| `flake_ref` | `^[a-zA-Z][a-zA-Z0-9+\-\.]*:[a-zA-Z0-9/_\-\.]+$` | `"`, `\`, `$`, `;`, `(`, `)`, spaces, unicode | -| `attribute` | `^[a-zA-Z_][a-zA-Z0-9_\-]*(\.[a-zA-Z_][a-zA-Z0-9_\-]*)*$` | Anything that isn't a valid Nix attribute path | -| `revision` | `^[a-fA-F0-9]{6,40}$` | Non-hex characters, wrong length | - -These run **before** the allowlist check and **before** any string interpolation. -Rejection at this layer is a 400 Bad Request, not a security event. - -### Defense layers (eval pipeline) - -``` -1. Input validation -- reject malformed flake_ref / attribute / revision -2. Flake allowlist -- reject refs not matching configured patterns -3. Nix sandbox -- restrict_eval=true, IFD=false, max_jobs=0 -4. Container isolation -- non-root, no capabilities, read-only rootfs -5. Network policy -- egress limited to git HTTPS + DNS only -``` - -See [`docs/SECURITY_DESIGN.md`](docs/SECURITY_DESIGN.md) for the full 4-layer -defense model on eval sandboxing. - -## Leader Election - -The operator must run with a single active reconciler to avoid duplicate eval -calls, SSA conflicts, and race conditions when multiple replicas process the -same `NiphasWorkload` simultaneously. - -### Mechanism - -Lease-based leader election via `coordination.k8s.io/v1` Lease object. Only the -leader runs the `Controller` reconciliation loop; standby replicas wait and -monitor the lease. - -| Parameter | Value | Rationale | -|-----------|-------|-----------| -| Lease TTL | 15s | Time before a dead leader is considered gone | -| Renew interval | 5s | Leader heartbeat frequency | -| Retry interval | 5s | Non-leader polling frequency | - -Failover takes at most ~15 seconds. Reconciliation is idempotent (SSA-based), -so brief dual-leader windows during transition are safe. - -### RBAC - -The operator ServiceAccount requires a namespace-scoped Role for Lease -management in `niphas-system`: - -```yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: niphas-operator-leader-election - namespace: niphas-system -rules: - - apiGroups: ["coordination.k8s.io"] - resources: ["leases"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] -``` - -### Readiness integration - -The `/readyz` endpoint returns 200 only after leader election is resolved and -the controller is actively watching. During standby, the pod reports not-ready -so the health Service does not route traffic to it. - -See [`docs/OPERATOR.md`](docs/OPERATOR.md) for the full operator design -including reconciliation state machine, error handling, and watches. -See [`docs/RESILIENCE.md`](docs/RESILIENCE.md) for failure scenarios and -recovery timelines. - -## CRD Generation - -A small binary or example in `niphas-core` that outputs `serde_yaml::to_string(&NiphasWorkload::crd())` keeps `manifests/crd.yaml` in sync with the Rust types. Wire into CI. diff --git a/CRD_REFERENCE.md b/CRD_REFERENCE.md deleted file mode 100644 index 7f90269..0000000 --- a/CRD_REFERENCE.md +++ /dev/null @@ -1,302 +0,0 @@ -# Niphas -- CRD Reference - -Single source of truth for the `NiphasWorkload` custom resource. - -```yaml -apiVersion: niphas.io/v1alpha1 -kind: NiphasWorkload -``` - -## Spec - -### Core fields (required) - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `flakeRef` | `string` | yes | Flake reference. E.g. `github:myorg/myapp` | -| `attribute` | `string` | yes | Flake output attribute. E.g. `packages.x86_64-linux.default` | - -### Core fields (optional) - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `revision` | `string` | latest | Git revision to pin. E.g. `a1b2c3d4e5f6` | -| `replicas` | `i32` | `1` | Number of pod replicas | -| `binaryCache` | `string` | from ConfigMap | Override binary cache URL for this workload | - -### Container fields - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `command` | `Vec` | auto-detected | Override entrypoint. If omitted, resolved from `meta.mainProgram` during eval | -| `args` | `Vec` | none | Arguments passed to the command | -| `env` | `Vec` | none | Environment variables. Same schema as K8s `container.env` | -| `ports` | `Vec` | none | Exposed ports. Same schema as K8s `container.ports` | - -### Resource management - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `resources` | `ResourceRequirements` | none | CPU/memory requests and limits. Same schema as K8s | - -### Health checks - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `livenessProbe` | `Probe` | none | Same schema as K8s `container.livenessProbe` | -| `readinessProbe` | `Probe` | none | Same schema as K8s `container.readinessProbe` | -| `startupProbe` | `Probe` | none | Same schema as K8s `container.startupProbe` | - -### Scheduling - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `nodeSelector` | `Map` | none | Additional node selector labels (merged with `niphas.io/store=true`) | -| `tolerations` | `Vec` | failover tolerations | Extra tolerations. The operator always injects `not-ready`/`unreachable` tolerations (30s) | -| `affinity` | `Affinity` | none | Pod affinity/anti-affinity rules | - -### Service exposure - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `service` | `ServiceSpec` | none | If set, the operator creates a Service with `ownerReferences` | -| `ingress` | `IngressSpec` | none | If set, the operator creates an Ingress with `ownerReferences` | - -#### ServiceSpec - -```yaml -service: - type: ClusterIP # ClusterIP, NodePort, LoadBalancer - ports: - - name: http - port: 80 - targetPort: http # name or number -``` - -#### IngressSpec - -```yaml -ingress: - enabled: true - className: nginx - hosts: - - host: myapp.example.com - paths: - - path: / - pathType: Prefix - port: http - tls: - - secretName: myapp-tls - hosts: - - myapp.example.com -``` - -### Extra volumes - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `extraVolumes` | `Vec` | none | Additional K8s volumes (ConfigMaps, Secrets, etc.) | -| `extraVolumeMounts` | `Vec` | none | Mount points for extra volumes | - -### Multi-architecture - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `architectures` | `Vec` | none | If set, evaluate per-architecture. Uses `{arch}` template in `attribute`. E.g. `["x86_64", "aarch64"]` | - -When `architectures` is set: -- The `attribute` field must contain `{arch}` placeholder -- Operator evaluates once per architecture -- Creates separate Deployments per architecture with `nodeSelector` -- All Deployments share the label `niphas.io/workload: ` -- A single Service selects all architectures via the shared label - ---- - -## Status - -All status fields are set by the operator. Users do not write to status. - -### Top-level status - -| Field | Type | Description | -|-------|------|-------------| -| `observedGeneration` | `i64` | The `metadata.generation` most recently processed. If `< generation`, the operator hasn't processed the latest spec change | -| `phase` | `string` | High-level state. One of: `Pending`, `Evaluating`, `Provisioning`, `Running`, `Failed` | -| `storePath` | `string` | Resolved Nix store path. E.g. `/nix/store/abc123-myapp-1.0.0` | -| `resolvedCommand` | `string` | The actual entrypoint. E.g. `/nix/store/abc123-myapp-1.0.0/bin/myapp` | -| `closurePaths` | `Vec` | Full closure (root + all transitive references). CSI uses this to fetch without depending on binary cache `.narinfo` | -| `lastEval` | `string` | ISO 8601 timestamp of last successful evaluation | -| `readyReplicas` | `i32` | Number of pods in Ready state | -| `conditions` | `Vec` | Standard K8s-style conditions | - -### Multi-architecture status - -When `spec.architectures` is set, status includes per-arch details: - -| Field | Type | Description | -|-------|------|-------------| -| `architectures` | `Vec` | Per-architecture status | - -```yaml -status: - architectures: - - arch: x86_64 - storePath: "/nix/store/abc123-myapp-1.0.0" - resolvedCommand: "/nix/store/abc123-myapp-1.0.0/bin/myapp" - readyReplicas: 3 - - arch: aarch64 - storePath: "/nix/store/xyz789-myapp-1.0.0" - resolvedCommand: "/nix/store/xyz789-myapp-1.0.0/bin/myapp" - readyReplicas: 3 - readyReplicas: 6 # sum of all archs -``` - -### Phase lifecycle - -``` -Pending --> Evaluating --> Provisioning --> Running - | | | - v v v - Failed Failed Failed -``` - -| Phase | Meaning | -|-------|---------| -| `Pending` | CRD created, operator has not started reconciliation | -| `Evaluating` | Operator called eval webhook, waiting for result | -| `Provisioning` | Eval succeeded, operator is creating child resources (Deployment, Service, etc.) | -| `Running` | At least one replica is Ready | -| `Failed` | Eval failed, NAR not found in cache, or child resource creation failed | - -Transitions back to `Evaluating` happen when `observedGeneration < generation` -(spec was updated). - -### Condition types - -| Type | True | False | -|------|------|-------| -| `Evaluated` | Nix eval succeeded, store path resolved | Eval failed, pending, or flake not allowed | -| `ClosureCached` | All closure paths available in binary cache | Fetch failed or pending | -| `Available` | >= 1 replica running and Ready | No replicas ready | -| `Progressing` | Rollout in progress (new spec being applied) | Stable | -| `Degraded` | Partial failure (some replicas down, NAR corrupted, etc.) | Everything healthy | - -#### Condition struct - -```yaml -conditions: - - type: Evaluated - status: "True" # "True", "False", "Unknown" - reason: EvalSucceeded # machine-readable - message: "store path resolved: /nix/store/abc123-myapp-1.0.0" - lastTransitionTime: "2026-06-05T12:00:00Z" - observedGeneration: 3 # generation when this condition was set -``` - -#### Reason values - -| Condition | Reason (True) | Reason (False) | -|-----------|--------------|----------------| -| `Evaluated` | `EvalSucceeded` | `EvalFailed`, `FlakeNotAllowed`, `EvalTimeout` | -| `ClosureCached` | `CacheVerified` | `StorePathNotCached`, `NarFetchFailed` | -| `Available` | `ReplicasReady` | `NoReplicasReady` | -| `Progressing` | `RolloutInProgress` | `RolloutComplete` | -| `Degraded` | `PartialFailure`, `NarCorrupted`, `NarSignatureVerificationFailed` | `Healthy` | - ---- - -## Validation rules - -| Field | Rule | -|-------|------| -| `flakeRef` | Must match `^[a-zA-Z][a-zA-Z0-9+.-]*:.+$` (valid flake reference syntax) | -| `attribute` | Must not be empty | -| `replicas` | >= 0 | -| `revision` | If set, must match `^[a-f0-9]{6,40}$` (git short or full SHA) | -| `ports[].containerPort` | 1-65535, unique within the array | -| `architectures[]` | Each must be a valid Nix system arch: `x86_64`, `aarch64`, `i686`, `armv7l` | -| `architectures` + `attribute` | If `architectures` is set, `attribute` must contain `{arch}` | - -## Defaults injected by the operator - -The operator merges these into the generated Deployment spec: - -| What | Value | Source | -|------|-------|--------| -| `nodeSelector` | `niphas.io/store: "true"` | Always injected, merged with `spec.nodeSelector` | -| `tolerations` | `not-ready`/`unreachable` with 30s | Always injected for fast failover | -| `topologySpreadConstraints` | hostname spread (hard), zone spread (soft) | Injected for replicas >= 2 | -| `labels` | `niphas.io/workload: ` | On all pods, for Service selection | -| `volumes` | CSI inline volume for `/nix/store` | Always, from closure data | -| `volumeMounts` | `/nix/store` (read-only) | Always | -| `image` | `ghcr.io/fullzer4/niphas-runner:latest` | Stub image, overridable | -| `command` | `[resolvedCommand]` | From eval result | - -## Labels and annotations set by the operator - -### On child resources (Deployment, Service, Ingress, PDB) - -```yaml -labels: - niphas.io/workload: - niphas.io/managed-by: niphas-operator - app.kubernetes.io/name: - app.kubernetes.io/managed-by: niphas -annotations: - niphas.io/store-path: /nix/store/abc123-myapp-1.0.0 - niphas.io/flake-ref: github:myorg/myapp - niphas.io/revision: a1b2c3d4e5f6 -``` - -### On pods - -```yaml -labels: - niphas.io/workload: # for Service selection - niphas.io/store-hash: abc123 # short hash for identification -``` - -## ownerReferences - -All child resources have `ownerReferences` pointing to the NiphasWorkload: - -```yaml -ownerReferences: - - apiVersion: niphas.io/v1alpha1 - kind: NiphasWorkload - name: - uid: - controller: true - blockOwnerDeletion: true -``` - -K8s garbage collection automatically deletes child resources when the -NiphasWorkload is deleted (after finalizer cleanup). - -## Print columns (kubectl output) - -``` -$ kubectl get niphasworkloads -NAME FLAKE PHASE READY -myapp github:myorg/myapp Running 3 -hello github:NixOS/nixpkgs Running 1 -``` - -Defined via `printcolumn` in the CRD derive: - -| Column | JSONPath | -|--------|----------| -| Flake | `.spec.flakeRef` | -| Phase | `.status.phase` | -| Ready | `.status.readyReplicas` | - -## Short names - -```yaml -shortNames: - - nw - - niphas -``` - -`kubectl get nw` is equivalent to `kubectl get niphasworkloads`. diff --git a/CSI_DRIVER.md b/CSI_DRIVER.md deleted file mode 100644 index f6f42f1..0000000 --- a/CSI_DRIVER.md +++ /dev/null @@ -1,344 +0,0 @@ -# niphas-csi -- CSI Driver Design - -## Why CSI - -CSI (Container Storage Interface) is the official K8s storage abstraction. -It is the only approach that: - -- Follows K8s storage standards (no hostPath, no node mutation) -- Works under Pod Security Standards Restricted -- Functions on all managed K8s (EKS, GKE, AKS, OpenShift) -- Lets kubelet manage the volume lifecycle correctly -- Supports topology-aware scheduling - -## Architecture - -niphas-csi is a **Node-only plugin**. No Controller component. No PV/PVC. -It uses **CSI Ephemeral Inline Volumes** (GA since K8s 1.25). - -``` -pod spec - volumes: - - csi: - driver: niphas.io.csi - volumeAttributes: - storePath: "/nix/store/abc123-hello-2.12.1" - binaryCacheUrl: "https://cache.nixos.org" - -kubelet sees driver: niphas.io.csi - --> connects to /var/lib/kubelet/plugins/niphas.io.csi/csi.sock - --> calls NodePublishVolume(volume_id, target_path, volume_context) - -niphas-csi driver - --> checks node-local cache at /var/lib/niphas/cache/abc123-hello-2.12.1/ - --> if cache miss: fetches .narinfo from binary cache (HTTP) - --> downloads and extracts NAR to cache dir - --> bind-mounts cache dir --> target_path (read-only) - --> returns success - -kubelet starts pod containers with volume mounted - -pod deletion - --> kubelet calls NodeUnpublishVolume - --> driver unmounts bind mount - --> cached NAR data stays for reuse by other pods -``` - -## gRPC Services - -CSI v1.12.0 defines 3 gRPC services. niphas-csi only implements Identity + Node. - -### Identity Service (3 RPCs, all required) - -| RPC | What niphas-csi returns | -|-----|------------------------| -| `GetPluginInfo` | name=`niphas.io.csi`, version from Cargo.toml | -| `GetPluginCapabilities` | empty (no CONTROLLER_SERVICE) | -| `Probe` | ready=true | - -### Node Service (4 RPCs implemented, 4 return UNIMPLEMENTED) - -| RPC | Status | Purpose | -|-----|--------|---------| -| `NodePublishVolume` | Implemented | Fetch NAR, extract, bind-mount | -| `NodeUnpublishVolume` | Implemented | Unmount bind mount | -| `NodeGetCapabilities` | Implemented | Returns empty capabilities | -| `NodeGetInfo` | Implemented | Returns node hostname | -| `NodeStageVolume` | UNIMPLEMENTED | Not needed (no staging) | -| `NodeUnstageVolume` | UNIMPLEMENTED | Not needed | -| `NodeGetVolumeStats` | UNIMPLEMENTED | Not needed | -| `NodeExpandVolume` | UNIMPLEMENTED | Not needed | - -### Controller Service - -Not implemented. Not needed for ephemeral inline volumes. - -**Total RPCs to implement: 7** (3 Identity + 4 Node). - -## Volume Lifecycle - -### Pod creation - -1. Scheduler places pod on node -2. Kubelet reads pod spec, sees `driver: niphas.io.csi` -3. Kubelet checks `CSIDriver` object: `attachRequired: false`, skips attach -4. Kubelet generates `volume_id` (e.g. `csi--`) -5. Kubelet calls `NodePublishVolume` with: - - `volume_id` - - `target_path`: `/var/lib/kubelet/pods//volumes/kubernetes.io~csi//mount` - - `volume_context`: `storePath`, `binaryCacheUrl`, pod metadata - - `readonly: true` -6. Driver fetches NAR (or hits cache), bind-mounts, returns OK -7. Kubelet starts containers - -### Pod deletion - -1. Kubelet calls `NodeUnpublishVolume(volume_id, target_path)` -2. Driver unmounts, returns OK -3. Cached data stays on disk for dedup - -### Idempotency - -Both `NodePublishVolume` and `NodeUnpublishVolume` must be idempotent: -- Publish on already-mounted volume: check mountpoint, return OK -- Unpublish on already-unmounted volume: return OK - -## Node-Local Cache and Deduplication - -The driver manages a cache at `/var/lib/niphas/cache/` on each node: - -``` -/var/lib/niphas/cache/ - abc123-hello-2.12.1/ # extracted NAR contents - def456-glibc-2.38/ # shared dependency - .tmp-xyz789/ # in-progress extraction (cleaned on crash) -``` - -### Dedup guarantees - -- **Same path, multiple pods**: first pod triggers download, others hit cache -- **Concurrent requests**: per-path mutex prevents duplicate downloads -- **Atomic population**: extract to `.tmp-*`, then `rename()` into place -- **Crash safety**: incomplete `.tmp-*` dirs are cleaned on driver startup -- **Content-addressed**: hash in path name means cache is always valid (no staleness) - -### Garbage collection - -Background task periodically: -1. Scans active mounts (ref counting) -2. Evicts unreferenced paths by LRU or disk pressure -3. Never evicts paths with active bind mounts - -## Closure Handling - -A Nix closure includes a package and all its transitive dependencies. - -Closure resolution happens in `niphas-eval` (not the CSI driver). The eval -webhook resolves the full closure by recursively fetching `.narinfo` files -from the binary cache (pure HTTP, no Nix needed) and writes the complete -list to `status.closurePaths` in the CRD. - -The operator passes the closure list to the CSI driver via `volumeAttributes`: - -```yaml -volumes: - - name: app-closure - csi: - driver: niphas.io.csi - volumeAttributes: - closurePaths: "/nix/store/abc123-hello,/nix/store/def456-glibc,..." - mountMode: "closure" -``` - -The CSI driver receives the pre-resolved closure list. It does NOT need -to resolve references itself. For each path in the list, it: - -1. Checks local cache (`/var/lib/niphas/cache//`) -2. If cache miss: fetches from mesh or binary cache -3. After all paths are cached: constructs a merged `/nix/store` view -4. Bind-mounts the merged view at `target_path` (read-only) - -This design keeps the CSI driver simple (no `.narinfo` parsing needed for -closure resolution) and avoids depending on binary cache availability -during mount -- the closure list is already known. - -## Serving on Unix Domain Socket - -The driver listens on a UDS, not TCP. kubelet communicates with CSI drivers -exclusively via Unix sockets. - -``` -/var/lib/kubelet/plugins/niphas.io.csi/csi.sock <-- driver socket -/var/lib/kubelet/plugins_registry/ <-- registrar creates reg socket here -``` - -The `node-driver-registrar` sidecar: -1. Connects to the driver socket -2. Calls `GetPluginInfo` + `NodeGetInfo` -3. Creates a registration socket in `/var/lib/kubelet/plugins_registry/` -4. kubelet's plugin watcher discovers the driver - -## CSIDriver K8s Object - -```yaml -apiVersion: storage.k8s.io/v1 -kind: CSIDriver -metadata: - name: niphas.io.csi -spec: - attachRequired: false - podInfoOnMount: true - volumeLifecycleModes: - - Ephemeral - fsGroupPolicy: None - requiresRepublish: false - seLinuxMount: false -``` - -| Field | Value | Reason | -|-------|-------|--------| -| `attachRequired` | false | No controller, kubelet calls Node directly | -| `podInfoOnMount` | true | Driver receives pod name/namespace/uid in volume_context | -| `volumeLifecycleModes` | Ephemeral | Inline volumes only (for now) | -| `fsGroupPolicy` | None | Nix store paths are immutable, no permission changes | - -## DaemonSet Deployment - -```yaml -apiVersion: apps/v1 -kind: DaemonSet -metadata: - name: niphas-csi-node - namespace: niphas-system -spec: - selector: - matchLabels: - app: niphas-csi-node - template: - metadata: - labels: - app: niphas-csi-node - spec: - serviceAccountName: niphas-csi-node - containers: - - name: niphas-csi - image: ghcr.io/fullzer4/niphas-csi:latest - securityContext: - privileged: true - env: - - name: NODE_NAME - valueFrom: - fieldRef: - fieldPath: spec.nodeName - volumeMounts: - - name: socket-dir - mountPath: /csi - - name: pods-mount-dir - mountPath: /var/lib/kubelet/pods - mountPropagation: Bidirectional - - name: niphas-cache - mountPath: /var/lib/niphas/cache - - - name: node-driver-registrar - image: registry.k8s.io/sig-storage/csi-node-driver-registrar:v2.13.0 - args: - - "--csi-address=/csi/csi.sock" - - "--kubelet-registration-path=/var/lib/kubelet/plugins/niphas.io.csi/csi.sock" - volumeMounts: - - name: socket-dir - mountPath: /csi - - name: registration-dir - mountPath: /registration - - - name: liveness-probe - image: registry.k8s.io/sig-storage/livenessprobe:v2.14.0 - args: - - "--csi-address=/csi/csi.sock" - - "--health-port=9898" - volumeMounts: - - name: socket-dir - mountPath: /csi - - volumes: - - name: socket-dir - hostPath: - path: /var/lib/kubelet/plugins/niphas.io.csi/ - type: DirectoryOrCreate - - name: pods-mount-dir - hostPath: - path: /var/lib/kubelet/pods - type: Directory - - name: registration-dir - hostPath: - path: /var/lib/kubelet/plugins_registry/ - type: Directory - - name: niphas-cache - hostPath: - path: /var/lib/niphas/cache - type: DirectoryOrCreate -``` - -### Key volume details - -| Volume | hostPath | Why | -|--------|----------|-----| -| `socket-dir` | `/var/lib/kubelet/plugins/niphas.io.csi/` | Driver socket, shared with registrar | -| `pods-mount-dir` | `/var/lib/kubelet/pods` | Where kubelet expects bind mounts. **Must have `mountPropagation: Bidirectional`** | -| `registration-dir` | `/var/lib/kubelet/plugins_registry/` | Registrar creates reg socket here for kubelet discovery | -| `niphas-cache` | `/var/lib/niphas/cache` | Node-local NAR cache, persists across DaemonSet restarts | - -### Why the DaemonSet uses hostPath but consumer pods don't - -The CSI DaemonSet needs hostPath to: -- Place the Unix socket where kubelet expects it -- Access kubelet's pod mount directory -- Maintain a persistent cache - -But **consumer pods don't use hostPath**. They use standard CSI inline volumes. -The CSI abstraction exists precisely to give privileged access to the driver -while keeping consumer pods unprivileged. - -## NAR Fetching - -The CSI driver fetches individual NARs via the fallback chain -(see [`RESILIENCE.md`](RESILIENCE.md)): - -1. Local cache (`/var/lib/niphas/cache//`) -2. Mesh P2P via Unix socket (`/var/run/niphas/mesh.sock`) -3. Binary cache HTTP (direct) - -For each store path in the closure list (received via `volumeAttributes`): - -1. Fetch `.narinfo` from binary cache -2. Verify Ed25519 signature -3. Download compressed NAR (`.nar.zst`) -4. Verify `FileHash` (compressed) -5. Decompress -6. Verify `NarHash` (uncompressed, computed inline during extraction) -7. Extract to temp dir with safety checks (CVE-2024-45593 mitigations) -8. Atomic rename to cache dir - -All NAR format parsing, hash verification, and signature checking is -implemented in-house in `niphas-core`. See [`NIX_WIRE.md`](NIX_WIRE.md) -for the complete specification. - -## Sidecars - -| Sidecar | Required | Image | Purpose | -|---------|----------|-------|---------| -| node-driver-registrar | Yes | `registry.k8s.io/sig-storage/csi-node-driver-registrar:v2.13.0` | Registers driver with kubelet | -| livenessprobe | Recommended | `registry.k8s.io/sig-storage/livenessprobe:v2.14.0` | Health monitoring via `Probe` RPC | -| csi-provisioner | No | - | Only for PV/PVC (not needed for ephemeral) | -| csi-attacher | No | - | Only for `attachRequired: true` | - -## Proto File - -Vendor CSI v1.12.0 proto from: -``` -https://github.com/container-storage-interface/spec/blob/v1.12.0/csi.proto -``` - -Place at `proto/csi/v1/csi.proto`. Build with `tonic-build` in `build.rs`. - -Proto depends on `google/protobuf/{descriptor,timestamp,wrappers}.proto`, -which prost/tonic-build bundle automatically. diff --git a/DEPLOY_FLOW.md b/DEPLOY_FLOW.md deleted file mode 100644 index e4caab0..0000000 --- a/DEPLOY_FLOW.md +++ /dev/null @@ -1,565 +0,0 @@ -# Niphas -- Deploy Flow - -## User experience - -A user deploys a Nix flake on K8s with a single resource: - -```yaml -apiVersion: niphas.io/v1alpha1 -kind: NiphasWorkload -metadata: - name: myapp -spec: - flakeRef: "github:myorg/myapp" - attribute: "packages.x86_64-linux.default" -``` - -No Dockerfile, no registry, no image tags. The operator handles eval, -build, caching, mounting, service creation, and failure recovery. - -## Minimal deploy (3 lines) - -```yaml -apiVersion: niphas.io/v1alpha1 -kind: NiphasWorkload -metadata: - name: hello -spec: - flakeRef: "github:NixOS/nixpkgs" - attribute: "legacyPackages.x86_64-linux.hello" -``` - -This evaluates the flake, resolves the store path, creates a single-replica -Deployment, mounts the closure via CSI, and runs the `hello` binary. - -## Production deploy - -```yaml -apiVersion: niphas.io/v1alpha1 -kind: NiphasWorkload -metadata: - name: myapp - namespace: production -spec: - flakeRef: "github:myorg/myapp" - attribute: "packages.x86_64-linux.default" - revision: "a1b2c3d4e5f6" - replicas: 3 - - args: ["--port", "8080"] - - env: - - name: DATABASE_URL - valueFrom: - secretKeyRef: - name: myapp-db - key: url - - name: LOG_LEVEL - value: "info" - - ports: - - name: http - containerPort: 8080 - - name: metrics - containerPort: 9090 - - resources: - requests: - cpu: "250m" - memory: "256Mi" - limits: - cpu: "1" - memory: "1Gi" - - livenessProbe: - httpGet: - path: /healthz - port: http - initialDelaySeconds: 10 - readinessProbe: - httpGet: - path: /ready - port: http - - service: - type: ClusterIP - ports: - - name: http - port: 80 - targetPort: http - - ingress: - enabled: true - className: nginx - hosts: - - host: myapp.example.com - paths: - - path: / - pathType: Prefix - port: http - tls: - - secretName: myapp-tls - hosts: - - myapp.example.com - - binaryCache: "https://cache.company.com" - - extraVolumes: - - name: config - configMap: - name: myapp-config - extraVolumeMounts: - - name: config - mountPath: /etc/myapp - readOnly: true -``` - -## CRD fields - -The NiphasWorkload replaces every Dockerfile concept: - -| Dockerfile | NiphasWorkload | -|-----------|----------------| -| `FROM` / image | `spec.flakeRef` + `spec.attribute` | -| `ENTRYPOINT` | `spec.command` (auto-detected from `meta.mainProgram` if omitted) | -| `CMD` | `spec.args` | -| `EXPOSE` | `spec.ports` | -| `ENV` | `spec.env` | -| `HEALTHCHECK` | `spec.livenessProbe` / `spec.readinessProbe` | - -Plus K8s-native fields: `resources`, `nodeSelector`, `tolerations`, -`affinity`, `service`, `ingress`, `extraVolumes`, `extraVolumeMounts`. - -## Build model: CI builds, cluster consumes - -Niphas does NOT build in-cluster. The cluster only evaluates and fetches. - -``` -CI (GitHub Actions, Hydra, etc.): - nix build .#packages.x86_64-linux.default - nix copy --to https://cache.company.com ./result -``` - -`nix eval` resolves the store path deterministically from the derivation -inputs (the `.drv` hash) without building. The path is pure math -- same -inputs always produce the same `/nix/store/-`. The cluster -runs `nix eval` to discover the path, then fetches the pre-built NAR -from the binary cache. - -If the store path is not found in any configured binary cache, the -workload fails with a clear error: - -``` -status.phase = "Failed" -status.conditions: - - type: Evaluated - status: "False" - reason: StorePathNotCached - message: "/nix/store/abc123-myapp-1.0.0 not found in any binary cache. Build in CI first." -``` - -This is intentional. In-cluster builds would execute arbitrary code with -network access, require Nix daemon privileges, and add massive complexity. -The CI is the build system. The cluster is the runtime. - -## Evaluation: in-process via Nix C API (no Jobs) - -niphas-eval does NOT spawn Jobs or shell out to `nix eval`. It calls the -Nix evaluator directly via C FFI using `nix-bindings-rust`, the same -approach devenv 2.0 uses in production. - -The Nix C API (`libexpr-c`, `libstore-c`, `libflake-c`) exposes evaluation -as library calls. niphas-eval links against these at build time via -Rust FFI bindings. This eliminates: - -- Job creation overhead (K8s API round-trip) -- Pod scheduling latency -- Container image pull for eval sandbox -- Process spawn + exit overhead -- Log scraping - -Evaluation becomes a function call inside the niphas-eval process: - -```rust -// Simplified -- actual implementation uses nix-bindings-rust -fn evaluate_workload(flake_ref: &str, attribute: &str) -> Result { - let ctx = Context::new()?; - let store = Store::open(&ctx, None)?; - let state = EvalStateBuilder::new(&store)?.build()?; - - // Construct flake installable: "github:myorg/myapp#packages.x86_64-linux.default" - let expr = format!( - "let drv = (builtins.getFlake \"{}\").{}; in {{ \ - outPath = drv.outPath; \ - name = drv.name; \ - mainProgram = drv.meta.mainProgram or null; \ - }}", - flake_ref, attribute - ); - - let value = state.eval_from_string(&expr, "")?; - let attrs = value.require_attrs()?; - - Ok(EvalResult { - store_path: attrs.get("outPath")?.require_string()?, - name: attrs.get("name")?.require_string()?, - main_program: attrs.get("mainProgram")?.as_string().ok(), - }) -} -``` - -### Eval performance - -| Scenario | Time | Why | -|----------|------|-----| -| First eval of a new flake | 5-15s | Fetches flake source + inputs from git | -| Same flake, new revision | 1-3s | Inputs cached, re-evaluates expression | -| Same flake, same revision | milliseconds | Eval cache hit | -| Redeploy (no spec change) | skipped | `observedGeneration == generation` | - -The Nix eval cache persists in the niphas-eval pod's local store -(`/nix/store` on a PVC or hostPath). Cold start (first eval ever) -fetches nixpkgs and inputs -- this is expected and normal. - -### Sandbox flags - -Even via FFI, the evaluator runs with restrictive settings: - -```rust -let mut settings = EvalSettings::default(); -settings.sandbox = true; -settings.restrict_eval = true; -settings.allowed_uris = vec![ - "https://github.com", - "https://cache.nixos.org", -]; -settings.allow_import_from_derivation = false; // critical: blocks IFD -settings.max_jobs = 0; // no builds, eval only -``` - -`allow-import-from-derivation = false` is the most important flag. IFD -would allow arbitrary builds during eval, which is the main attack vector -for malicious flakes. - -### Flake allowlist (deny-by-default) - -Before evaluation, niphas-eval validates the flakeRef: - -```rust -fn validate_flake_ref(flake_ref: &str, allowlist: &[String]) -> Result<()> { - for pattern in allowlist { - if matches_glob(flake_ref, pattern) { - return Ok(()); - } - } - Err(EvalError::FlakeNotAllowed(flake_ref.into())) -} -``` - -Config: -```yaml -allowedFlakeOrigins: - - "github:myorg/*" - - "github:nixos/nixpkgs" -``` - -Any flakeRef not matching is rejected before the evaluator is invoked. - -## End-to-end sequence - -``` -User creates NiphasWorkload CR - | - v -[1] operator watches CRD, sees new resource - sets status.phase = "Evaluating" - | - v -[2] operator calls niphas-eval webhook - POST /evaluate { flakeRef, attribute, revision } - | - v -[3] niphas-eval validates flakeRef against allowlist - if rejected: returns error, operator sets phase = "Failed" - | - v -[4] niphas-eval calls Nix evaluator via C FFI (in-process): - - constructs flakeRef with revision: "github:myorg/myapp/" - - evaluates: outPath, name, meta.mainProgram - - sandbox=true, IFD=false, restrict-eval=true - - returns EvalResult in milliseconds (warm) to seconds (cold) - | - v -[5] niphas-eval resolves the full closure via HTTP: - - fetches /.narinfo - - parses References field (immediate dependencies) - - recursively fetches .narinfo for each reference - (parallel, concurrency 16-32) - - collects all store paths in the closure - | - v -[6] if root .narinfo returns 404 from all caches: - returns error, operator sets phase = "Failed", - reason = StorePathNotCached - (user must build in CI and push to cache first) - | - v -[7] niphas-eval returns result to operator. - operator updates CRD status: - status.phase = "Provisioning" - status.storePath = "/nix/store/abc123-myapp-1.0.0" - status.resolvedCommand = "/nix/store/abc123-myapp-1.0.0/bin/myapp" - status.closurePaths = ["/nix/store/abc123-myapp-1.0.0", - "/nix/store/def456-glibc-2.38", ...] - status.conditions += { type: Evaluated, status: True } - | - v -[8] operator generates child resources: - - Deployment: - - image: ghcr.io/fullzer4/niphas-runner:latest (scratch stub) - - command: ["/nix/store/abc123-myapp-1.0.0/bin/myapp"] - - volumes: - - csi: - driver: niphas.io.csi - volumeAttributes: - closurePaths: "/nix/store/abc123-myapp-1.0.0,..." - mountMode: "closure" - - volumeMounts: /nix/store (read-only) - - env, ports, probes, resources from CRD spec - - topology spread + failover tolerations - Service (if spec.service set) - Ingress (if spec.ingress set) - PDB (if replicas >= 2) - - all child resources have ownerReferences -> NiphasWorkload - | - v -[9] K8s schedules pods. kubelet on each node: - - calls NodePublishVolume on niphas-csi - - CSI fetches closure via fallback chain: - local cache -> mesh P2P -> binary cache HTTP - - verifies NAR signatures (Ed25519) - - extracts and bind-mounts at /nix/store (read-only) - | - v -[10] container starts, probes pass, pod becomes Ready - | - v -[11] operator updates status: - phase = "Running" - readyReplicas = 3 - conditions += { type: Available, status: True } - | - v -[12] niphas-mesh (if enabled) announces new NARs via gossipsub - other nodes discover availability for future fetches - no proactive replication -- each node fetches on demand -``` - -## The runner image - -K8s requires an `image` field on every container. Since Niphas mounts -the actual binary from the CSI volume, the container image is just a -stub that provides a minimal Linux userspace. - -`niphas-runner` is a scratch-like image (~1MB) that provides: -- `/lib64/ld-linux-x86-64.so.2` (dynamic linker, from Nix) -- Basic `/etc` files (passwd, group, nsswitch.conf) -- Nothing else. No shell, no package manager. - -For statically linked Nix binaries (musl), even this is unnecessary -and a true `FROM scratch` (0 bytes) image works. - -## Command resolution - -When `spec.command` is omitted: - -1. Eval resolves `meta.mainProgram` from the derivation -2. If set: uses `${storePath}/bin/${mainProgram}` -3. If not: lists `$out/bin/`, picks the single binary -4. If ambiguous (multiple binaries): eval fails with clear error -5. Resolved command stored in status for observability - -## Updates and rollouts - -When the user changes `flakeRef`, `attribute`, or `revision`: - -1. `metadata.generation` increments -2. Operator detects `observedGeneration < generation` -3. Re-triggers eval (steps 2-6) -4. If store path changed: updates Deployment CSI volumeAttributes + command -5. K8s performs standard rolling update -6. Old pods drain, new pods mount new closure -7. Canary/blue-green strategies work as normal - -## Multi-architecture - -For mixed clusters (amd64 + arm64), use the `{arch}` template: - -```yaml -spec: - flakeRef: "github:myorg/myapp" - attribute: "packages.{arch}-linux.default" - architectures: - - x86_64 - - aarch64 - replicas: 6 -``` - -The operator: -1. Evaluates per architecture (two in-process eval calls via Nix C API) -2. Creates two Deployments (3 replicas each) with `nodeSelector` -3. Both share the label `niphas.io/workload: myapp` -4. Single Service selects both via the shared label -5. Load balances across architectures transparently - -Status: -```yaml -status: - architectures: - - arch: x86_64 - storePath: "/nix/store/abc123-myapp-1.0.0" - readyReplicas: 3 - - arch: aarch64 - storePath: "/nix/store/xyz789-myapp-1.0.0" - readyReplicas: 3 - readyReplicas: 6 -``` - -## Helm chart for user workloads - -Users in Helm-based workflows can deploy via a convenience chart: - -```bash -helm install myapp niphas/workload \ - --set flakeRef=github:myorg/myapp \ - --set attribute=packages.x86_64-linux.default \ - --set replicas=3 \ - --set ports[0].name=http \ - --set ports[0].containerPort=8080 \ - --set service.enabled=true \ - --set service.ports[0].port=80 \ - --set service.ports[0].targetPort=http \ - --set ingress.enabled=true \ - --set ingress.hosts[0].host=myapp.example.com -``` - -Or with a values file: - -```yaml -# myapp-values.yaml -flakeRef: "github:myorg/myapp" -attribute: "packages.x86_64-linux.default" -revision: "a1b2c3d" -replicas: 3 -ports: - - name: http - containerPort: 8080 -resources: - requests: - cpu: "250m" - memory: "256Mi" -service: - enabled: true - ports: - - name: http - port: 80 - targetPort: http -ingress: - enabled: true - className: nginx - hosts: - - host: myapp.example.com - paths: - - path: / - port: http -``` - -```bash -helm install myapp niphas/workload -f myapp-values.yaml -``` - -The chart generates a NiphasWorkload CR from the values. - -## Helm chart for Niphas platform - -Installing Niphas itself: - -```bash -# 1. Install the platform -helm install niphas niphas/niphas -n niphas-system --create-namespace - -# 2. Label nodes that should run Niphas workloads -kubectl label node worker-{1..5} niphas.io/store=true - -# 3. Optional: enable mesh for P2P NAR sharing -kubectl label node worker-{1..5} niphas.io/mesh=true -``` - -With custom binary cache and mesh disabled: - -```bash -helm install niphas niphas/niphas -n niphas-system --create-namespace \ - --set csi.binaryCaches[0].url=https://cache.company.com \ - --set csi.binaryCaches[0].publicKey="company-1:abc..." \ - --set mesh.enabled=false -``` - -The platform chart deploys: operator (Deployment), CSI driver (DaemonSet -with `nodeSelector: niphas.io/store=true`), mesh (DaemonSet with -`nodeSelector: niphas.io/mesh=true`, optional via `mesh.enabled`), -eval webhook (Deployment), CRDs, RBAC, NetworkPolicies, PriorityClasses. - -Nodes must be labeled before workloads can be scheduled on them: - -```bash -kubectl label node worker-1 worker-2 worker-3 niphas.io/store=true -# Optional: enable mesh for P2P NAR sharing between nodes -kubectl label node worker-1 worker-2 worker-3 niphas.io/mesh=true -``` - -## GitOps integration - -NiphasWorkload CRDs work with ArgoCD and Flux out of the box. - -ArgoCD health check (add to argocd-cm ConfigMap): - -```lua --- resource.customizations.health.niphas.io_NiphasWorkload -hs = {} -if obj.status ~= nil then - if obj.status.phase == "Running" then - hs.status = "Healthy" - elseif obj.status.phase == "Failed" then - hs.status = "Degraded" - else - hs.status = "Progressing" - end - hs.message = "Phase: " .. (obj.status.phase or "Unknown") -end -return hs -``` - -Flux uses `observedGeneration` from the status to detect drift -(already implemented in the CRD). - -## Service exposure - -### Embedded (simple case) - -The CRD includes `spec.service` and `spec.ingress`. The operator -creates both with `ownerReferences`. Covers 80% of use cases. - -### Standalone (advanced case) - -For Gateway API, multiple services, or service mesh routing, the user -creates standard K8s resources that select pods via label: - -```yaml -selector: - niphas.io/workload: myapp -``` - -The operator labels all generated pods with `niphas.io/workload: `, -making them selectable by any K8s networking resource. diff --git a/EVAL.md b/EVAL.md deleted file mode 100644 index 1e0ab31..0000000 --- a/EVAL.md +++ /dev/null @@ -1,544 +0,0 @@ -# Niphas -- Eval Webhook Design - -niphas-eval is an HTTP webhook that evaluates Nix flakes via the Nix C API -(in-process FFI) and resolves closures via binary cache HTTP. It is called -by the operator during reconciliation. - -## Architecture - -``` -operator niphas-eval binary cache - | | | - | POST /evaluate | | - | {flakeRef, attribute} | | - |------------------------->| | - | | | - | | 1. validate flakeRef | - | | against allowlist | - | | | - | | 2. nix eval via C FFI | - | | (in-process, no Jobs) | - | | -> outPath, name, | - | | mainProgram | - | | | - | | 3. resolve closure | - | | GET /.narinfo ---->| - | | parse References <----| - | | (recursive, parallel) | - | | | - | {storePath, closure} | | - |<-------------------------| | -``` - -niphas-eval runs as a Deployment (not DaemonSet). Typically 2 replicas -for availability. Stateless except for the Nix eval cache (`/nix/store` -on a PVC or hostPath). - -## HTTP API - -### POST /evaluate - -Request: - -```json -{ - "flakeRef": "github:myorg/myapp", - "attribute": "packages.x86_64-linux.default", - "revision": "a1b2c3d4e5f6", - "binaryCache": "https://cache.company.com" -} -``` - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `flakeRef` | string | yes | Flake reference | -| `attribute` | string | yes | Output attribute path | -| `revision` | string | no | Git revision to pin | -| `binaryCache` | string | no | Override binary cache URL | - -Response (success, 200): - -```json -{ - "storePath": "/nix/store/abc123-myapp-1.0.0", - "name": "myapp-1.0.0", - "mainProgram": "myapp", - "closurePaths": [ - "/nix/store/abc123-myapp-1.0.0", - "/nix/store/def456-glibc-2.38", - "/nix/store/ghi789-openssl-3.1" - ] -} -``` - -| Field | Type | Description | -|-------|------|-------------| -| `storePath` | string | Resolved output path | -| `name` | string | Derivation name (from `drv.name`) | -| `mainProgram` | string or null | From `meta.mainProgram`. Used for command auto-detection | -| `closurePaths` | Vec | Full transitive closure (root + all references) | - -Response (error, 4xx/5xx): - -```json -{ - "error": "FlakeNotAllowed", - "message": "github:evil/repo not in allowlist" -} -``` - -| HTTP Status | Error code | Meaning | -|-------------|-----------|---------| -| 403 | `FlakeNotAllowed` | flakeRef not in allowlist | -| 422 | `EvalFailed` | Nix evaluation error | -| 408 | `EvalTimeout` | Evaluation exceeded timeout | -| 404 | `StorePathNotCached` | NAR not found in any binary cache | -| 502 | `ClosureResolutionFailed` | Could not resolve all closure paths | -| 500 | `InternalError` | Unexpected error | - -### GET /healthz - -Returns 200 if the process is running and the Nix evaluator is initialized. - -### GET /readyz - -Returns 200 if the evaluator has completed at least one successful eval -(warm cache). Returns 503 during cold start. - -## Nix C FFI integration - -niphas-eval links against the Nix C libraries (`libexpr-c`, `libstore-c`, -`libflake-c`) at build time via `nix-bindings-rust`. The evaluator runs -in-process, not in a separate process or container. - -### Lifecycle - -```rust -// On startup (once) -let nix_ctx = nix::Context::new()?; -let store = nix::Store::open(&nix_ctx, None)?; - -// Configure evaluator settings -let mut settings = nix::EvalSettings::default(); -settings.sandbox = true; -settings.restrict_eval = true; -settings.allowed_uris = config.allowed_uris.clone(); -settings.allow_import_from_derivation = false; -settings.max_jobs = 0; - -let state = nix::EvalStateBuilder::new(&store)? - .with_settings(&settings) - .build()?; - -// Per-request (shared state, concurrent via Arc) -let state = Arc::new(state); -``` - -The `EvalState` is initialized once and shared across requests. The Nix -C API is internally thread-safe for evaluation (each eval call acquires -internal locks). Concurrent evaluations of different flakes are safe. - -### Evaluation call - -```rust -fn evaluate( - state: &EvalState, - flake_ref: &str, - attribute: &str, - revision: Option<&str>, -) -> Result { - // Construct flake reference with optional revision - let pinned_ref = match revision { - Some(rev) => format!("{}/{}", flake_ref, rev), - None => flake_ref.to_string(), - }; - - // Build Nix expression that extracts what we need - let expr = format!( - r#"let drv = (builtins.getFlake "{}").{}; in {{ - outPath = drv.outPath; - name = drv.name; - mainProgram = drv.meta.mainProgram or null; - }}"#, - pinned_ref, attribute - ); - - // Evaluate with timeout - let value = tokio::time::timeout( - Duration::from_secs(300), - tokio::task::spawn_blocking(move || { - state.eval_from_string(&expr, "") - }), - ).await - .map_err(|_| EvalError::Timeout)??; - - let attrs = value.require_attrs()?; - - Ok(EvalResult { - store_path: attrs.get("outPath")?.require_string()?, - name: attrs.get("name")?.require_string()?, - main_program: attrs.get("mainProgram")?.as_string().ok(), - }) -} -``` - -### Why spawn_blocking - -Nix evaluation is CPU-bound and blocks the thread. Running it on -tokio's blocking thread pool prevents stalling the async runtime. -The eval call can take 1-15 seconds for cold evaluations. - -### Eval settings - -| Setting | Value | Rationale | -|---------|-------|-----------| -| `sandbox` | `true` | Restrict filesystem access during eval | -| `restrict_eval` | `true` | Only allow access to explicitly allowed URIs | -| `allowed_uris` | `["https://github.com", "https://cache.nixos.org"]` | Configurable via ConfigMap | -| `allow_import_from_derivation` | `false` | **Critical**: blocks IFD (arbitrary code execution during eval) | -| `max_jobs` | `0` | No builds, eval only. Even if IFD is somehow bypassed, no builder is available | - -See [`SECURITY_DESIGN.md`](SECURITY_DESIGN.md) for the full 4-layer -defense model. - -## Flake allowlist - -Before evaluation, the webhook validates the flakeRef against a -deny-by-default allowlist. - -```rust -fn validate_flake_ref(flake_ref: &str, allowlist: &[String]) -> Result<(), EvalError> { - for pattern in allowlist { - if matches_glob(flake_ref, pattern) { - return Ok(()); - } - } - Err(EvalError::FlakeNotAllowed(flake_ref.into())) -} -``` - -### Glob matching rules - -| Pattern | Matches | Does not match | -|---------|---------|----------------| -| `github:myorg/*` | `github:myorg/myapp`, `github:myorg/lib` | `github:other/repo` | -| `github:nixos/nixpkgs` | `github:nixos/nixpkgs` | `github:nixos/nix` | -| `github:*/*` | any GitHub flake | `path:/local/flake` | - -### Config - -```yaml -# niphas-eval ConfigMap -apiVersion: v1 -kind: ConfigMap -metadata: - name: niphas-eval-config - namespace: niphas-system -data: - config.yaml: | - allowedFlakeOrigins: - - "github:myorg/*" - - "github:nixos/nixpkgs" - evalTimeout: 300s - allowedUris: - - "https://github.com" - - "https://cache.nixos.org" - binaryCaches: - - url: "https://cache.company.com" - publicKey: "cache.company.com-1:..." - - url: "https://cache.nixos.org" - publicKey: "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" - closureResolution: - concurrency: 32 - timeout: 60s -``` - -## Closure resolution - -After evaluation, niphas-eval resolves the full transitive closure by -walking `.narinfo` files from the binary cache. This is pure HTTP -- -no Nix needed. - -### Algorithm - -```rust -async fn resolve_closure( - cache: &CacheClient, - root_path: &str, -) -> Result, EvalError> { - let mut closure = Vec::new(); - let mut visited = HashSet::new(); - let mut queue = VecDeque::new(); - - queue.push_back(root_path.to_string()); - visited.insert(root_path.to_string()); - - while let Some(path) = queue.pop_front() { - // Fetch .narinfo for this path - let hash = store_path_hash(&path)?; - let narinfo = cache.fetch_narinfo(&hash).await?; - - closure.push(path); - - // Add unvisited references to queue - for reference in &narinfo.references { - let ref_path = format!("/nix/store/{}", reference); - if visited.insert(ref_path.clone()) { - queue.push_back(ref_path); - } - } - } - - Ok(closure) -} -``` - -### Parallel resolution - -The BFS is parallelized: multiple `.narinfo` fetches happen concurrently -(up to `closureResolution.concurrency`, default 32). - -```rust -async fn resolve_closure_parallel( - cache: &CacheClient, - root_path: &str, - concurrency: usize, -) -> Result, EvalError> { - let mut closure = Vec::new(); - let mut visited = HashSet::new(); - let mut pending = FuturesUnordered::new(); - - // Seed with root - visited.insert(root_path.to_string()); - pending.push(fetch_and_parse(cache, root_path)); - - while let Some(result) = pending.next().await { - let (path, narinfo) = result?; - closure.push(path); - - for reference in &narinfo.references { - let ref_path = format!("/nix/store/{}", reference); - if visited.insert(ref_path.clone()) { - if pending.len() < concurrency { - pending.push(fetch_and_parse(cache, &ref_path)); - } - } - } - } - - Ok(closure) -} -``` - -### What if root .narinfo returns 404? - -The store path was evaluated but never built (or not pushed to the -binary cache). This is the `StorePathNotCached` error: - -``` -The workload cannot be deployed. Build in CI and push to binary cache first. - - nix build .#packages.x86_64-linux.default - nix copy --to https://cache.company.com ./result -``` - -The operator sets: -```yaml -status: - phase: Failed - conditions: - - type: Evaluated - status: "False" - reason: StorePathNotCached - message: "/nix/store/abc123-myapp-1.0.0 not found in any binary cache" -``` - -## Eval cache - -The Nix evaluator maintains its own internal cache: - -| Cache | Location | Contents | -|-------|----------|----------| -| Eval cache | `/nix/store` (PVC) | Fetched flake inputs, evaluated derivations | -| Git cache | `~/.cache/nix/` | Cloned git repos (flake sources) | - -### Performance from cache - -| Scenario | Time | Why | -|----------|------|-----| -| First eval of a new flake | 5-15s | Fetches flake source + inputs from git | -| Same flake, new revision | 1-3s | Inputs cached, re-evaluates expression | -| Same flake, same revision | <100ms | Eval cache hit | -| Redeploy (no spec change) | skipped | Operator checks `observedGeneration == generation` | - -The eval cache persists across pod restarts via PVC. Cold start (first -eval ever in a fresh pod) fetches nixpkgs and inputs -- this is expected -and documented in startup logs. - -### Cache sizing - -The eval cache grows as new flakes are evaluated. nixpkgs alone is ~1 GB -in the eval cache. For most deployments, 10-20 GB PVC is sufficient. -The Nix evaluator handles its own GC internally. - -## Concurrency model - -niphas-eval uses Axum with tokio multi-thread runtime: - -```rust -#[tokio::main] -async fn main() -> Result<()> { - // Initialize tracing - niphas_core::telemetry::init_tracing(); - - // Load config - let config = Config::load()?; - - // Initialize Nix evaluator (once) - let evaluator = Arc::new(Evaluator::new(&config)?); - - // Build router - let app = Router::new() - .route("/evaluate", post(handlers::evaluate)) - .route("/healthz", get(handlers::healthz)) - .route("/readyz", get(handlers::readyz)) - .layer(Extension(evaluator)) - .layer(Extension(config)); - - // Serve - let addr = SocketAddr::from(([0, 0, 0, 0], 8443)); - let listener = TcpListener::bind(addr).await?; - axum::serve(listener, app).await?; - - Ok(()) -} -``` - -### Concurrent eval requests - -Multiple eval requests can arrive simultaneously (e.g. operator creates -multiple NiphasWorkloads). Each request runs on its own tokio task: - -1. Allowlist check: instant, no blocking -2. Nix eval: `spawn_blocking` (CPU-bound, runs on blocking thread pool) -3. Closure resolution: async HTTP calls (concurrent `.narinfo` fetches) - -The Nix C API's `EvalState` is behind an `Arc`. The C API internally -serializes evaluation (one eval at a time via internal mutex). This -means concurrent eval requests are queued at the C level. For true -parallelism, niphas-eval would need multiple `EvalState` instances -- -this is a future optimization. - -### Request timeout - -Each handler wraps the evaluation in a tokio timeout: - -```rust -async fn evaluate( - Extension(evaluator): Extension>, - Json(req): Json, -) -> Result, AppError> { - let result = tokio::time::timeout( - evaluator.config.eval_timeout, - evaluator.evaluate(&req), - ).await - .map_err(|_| AppError::EvalTimeout)? - .map_err(AppError::from)?; - - Ok(Json(result)) -} -``` - -## Deployment - -```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: niphas-eval - namespace: niphas-system -spec: - replicas: 2 - selector: - matchLabels: - app: niphas-eval - template: - metadata: - labels: - app: niphas-eval - spec: - serviceAccountName: niphas-eval - securityContext: - runAsNonRoot: true - runAsUser: 65534 - seccompProfile: - type: RuntimeDefault - containers: - - name: niphas-eval - image: ghcr.io/fullzer4/niphas-eval:latest - ports: - - containerPort: 8443 - name: webhook - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - capabilities: - drop: ["ALL"] - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "2" - memory: "4Gi" - volumeMounts: - - name: nix-store - mountPath: /nix/store - - name: eval-config - mountPath: /etc/niphas - readOnly: true - livenessProbe: - httpGet: - path: /healthz - port: webhook - initialDelaySeconds: 10 - readinessProbe: - httpGet: - path: /readyz - port: webhook - initialDelaySeconds: 30 # cold start may take time - volumes: - - name: nix-store - persistentVolumeClaim: - claimName: niphas-eval-store - - name: eval-config - configMap: - name: niphas-eval-config ---- -apiVersion: v1 -kind: Service -metadata: - name: niphas-eval - namespace: niphas-system -spec: - selector: - app: niphas-eval - ports: - - port: 8443 - targetPort: webhook -``` - -### Why PVC for /nix/store - -The Nix eval cache (fetched flake inputs, evaluated derivations) must -persist across pod restarts. Without PVC, every restart triggers a cold -eval (fetch nixpkgs from git, ~5-15 seconds). With PVC, warm evals -take <100ms. - -The PVC can be `ReadWriteOnce` -- only one pod writes at a time (the -Nix evaluator serializes writes internally). If running 2 replicas, -each gets its own PVC (`volumeClaimTemplates` in a StatefulSet) or -they share via `ReadWriteMany` if the storage class supports it. - -Alternative: hostPath at `/var/lib/niphas/eval-cache/`. Simpler but -ties the eval pod to a specific node. PVC is preferred for portability. diff --git a/MESH_PROTOCOL.md b/MESH_PROTOCOL.md deleted file mode 100644 index aef6d94..0000000 --- a/MESH_PROTOCOL.md +++ /dev/null @@ -1,688 +0,0 @@ -# Niphas -- Mesh Protocol Design - -## Overview - -niphas-mesh is an **optional** DaemonSet that runs on nodes labeled with -`niphas.io/store=true`. It provides P2P distribution of Nix store paths -(NARs) between Niphas-enabled nodes, accelerating closure fetching via -LAN-speed peer transfers instead of WAN binary cache HTTP. - -The mesh is NOT required. Without it, the CSI driver fetches directly from -binary cache HTTP. The mesh is an optimization for: - -- **Scale**: 100+ nodes fetching the same NAR simultaneously would overwhelm - a single binary cache server. P2P distributes the load. -- **Speed**: LAN peer fetch (~1 GB/s) vs WAN binary cache (~100 MB/s). -- **Resilience**: If the binary cache goes down, peers serve cached NARs. - -**No full replication.** Each node only caches the NARs its pods actually -use. The mesh enables fast peer-to-peer fetch on cache miss, not cluster-wide -synchronization. - -## Selective Deployment - -### Deployer controls via node labels - -```bash -# Mark nodes that should run Niphas workloads -kubectl label node worker-1 niphas.io/store=true -kubectl label node worker-2 niphas.io/store=true - -# Nodes without the label: zero Niphas overhead -# worker-3 through worker-100: no CSI, no mesh, no cache -``` - -### DaemonSet nodeSelector - -```yaml -# CSI DaemonSet -- only on labeled nodes -apiVersion: apps/v1 -kind: DaemonSet -metadata: - name: niphas-csi - namespace: niphas-system -spec: - selector: - matchLabels: - app: niphas-csi - template: - spec: - nodeSelector: - niphas.io/store: "true" - # ... - ---- -# Mesh DaemonSet -- only on labeled nodes, optional -apiVersion: apps/v1 -kind: DaemonSet -metadata: - name: niphas-mesh - namespace: niphas-system -spec: - selector: - matchLabels: - app: niphas-mesh - template: - spec: - nodeSelector: - niphas.io/store: "true" - # ... -``` - -### Resource usage - -| Node type | CSI pod | Mesh pod | Cache | RAM overhead | -|-----------|---------|----------|-------|-------------| -| `niphas.io/store=true` | Running (~10 MB idle) | Running (~30-50 MB idle) | Grows with usage | ~40-60 MB | -| No label | None | None | None | 0 | - -For comparison: calico-node uses 150-256 MB, Cilium uses 330-430 MB. - -### Mesh is optional - -```yaml -# Helm values -mesh: - enabled: true # false = CSI-only, binary cache HTTP - nodeSelector: - niphas.io/store: "true" - resources: - requests: - cpu: "50m" - memory: "64Mi" - limits: - cpu: "500m" - memory: "256Mi" -``` - -When `mesh.enabled: false`, the CSI driver fetches exclusively from binary -cache HTTP. For small clusters with a fast self-hosted binary cache -(Attic/harmonia on the same LAN), this is sufficient. - -## Architecture - -``` - Nodes with niphas.io/store=true - +--------------------------------------------------+ - | | - | +------------------+ +---------------------+ | - | | niphas-csi | | niphas-mesh | | - | | (DaemonSet) | | (DaemonSet,optional)| | - | | | | | | - | | NodePublishVolume| | Gossipsub | | - | | NodeUnpublish | | Request-Response | | - | | NAR verification | | libp2p_stream | | - | +--------+---------+ +----------+----------+ | - | | | | - | +-------+ +------------+ | - | | | | - | +-------v---v--------+ | - | | /var/lib/niphas/ | | - | | cache/ | | - | | (hostPath, shared) | | - | +--------------------+ | - +--------------------------------------------------+ - - Nodes WITHOUT label - +--------------------------------------------------+ - | No Niphas pods. Zero overhead. | - +--------------------------------------------------+ -``` - -## No Leader Election - -The mesh has **no leader**. Every mesh pod is equal. Each node acts -autonomously based on local state: - -- What it has in its local cache -- What gossipsub tells it peers have (NAR index) -- What the CSI driver requests - -This works because NARs are content-addressed and immutable. Two nodes -pushing the same NAR to a third is idempotent -- deduplicated by hash. - -The operator has leader election (Lease-based, for CRD reconciliation), -but the mesh layer is fully decentralized. - -## libp2p Stack - -### Behaviour - -```rust -use libp2p::{ - tcp, quic, noise, yamux, - identify, gossipsub, request_response, - swarm::NetworkBehaviour, -}; - -#[derive(NetworkBehaviour)] -struct MeshBehaviour { - identify: identify::Behaviour, - gossipsub: gossipsub::Behaviour, - /// Control plane: HasNar, QueryNarInfo - control: request_response::cbor::Behaviour, - /// Data plane: raw streaming for bulk NAR transfers - transfer: libp2p_stream::Behaviour, -} -``` - -| Layer | Choice | Why | -|-------|--------|-----| -| Transport | TCP + QUIC | TCP for reliability, QUIC for low-latency (UDP) | -| Encryption | Noise XX | Mutual auth, PFS, no TLS cert management | -| Multiplexing | Yamux | Standard for libp2p Rust | -| Announcements | Gossipsub | Pubsub Have/Evicted, peers learn what's available | -| Content index | Local HashMap | O(1) lookup: "who has NAR X?" | -| Control | Request-Response (CBOR) | HasNar, QueryNarInfo | -| Data transfer | libp2p_stream | Raw AsyncRead/AsyncWrite, zero-copy streaming | -| Identity | Identify | Exchange peer metadata (version, zone, etc.) | - -### Port - -Single port: `4001` (TCP + QUIC). Same as IPFS convention. - -### Gossipsub Config - -```rust -// Tuned for K8s clusters. Matches Ethereum beacon chain production -// config (validated at 100k+ validators). -let gossipsub_config = gossipsub::ConfigBuilder::default() - .heartbeat_interval(Duration::from_millis(700)) - .mesh_n(8) // target mesh degree - .mesh_n_low(6) // minimum before grafting - .mesh_n_high(12) // maximum before pruning - .history_length(6) // IHAVE history for reliability - .history_gossip(3) // gossip to D_lazy peers - .max_transmit_size(65536) // gossip messages are small (~100 bytes) - .build() - .unwrap(); -``` - -## Protocol Messages - -### Gossipsub (announcements) - -Single topic: `niphas/nar/v1` - -```rust -#[derive(Debug, Clone, Serialize, Deserialize)] -enum GossipMessage { - /// Peer has a new NAR available in its local cache. - Have { nar_hash: String, store_path: String, nar_size: u64 }, - /// Peer evicted a NAR from local cache (GC). - Evicted { nar_hash: String }, -} -``` - -When a message arrives: -- `Have`: add peer to NAR index -- `Evicted`: remove peer from NAR index for that hash -- Peer disconnects: remove all entries for that peer - -### Control plane (Request-Response CBOR) - -```rust -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum MeshRequest { - /// Check if peer has a NAR. - HasNar { nar_hash: String }, - - /// Query narinfo for a store path. - QueryNarInfo { store_path_hash: String }, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum MeshResponse { - HasNar { available: bool, nar_size: u64 }, - NarInfo { narinfo: String }, - NotFound, - Busy, -} -``` - -### Data plane (libp2p_stream) - -NAR transfers use raw substreams via `libp2p_stream::Behaviour`. - -Stream protocol ID: `/niphas/nar-transfer/1` - -```rust -/// Sent at the start of a transfer substream. -struct TransferHeader { - nar_hash: [u8; 32], - offset: u64, // for parallel range requests (0 = from start) - length: u64, // 0 = send everything from offset to end -} - -/// Header is 48 bytes, fixed layout, big-endian. -/// After the header, sender streams raw compressed NAR bytes until -/// `length` bytes are sent, then closes the substream write half. -``` - -The receiver: -1. Opens substream, sends `TransferHeader` -2. Sender streams compressed NAR bytes directly from disk (zero-copy via mmap) -3. Receiver writes to temp file, verifies NarHash after all bytes received -4. If valid: moves to cache, announces via gossipsub `Have` -5. If invalid: discards temp file, logs warning - -### Parallel range download (swarming) - -For NARs > 64 MB, the receiver can open substreams to **multiple peers** -simultaneously, each requesting a different byte range: - -``` -NAR = 256 MB, 4 peers available - -Peer A: offset=0, length=64MB -Peer B: offset=64MB, length=64MB -Peer C: offset=128MB,length=64MB -Peer D: offset=192MB,length=64MB - -All download in parallel -> ~4x throughput -Receiver assembles chunks, verifies NarHash of the whole file -``` - -For NARs < 64 MB (the vast majority), single-peer transfer is sufficient. - -## NAR Index - -Each mesh node maintains a local HashMap tracking which peers have which -NARs. Populated entirely from gossipsub messages. - -```rust -use std::collections::{HashMap, HashSet}; - -struct NarIndex { - index: HashMap>, -} - -impl NarIndex { - /// O(1) lookup, zero network latency. - fn providers(&self, nar_hash: &str) -> Vec { - self.index.get(nar_hash) - .map(|peers| peers.iter().copied().collect()) - .unwrap_or_default() - } - - fn add(&mut self, nar_hash: &str, peer: PeerId) { - self.index.entry(nar_hash.to_string()).or_default().insert(peer); - } - - fn remove_peer(&mut self, peer: &PeerId) { - for peers in self.index.values_mut() { - peers.remove(peer); - } - self.index.retain(|_, peers| !peers.is_empty()); - } -} -``` - -Memory: only tracks NARs that at least one peer announced. With no full -replication, each peer only announces what it cached for its pods. A node -running 20 microservices with ~3000 unique store paths: -~3000 entries * ~80 bytes = ~240 KB. Negligible. - -## Peer Discovery - -### Bootstrap: Headless Service DNS - -```yaml -apiVersion: v1 -kind: Service -metadata: - name: niphas-mesh - namespace: niphas-system -spec: - clusterIP: None - selector: - app: niphas-mesh - ports: - - port: 4001 - name: libp2p -``` - -On startup, resolve `niphas-mesh.niphas-system.svc.cluster.local` -to get all mesh pod IPs. Dial each one. - -```rust -async fn bootstrap_peers(service_dns: &str) -> Vec { - let addrs = tokio::net::lookup_host(format!("{}:4001", service_dns)).await?; - addrs.map(|addr| { - format!("/ip4/{}/tcp/4001", addr.ip()).parse().unwrap() - }).collect() -} -``` - -### Dynamic: K8s API Watch - -Watch mesh pods for real-time membership changes + node labels. - -```rust -async fn watch_mesh_pods(client: kube::Client) { - let pods: Api = Api::namespaced(client, "niphas-system"); - let params = ListParams::default().labels("app=niphas-mesh"); - let mut stream = watcher(pods, params).boxed(); - - while let Some(event) = stream.next().await { - match event? { - Event::Applied(pod) => { - let ip = pod.status?.pod_ip?; - let node = pod.spec?.node_name?; - let zone = get_node_label(&node, "topology.kubernetes.io/zone"); - peer_registry.add(PeerInfo { ip, node, zone, .. }); - } - Event::Deleted(pod) => { - peer_registry.remove(pod.metadata.name); - } - _ => {} - } - } -} -``` - -## CSI Interface - -The CSI driver communicates with the local mesh pod via Unix socket. - -### Socket path - -``` -/var/run/niphas/mesh.sock -``` - -Shared via a hostPath volume between the CSI DaemonSet and mesh DaemonSet. - -### Protocol (length-prefixed JSON over UDS) - -```rust -/// CSI -> Mesh requests -enum CsiRequest { - /// Fetch a NAR. Mesh tries peers, returns path or error. - FetchNar { - store_path: String, - nar_hash: String, - nar_size: u64, - }, - /// Check if a NAR is available on any peer. - HasNar { - nar_hash: String, - }, -} - -/// Mesh -> CSI responses -enum CsiResponse { - /// NAR fetched from peer and now available locally. - Fetched { cache_path: String }, - /// NAR not available from any mesh peer. - NotFound, - /// Error during fetch. - Error { message: String }, -} -``` - -### Fetch flow (CSI perspective) - -``` -1. CSI checks local cache (/var/lib/niphas/cache/-/) - --> if exists: mount directly, done (common case after first run) - -2. CSI checks if mesh socket exists (/var/run/niphas/mesh.sock) - --> if no mesh running: skip to step 5 - -3. CSI asks mesh via UDS: FetchNar { store_path, nar_hash, nar_size } - -4. Mesh queries local NAR index: nar_index.providers(hash) - --> if found: fetch from best peer (same zone preferred) - --> verify signature + NarHash - --> write to local cache - --> publish gossipsub Have { ... } - --> respond Fetched { cache_path } - --> if not found: respond NotFound - -5. CSI falls back to binary cache HTTP (direct fetch) - --> uses CacheClient from niphas-core - --> verify signature + NarHash - --> write to local cache - -6. If all sources fail: - --> return gRPC Unavailable to kubelet - --> kubelet retries with backoff -``` - -The CSI driver does NOT depend on the mesh. If `mesh.sock` does not exist -(mesh disabled or not yet started), CSI goes directly to binary cache HTTP. - -## Cache Management - -### Per-node, lazy cache - -Each node only caches NARs that its pods actually used. No proactive -replication, no background sync. - -``` -/var/lib/niphas/cache/ - abc123-hello-2.12.1/ # extracted, used by a local pod - def456-glibc-2.38/ # dependency of hello, also cached - .tmp-xyz789/ # in-progress extraction -``` - -Cache grows as workloads are deployed. Shared dependencies (glibc, -coreutils, etc.) are fetched once and reused by all subsequent closures. - -### Garbage collection (per-node, autonomous) - -No cluster coordination needed. Each node manages its own cache with -simple LRU eviction. - -```rust -struct GcPolicy { - /// Start evicting when disk usage exceeds this - high_watermark_percent: u8, // default: 85 - /// Stop evicting when disk drops below this - low_watermark_percent: u8, // default: 75 -} - -fn gc_local(cache: &mut Cache, policy: &GcPolicy) -> Result<()> { - while cache.disk_usage_percent() > policy.low_watermark_percent { - // Get LRU NAR that is NOT mounted by a local pod - let candidate = cache.lru_unmounted()?; - cache.evict(&candidate.nar_hash)?; - - // Announce eviction so peers remove us from their NAR index - gossipsub.publish("niphas/nar/v1", GossipMessage::Evicted { - nar_hash: candidate.nar_hash.clone(), - }); - } - Ok(()) -} -``` - -**Invariant:** never evict a NAR that is mounted by a local pod (CSI -ref-counting prevents this). - -### Cache config (deployer-controlled) - -```yaml -# ConfigMap: niphas-config -data: - config.yaml: | - cache: - path: /var/lib/niphas/cache # overridable for dedicated disk - highWatermarkPercent: 85 - lowWatermarkPercent: 75 - binaryCaches: - - url: "https://cache.company.com" - priority: 1 - publicKey: "cache.company.com-1:..." - - url: "https://cache.nixos.org" - priority: 2 - publicKey: "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" - mesh: - bandwidthLimitMbps: 500 - peerFetchTimeout: 5s -``` - -## Bandwidth Control - -### Per-peer rate limiting - -```rust -use governor::{Quota, RateLimiter}; - -struct BandwidthController { - /// Global outbound rate limit - global: RateLimiter<...>, - /// Per-peer outbound rate limit - per_peer: DashMap>, -} -``` - -### Priority queue - -```rust -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] -enum TransferPriority { - CsiMount = 0, // highest: pod waiting for volume - PeerRequest = 1, // another node's CSI needs this NAR -} - -struct TransferQueue { - queue: BinaryHeap, - semaphore: Semaphore, // limit concurrent transfers (e.g. 8) -} -``` - -### Connection limits - -```rust -let swarm = SwarmBuilder::with_existing_identity(keypair) - .with_tokio() - .with_tcp(...) - .with_quic() - .with_behaviour(|key| MeshBehaviour::new(key))? - .with_swarm_config(|cfg| { - cfg.with_max_negotiating_inbound_streams(128) - .with_idle_connection_timeout(Duration::from_secs(60)) - }) - .build(); -``` - -Each node does NOT connect to all peers. Gossipsub maintains D=8 mesh -connections. Transfer streams open on-demand and close after transfer. - -## Authentication - -### Noise XX Handshake - -Every connection uses libp2p Noise XX: -- Both peers prove possession of their Ed25519 identity key -- Ephemeral Curve25519 keys per session (PFS) -- Post-handshake encryption: ChaCha20-Poly1305 - -### Closed Mesh (PeerId Allowlist) - -Only known cluster members can connect. The peer registry (from K8s API -watch) provides the allowlist. - -```rust -impl MeshBehaviour { - fn handle_new_connection(&mut self, peer_id: &PeerId) -> Result<(), ConnectionDenied> { - if !self.peer_registry.contains(peer_id) { - tracing::warn!(%peer_id, "rejected connection from unknown peer"); - return Err(ConnectionDenied::new("peer not in cluster")); - } - Ok(()) - } -} -``` - -### Key Generation - -Each mesh pod generates an Ed25519 keypair on first boot and stores it -in a K8s Secret (one per node): - -```yaml -apiVersion: v1 -kind: Secret -metadata: - name: niphas-mesh-identity- - namespace: niphas-system -type: Opaque -data: - private-key: -``` - -The PeerId (derived from the public key) is published as a pod annotation: - -```yaml -metadata: - annotations: - niphas.io/peer-id: "12D3KooW..." -``` - -### Key Rotation - -1. Delete the Secret -2. Restart the mesh pod -3. Pod generates new keypair, publishes new PeerId -4. Other pods see updated annotation via K8s API watch -5. Old PeerId expires from allowlist - -## Peer Metadata - -```rust -struct PeerMetadata { - zone: String, - hostname: String, - available_disk: u64, - load_percent: u8, - cached_paths: u32, - version: String, -} -``` - -Exchanged via the Identify protocol (periodic, every 5 minutes). -Used for peer selection (same-zone preference, least-loaded fallback). - -## Observability - -### Metrics (Prometheus) - -``` -niphas_mesh_peers_connected gauge # connected mesh peers -niphas_mesh_nar_fetch_total counter # fetches by source (cache/peer/http) -niphas_mesh_nar_fetch_bytes_total counter # bytes fetched -niphas_mesh_nar_fetch_duration_seconds histogram # fetch latency -niphas_mesh_nar_serve_total counter # NARs served to other peers -niphas_mesh_gossip_messages_total counter # gossipsub messages (have/evicted) -niphas_mesh_bandwidth_bytes_total counter # total bandwidth (in/out) -niphas_mesh_cache_size_bytes gauge # local cache disk usage -niphas_mesh_cache_paths gauge # cached store paths -niphas_mesh_gc_evictions_total counter # NARs evicted by GC -``` - -### Structured logs - -``` -level=info msg="NAR fetched from peer" store_path=/nix/store/abc123-hello nar_hash=sha256:... source_peer=12D3KooW... zone=us-east-1a bytes=12345 duration_ms=42 -level=info msg="NAR served to peer" nar_hash=sha256:... target_peer=12D3KooW... bytes=12345 duration_ms=38 -level=info msg="cache miss, falling back to HTTP" store_path=/nix/store/abc123-hello cache=https://cache.company.com -level=warn msg="NAR signature verification failed" nar_hash=sha256:... source=mesh peer=12D3KooW... -level=info msg="GC eviction" nar_hash=sha256:... reason=disk_pressure -``` - -## Failure Scenarios - -| Failure | Behavior | -|---------|----------| -| Mesh pod dies | CSI still works: falls back to binary cache HTTP. Existing mounts survive. Local cache persists (hostPath) | -| Mesh disabled (`mesh.enabled: false`) | CSI fetches directly from binary cache HTTP. No P2P, no gossipsub, no mesh pods | -| No peer has the NAR | Mesh responds NotFound, CSI falls back to binary cache HTTP | -| Peer disconnects mid-transfer | Receiver discards partial data, tries another peer or falls back to HTTP | -| Binary cache down + no peer has NAR | Pod stuck in ContainerCreating, kubelet retries with backoff | -| Node dies | Pods rescheduled on another labeled node. CSI fetches closure from local cache (if previously used) or mesh peers or binary cache | -| Corrupted NAR from peer | Hash verification fails, discarded, try next peer or HTTP | -| Disk full | GC evicts LRU unmounted NARs until below low watermark | -| New labeled node joins | CSI + mesh pods start. Cache is empty. First workload fetches from peers/HTTP, caches locally | -| Label removed from node | CSI + mesh pods evicted. Cache persists on hostPath. Re-labeling restores warm cache | diff --git a/NIX_WIRE.md b/NIX_WIRE.md deleted file mode 100644 index 4978b93..0000000 --- a/NIX_WIRE.md +++ /dev/null @@ -1,846 +0,0 @@ -# Niphas -- Nix Wire Formats & Binary Cache Protocol - -Niphas implements all Nix format parsing and verification in-house. -Zero dependency on nix-compat, libnar, or any external Nix crate. -Everything lives in `niphas-core` as shared modules consumed by CSI, mesh, and eval. - -## Module layout (niphas-core) - -``` -niphas-core/src/ - nix/ - mod.rs - nar.rs # NAR archive parser + extractor - narinfo.rs # .narinfo text parser - hash.rs # Nix hashing (SHA-256, Nix-base32) - signature.rs # Ed25519 signature verification - store_path.rs # Store path parsing + validation - cache_client.rs # Binary cache HTTP client - closure.rs # Recursive closure resolution -``` - ---- - -## NAR format - -NAR (Nix Archive) is a deterministic binary archive. Same filesystem tree -always produces the exact same bytes. No timestamps, no ownership, no -extended attributes. - -### Wire primitives - -**Integer:** 64-bit unsigned, little-endian. - -``` -encode_u64(n) = n as [u8; 8] in LE -``` - -**String/bytes:** Length-prefixed, padded to 8-byte alignment. - -``` -encode_bytes(b) = - encode_u64(b.len()) - + b - + zero_pad(b.len()) # 0..7 null bytes to reach next 8-byte boundary - -zero_pad(len) = [0u8; (8 - (len % 8)) % 8] -``` - -### Grammar - -``` -nar = str("nix-archive-1") node - -node = str("(") node_body str(")") - -node_body = str("type") str("regular") regular_body - | str("type") str("symlink") symlink_body - | str("type") str("directory") directory_body - -regular_body = [ str("executable") str("") ] - str("contents") bytes(file_data) - -symlink_body = str("target") str(target_path) - -directory_body = { entry } # zero or more, sorted by name ASC - -entry = str("entry") str("(") - str("name") str(entry_name) - str("node") node - str(")") -``` - -Every token (`"nix-archive-1"`, `"("`, `"type"`, `"regular"`, etc.) is -encoded via `encode_bytes()`. File contents use `bytes()` (same encoding). - -### Constraints the parser must enforce - -| Rule | Reject if | -|------|-----------| -| Magic | First token != `"nix-archive-1"` | -| Entry names | Contains `/`, `\0`, or equals `.` or `..` or is empty | -| Entry order | Current name <= previous name (must be strictly ascending) | -| Duplicate names | Same name appears twice in a directory | -| Nesting depth | Exceeds configurable limit (default: 256) | -| File size | `contents` length > configurable max (from NarSize in narinfo) | -| Padding | Padding bytes are not all zero | -| Trailing data | Bytes remain after the root node closes | - -### Parser design (streaming, zero-alloc where possible) - -```rust -/// Token-level reader over an AsyncRead stream. -struct NarReader { - inner: R, - buf: [u8; 8], // reused for every u64/padding read - depth: u16, // current nesting depth - max_depth: u16, // configurable limit (default 256) - bytes_read: u64, // for NarHash computation - hasher: Sha256, // computes hash inline during parse -} - -impl NarReader { - /// Read a length-prefixed byte string. - async fn read_bytes(&mut self) -> Result>; - - /// Read a length-prefixed string, validate UTF-8. - async fn read_str(&mut self) -> Result; - - /// Read exactly the expected string token, error otherwise. - async fn expect_str(&mut self, expected: &str) -> Result<()>; - - /// Read a u64 (8 bytes LE). - async fn read_u64(&mut self) -> Result; - - /// Skip padding bytes (0..7 after a string), validate all zero. - async fn skip_padding(&mut self, len: u64) -> Result<()>; -} -``` - -The parser never loads the entire NAR into memory. It streams entries -and writes each file/symlink/directory to disk as it encounters them. - -### Extractor design - -```rust -/// Extracts a NAR stream into a target directory. -/// -/// Safety invariants: -/// - Target dir must be inside /var/lib/niphas/cache/ -/// - Extraction happens into a .tmp- dir, atomically renamed on success -/// - Symlinks are created but NEVER followed during extraction -/// - All paths resolved relative to extraction root, never escaping -struct NarExtractor { - root: PathBuf, // e.g. /var/lib/niphas/cache/.tmp-/ - max_nar_size: u64, // from narinfo NarSize -} - -impl NarExtractor { - /// Extract NAR from reader into root directory. - /// Returns the SHA-256 hash of the NAR byte stream. - async fn extract( - &self, - reader: R, - ) -> Result; -} -``` - -#### Symlink safety (CVE-2024-45593 mitigation) - -The Nix reference implementation had a critical vuln (CVSS 9.0) where -a NAR containing a symlink followed by a directory of the same name -could write files outside the extraction root. - -Mitigation: - -```rust -fn extract_entry(&self, parent_fd: RawFd, name: &str, node: Node) -> Result<()> { - // Validate entry name - if name.contains('/') || name.contains('\0') || name == "." || name == ".." || name.is_empty() { - return Err(NarError::InvalidEntryName(name.into())); - } - - match node { - Node::Regular { executable, contents } => { - // O_NOFOLLOW: if `name` is an existing symlink, this fails - // O_EXCL: fail if entry already exists (catches duplicate names) - let fd = openat(parent_fd, name, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, mode)?; - write_all(fd, contents)?; - if executable { - fchmod(fd, 0o555)?; - } else { - fchmod(fd, 0o444)?; - } - } - Node::Symlink { target } => { - // Create symlink. Do NOT validate target -- Nix allows - // arbitrary symlink targets (e.g. /nix/store/... or relative). - // Safety comes from never following symlinks, not from restricting targets. - symlinkat(target, parent_fd, name)?; - } - Node::Directory { entries } => { - // O_NOFOLLOW on mkdirat: if `name` is a symlink, this fails - mkdirat(parent_fd, name, 0o755)?; - let dir_fd = openat(parent_fd, name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW, 0)?; - for entry in entries { - self.extract_entry(dir_fd, &entry.name, entry.node)?; - } - } - } - Ok(()) -} -``` - -Key: every filesystem operation uses `*at()` syscalls (`openat`, `mkdirat`, -`symlinkat`) relative to a parent fd. Combined with `O_NOFOLLOW`, this -makes symlink-based escapes impossible regardless of NAR contents. - -#### Atomic extraction - -``` -1. mkdtemp("/var/lib/niphas/cache/.tmp-XXXXXX") -2. extract NAR into temp dir -3. verify computed NarHash == expected NarHash -4. rename(temp_dir, "/var/lib/niphas/cache/-/") -5. if rename fails (exists): another thread won the race, delete temp -``` - -If the process crashes mid-extraction, `.tmp-*` dirs are cleaned on -driver startup (they are always incomplete/unverified). - ---- - -## .narinfo format - -Plain text, one field per line, colon-separated. - -``` -StorePath: /nix/store/abc123-hello-2.12.1 -URL: nar/1w1fff338fvdw53sqgamddn1b2xgds473pv6y13gizdbqjv4i5p3.nar.zst -Compression: zstd -FileHash: sha256:1w1fff... -FileSize: 12345 -NarHash: sha256:1impfh... -NarSize: 45678 -References: abc123-hello-2.12.1 def456-glibc-2.38 ghi789-zlib-1.3.1 -Deriver: xyz-hello-2.12.1.drv -Sig: cache.nixos.org-1:GrGV/Ls10Tzo...base64... -Sig: company-cache-1:aBcD...base64... -``` - -### Fields - -| Field | Required | Type | Description | -|-------|----------|------|-------------| -| StorePath | yes | store path | Full `/nix/store/-` | -| URL | yes | relative URL | Path to compressed NAR file | -| Compression | yes | enum | `none`, `xz`, `bzip2`, `zstd`, `br`, `lzip`, `lz4` | -| FileHash | yes | `:` | Hash of compressed file | -| FileSize | yes | u64 | Size of compressed file in bytes | -| NarHash | yes | `:` | Hash of uncompressed NAR stream | -| NarSize | yes | u64 | Size of uncompressed NAR in bytes | -| References | no | space-separated | Runtime dependencies (basename only, no `/nix/store/` prefix) | -| Deriver | no | string | The `.drv` that produced this path | -| Sig | no (repeatable) | `:` | Ed25519 signatures | -| CA | no | string | Content address (for CA paths) | - -### Parser - -```rust -pub struct NarInfo { - pub store_path: StorePath, - pub url: String, - pub compression: Compression, - pub file_hash: NixHash, - pub file_size: u64, - pub nar_hash: NixHash, - pub nar_size: u64, - pub references: Vec, // basenames only - pub deriver: Option, - pub signatures: Vec, - pub ca: Option, -} - -#[derive(Debug, Clone, Copy)] -pub enum Compression { - None, - Xz, - Bzip2, - Zstd, - Br, - Lzip, - Lz4, -} - -pub struct NarSignature { - pub key_name: String, // e.g. "cache.nixos.org-1" - pub signature: [u8; 64], // Ed25519 signature bytes -} - -impl NarInfo { - /// Parse from narinfo text content. - pub fn parse(input: &str) -> Result { - let mut store_path = None; - let mut url = None; - // ... field-by-field parsing - - for line in input.lines() { - let (key, value) = line.split_once(": ") - .ok_or(NarInfoError::MalformedLine)?; - match key { - "StorePath" => store_path = Some(StorePath::parse(value)?), - "URL" => url = Some(value.to_owned()), - "Compression" => compression = Some(Compression::parse(value)?), - "NarHash" => nar_hash = Some(NixHash::parse(value)?), - "NarSize" => nar_size = Some(value.parse::()?), - "FileHash" => file_hash = Some(NixHash::parse(value)?), - "FileSize" => file_size = Some(value.parse::()?), - "References" => { - references = value.split_whitespace() - .map(StorePathRef::parse) - .collect::>>()?; - } - "Sig" => { - signatures.push(NarSignature::parse(value)?); - } - "Deriver" => deriver = Some(value.to_owned()), - "CA" => ca = Some(value.to_owned()), - _ => {} // ignore unknown fields (forward compat) - } - } - - Ok(NarInfo { /* ... */ }) - } -} -``` - ---- - -## Nix hashing - -Nix uses SHA-256 with a custom base32 encoding. - -### Nix-base32 - -Alphabet: `0123456789abcdfghijklmnpqrsvwxyz` (32 chars, omits `e o t u`). - -Encoding is **reversed** compared to standard base32: the least significant -bits come first. - -```rust -const NIX_BASE32_CHARS: &[u8; 32] = b"0123456789abcdfghijklmnpqrsvwxyz"; - -/// Encode bytes to Nix base32. -pub fn to_nix_base32(input: &[u8]) -> String { - let len = (input.len() * 8 + 4) / 5; // ceil(bits / 5) - let mut out = String::with_capacity(len); - - for n in (0..len).rev() { - let b = n * 5; - let byte_idx = b / 8; - let bit_idx = b % 8; - - let mut c = (input[byte_idx] >> bit_idx) & 0x1f; - if bit_idx > 3 && byte_idx + 1 < input.len() { - c |= input[byte_idx + 1] << (8 - bit_idx); - c &= 0x1f; - } - out.push(NIX_BASE32_CHARS[c as usize] as char); - } - - out -} - -/// Decode Nix base32 to bytes. -pub fn from_nix_base32(input: &str) -> Result, NixHashError> { - // Reverse of the above -} -``` - -### NixHash type - -```rust -pub struct NixHash { - pub algo: HashAlgo, - pub digest: Vec, // raw bytes -} - -#[derive(Debug, Clone, Copy)] -pub enum HashAlgo { - Sha256, - Sha512, - Sha1, // legacy, some old paths use this - Md5, // legacy -} - -impl NixHash { - /// Parse "sha256:1impfh..." (Nix-base32 encoded). - pub fn parse(s: &str) -> Result { - let (algo_str, hash_str) = s.split_once(':') - .ok_or(NixHashError::MissingAlgo)?; - let algo = match algo_str { - "sha256" => HashAlgo::Sha256, - "sha512" => HashAlgo::Sha512, - "sha1" => HashAlgo::Sha1, - "md5" => HashAlgo::Md5, - _ => return Err(NixHashError::UnknownAlgo(algo_str.into())), - }; - let digest = from_nix_base32(hash_str)?; - Ok(NixHash { algo, digest }) - } - - /// Verify that data matches this hash. - pub fn verify(&self, data: &[u8]) -> Result<(), NixHashError> { - use sha2::{Sha256, Digest}; - match self.algo { - HashAlgo::Sha256 => { - let computed = Sha256::digest(data); - if computed.as_slice() != self.digest { - return Err(NixHashError::Mismatch); - } - } - // other algos... - } - Ok(()) - } -} -``` - -### Store path hash - -The 32-char hash in `/nix/store/-` is **not** a SHA-256 of -the contents. It is derived from the derivation inputs via a truncated -SHA-256, then Nix-base32 encoded. For Niphas, we don't need to compute -this -- we receive it from `nix eval`. We only need to parse and validate it. - -```rust -pub struct StorePath { - pub hash: [u8; 20], // 20 bytes = 32 Nix-base32 chars - pub name: String, // e.g. "hello-2.12.1" -} - -pub struct StorePathRef(String); // basename only, e.g. "abc123-hello-2.12.1" - -impl StorePath { - /// Parse "/nix/store/abc123...-hello-2.12.1" - pub fn parse(s: &str) -> Result { - let rest = s.strip_prefix("/nix/store/") - .ok_or(StorePathError::InvalidPrefix)?; - if rest.len() < 34 { // 32 hash + "-" + at least 1 char name - return Err(StorePathError::TooShort); - } - let hash_str = &rest[..32]; - let name = &rest[33..]; // skip the "-" - let hash_bytes = from_nix_base32(hash_str)?; - Ok(StorePath { - hash: hash_bytes.try_into().map_err(|_| StorePathError::InvalidHash)?, - name: name.to_owned(), - }) - } - - /// The 32-char Nix-base32 hash prefix, used for .narinfo lookup. - pub fn hash_str(&self) -> String { - to_nix_base32(&self.hash) - } -} -``` - ---- - -## Ed25519 signature verification - -### Fingerprint format - -The signature covers a "fingerprint" string: - -``` -1;;;; -``` - -Where: -- `1` is the fingerprint version -- `` is the full path (e.g. `/nix/store/abc123-hello-2.12.1`) -- `` is `sha256:` (the NarHash field from narinfo) -- `` is decimal NarSize -- `` is comma-separated sorted store paths of References - -Example: -``` -1;/nix/store/abc123-hello-2.12.1;sha256:1impfh...;45678;/nix/store/def456-glibc-2.38,/nix/store/ghi789-zlib-1.3.1 -``` - -### Verification - -Dependencies: `ed25519-dalek` (already audited, widely used). - -```rust -use ed25519_dalek::{Signature, VerifyingKey, Verifier}; - -pub struct TrustedKey { - pub name: String, // e.g. "cache.nixos.org-1" - pub pubkey: VerifyingKey, // 32-byte Ed25519 public key -} - -impl TrustedKey { - /// Parse "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" - pub fn parse(s: &str) -> Result { - let (name, key_b64) = s.split_once(':') - .ok_or(SignatureError::MalformedKey)?; - let key_bytes = base64_decode(key_b64)?; - let pubkey = VerifyingKey::from_bytes( - &key_bytes.try_into().map_err(|_| SignatureError::InvalidKeyLength)? - )?; - Ok(TrustedKey { name: name.to_owned(), pubkey }) - } -} - -/// Compute the fingerprint string that signatures cover. -fn compute_fingerprint(narinfo: &NarInfo) -> String { - let refs: Vec = narinfo.references.iter() - .map(|r| format!("/nix/store/{}", r.0)) - .collect(); - let mut refs_sorted = refs.clone(); - refs_sorted.sort(); - - format!( - "1;{};{};{};{}", - narinfo.store_path.to_string(), - narinfo.nar_hash.to_nix_string(), // "sha256:" - narinfo.nar_size, - refs_sorted.join(",") - ) -} - -/// Verify that at least one signature matches a trusted key. -pub fn verify_narinfo( - narinfo: &NarInfo, - trusted_keys: &[TrustedKey], -) -> Result<(), SignatureError> { - let fingerprint = compute_fingerprint(narinfo); - - for sig in &narinfo.signatures { - for key in trusted_keys { - if sig.key_name == key.name { - let signature = Signature::from_bytes(&sig.signature); - if key.pubkey.verify(fingerprint.as_bytes(), &signature).is_ok() { - return Ok(()); - } - } - } - } - - Err(SignatureError::NoTrustedSignature { - store_path: narinfo.store_path.to_string(), - signatures_present: narinfo.signatures.iter() - .map(|s| s.key_name.clone()) - .collect(), - }) -} -``` - -### Invariant - -**An unverified NAR never reaches a pod.** The verification chain: - -``` -1. Fetch .narinfo -2. Verify signature (fingerprint covers NarHash) -3. Fetch compressed NAR -4. Verify FileHash (hash of compressed bytes) -5. Decompress -6. Verify NarHash (hash of decompressed NAR stream) -- computed inline during parse -7. Extract to temp dir -8. Atomic rename to cache dir -``` - -If any step fails, the NAR is discarded. No partial state persists. - ---- - -## Binary cache HTTP client - -### Protocol - -``` -GET /nix-cache-info --> cache metadata -GET /.narinfo --> store path metadata -GET /nar/.nar.zstd --> compressed NAR archive -``` - -The `` is the 32-char Nix-base32 prefix from the -store path. E.g. for `/nix/store/abc123...-hello`, the request is -`GET /abc123....narinfo`. - -### Client design - -```rust -pub struct CacheClient { - http: reqwest::Client, - caches: Vec, // ordered by priority - trusted_keys: Vec, -} - -pub struct CacheConfig { - pub url: String, // e.g. "https://cache.nixos.org" - pub priority: u32, - pub public_keys: Vec, // key names trusted for this cache -} - -impl CacheClient { - /// Fetch narinfo for a store path. Tries caches in priority order. - pub async fn fetch_narinfo( - &self, - store_path: &StorePath, - ) -> Result<(NarInfo, String), CacheError> { - let hash = store_path.hash_str(); - - for cache in &self.caches { - let url = format!("{}/{}.narinfo", cache.url, hash); - match self.http.get(&url).send().await { - Ok(resp) if resp.status() == 200 => { - let text = resp.text().await?; - let narinfo = NarInfo::parse(&text)?; - verify_narinfo(&narinfo, &self.trusted_keys)?; - return Ok((narinfo, cache.url.clone())); - } - Ok(resp) if resp.status() == 404 => continue, - Ok(resp) => { - tracing::warn!(cache = %cache.url, status = %resp.status(), "unexpected status"); - continue; - } - Err(e) => { - tracing::warn!(cache = %cache.url, error = %e, "cache unreachable"); - continue; - } - } - } - - Err(CacheError::NotFound(store_path.to_string())) - } - - /// Download and verify a NAR. Returns path to extracted dir. - pub async fn fetch_nar( - &self, - narinfo: &NarInfo, - cache_url: &str, - cache_dir: &Path, - ) -> Result { - let nar_url = format!("{}/{}", cache_url, narinfo.url); - - // Stream download - let resp = self.http.get(&nar_url).send().await?; - let compressed_bytes = resp.bytes().await?; - - // Verify FileHash (compressed) - narinfo.file_hash.verify(&compressed_bytes)?; - - // Decompress - let decompressed = decompress(&compressed_bytes, narinfo.compression)?; - - // Extract NAR (NarHash verified inline during extraction) - let tmp_dir = cache_dir.join(format!(".tmp-{}", uuid())); - std::fs::create_dir_all(&tmp_dir)?; - - let extractor = NarExtractor::new(&tmp_dir, narinfo.nar_size); - let computed_hash = extractor.extract(&mut &decompressed[..]).await?; - - // Verify NarHash - if computed_hash != narinfo.nar_hash { - std::fs::remove_dir_all(&tmp_dir)?; - return Err(CacheError::NarHashMismatch); - } - - // Atomic rename - let final_dir = cache_dir.join(narinfo.store_path.basename()); - match std::fs::rename(&tmp_dir, &final_dir) { - Ok(()) => Ok(final_dir), - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { - // Race: another thread/process extracted the same path - std::fs::remove_dir_all(&tmp_dir)?; - Ok(final_dir) - } - Err(e) => Err(e.into()), - } - } -} -``` - -### Streaming for large NARs - -For NARs larger than a configurable threshold (e.g. 64 MB), avoid -loading the entire compressed body into memory: - -```rust -pub async fn fetch_nar_streaming( - &self, - narinfo: &NarInfo, - cache_url: &str, - cache_dir: &Path, -) -> Result { - let nar_url = format!("{}/{}", cache_url, narinfo.url); - let resp = self.http.get(&nar_url).send().await?; - let byte_stream = resp.bytes_stream(); - - // Pipeline: download --> hash(compressed) --> decompress --> hash(nar) --> extract - // - // FileHash is verified by hashing the compressed stream as it passes through. - // NarHash is verified by hashing the decompressed stream during extraction. - // If either hash fails, extraction is aborted and temp dir removed. - - let compressed_hasher = HashingReader::new(byte_stream, narinfo.file_hash.algo); - let decompressor = ZstdDecoder::new(compressed_hasher); // or xz, etc. - let nar_reader = NarReader::new(decompressor); - - let tmp_dir = cache_dir.join(format!(".tmp-{}", uuid())); - std::fs::create_dir_all(&tmp_dir)?; - - let extractor = NarExtractor::new(&tmp_dir, narinfo.nar_size); - let (computed_nar_hash, computed_file_hash) = extractor - .extract_streaming(nar_reader) - .await?; - - // Verify both hashes - if computed_file_hash != narinfo.file_hash { - std::fs::remove_dir_all(&tmp_dir)?; - return Err(CacheError::FileHashMismatch); - } - if computed_nar_hash != narinfo.nar_hash { - std::fs::remove_dir_all(&tmp_dir)?; - return Err(CacheError::NarHashMismatch); - } - - // Atomic rename - let final_dir = cache_dir.join(narinfo.store_path.basename()); - std::fs::rename(&tmp_dir, &final_dir)?; - Ok(final_dir) -} -``` - ---- - -## Closure resolution (pure HTTP, no Nix) - -The full closure (all transitive runtime dependencies) can be resolved -by recursively fetching `.narinfo` files from the binary cache. - -Each `.narinfo` has a `References` field listing immediate dependencies -(basenames only). Walk this graph to get the complete closure. - -```rust -use std::collections::HashSet; - -impl CacheClient { - /// Resolve the full closure of a store path by walking .narinfo References. - /// Returns all store paths in the closure, including the root. - pub async fn resolve_closure( - &self, - root: &StorePath, - ) -> Result, CacheError> { - let mut visited: HashSet = HashSet::new(); - let mut queue: Vec = vec![root.clone()]; - let mut closure: Vec = Vec::new(); - - while let Some(path) = queue.pop() { - let basename = path.basename(); - if visited.contains(&basename) { - continue; - } - visited.insert(basename.clone()); - - let (narinfo, _cache_url) = self.fetch_narinfo(&path).await?; - - // Enqueue unvisited references - for ref_basename in &narinfo.references { - if !visited.contains(&ref_basename.0) { - let ref_path = StorePath::from_basename(&ref_basename.0)?; - queue.push(ref_path); - } - } - - closure.push(narinfo); - } - - Ok(closure) - } - - /// Parallel closure resolution: fetch multiple narinfos concurrently. - pub async fn resolve_closure_parallel( - &self, - root: &StorePath, - concurrency: usize, // e.g. 16 - ) -> Result, CacheError> { - // BFS with bounded concurrency using tokio::sync::Semaphore - // or futures::stream::buffer_unordered - } -} -``` - -### When closure resolution happens - -``` -1. User creates NiphasWorkload CR -2. niphas-eval evaluates flake via Nix C API (in-process FFI) --> gets outPath -3. niphas-eval calls resolve_closure(outPath) via HTTP to binary cache -4. niphas-eval writes closure_paths to CRD status -5. operator reads closure_paths from status, passes to CSI via volumeAttributes -6. CSI driver fetches individual NARs (already knows the full list) -``` - -Both steps happen inside the niphas-eval process. Step 2 uses the Nix C API -via FFI (no process spawn, no Job). Step 3 is pure HTTP -- no Nix needed. - ---- - -## Decompression - -Support the compression formats Nix binary caches use: - -| Format | Crate | Notes | -|--------|-------|-------| -| zstd | `async-compression` (already in workspace) | Most common for modern caches | -| xz | `async-compression` + `xz2` feature | cache.nixos.org uses this | -| bzip2 | `async-compression` + `bzip2` feature | Legacy | -| br (brotli) | `async-compression` + `brotli` feature | Rare | -| none | passthrough | Uncompressed | - -```rust -fn decompress(data: &[u8], compression: Compression) -> Result> { - match compression { - Compression::None => Ok(data.to_vec()), - Compression::Zstd => { - let mut decoder = zstd::Decoder::new(data)?; - let mut out = Vec::new(); - decoder.read_to_end(&mut out)?; - Ok(out) - } - Compression::Xz => { - let mut decoder = xz2::read::XzDecoder::new(data); - let mut out = Vec::new(); - decoder.read_to_end(&mut out)?; - Ok(out) - } - // ... - } -} -``` - -For streaming, use `async-compression::tokio::bufread::*Decoder`. - ---- - -## Dependency summary - -All new deps for the Nix wire format implementation: - -| Crate | Purpose | Already in workspace? | -|-------|---------|-----------------------| -| `sha2` | SHA-256 for NarHash/FileHash | No, add | -| `ed25519-dalek` | Ed25519 signature verification | No, add | -| `base64` | Decode signature bytes and public keys | No, add | -| `reqwest` | HTTP client for binary cache | No, add | -| `uuid` | Temp dir naming | No, add (or use random bytes) | -| `async-compression` | zstd/xz/bzip2 decompression | Yes | -| `tokio` | Async runtime | Yes | -| `serde` | NarInfo struct serialization | Yes | -| `tracing` | Logging | Yes | -| `thiserror` | Error types | Yes | diff --git a/OPERATOR.md b/OPERATOR.md deleted file mode 100644 index ff97639..0000000 --- a/OPERATOR.md +++ /dev/null @@ -1,524 +0,0 @@ -# Niphas -- Operator Design - -niphas-operator reconciles `NiphasWorkload` CRDs into running Kubernetes -workloads. It is the central control plane component. - -## Reconciliation state machine - -``` - ┌──────────────┐ - CRD created │ │ - ────────────>│ Pending │ - │ │ - └──────┬───────┘ - │ - set phase = Evaluating - call eval webhook - │ - ┌──────▼───────┐ - │ │ eval error - │ Evaluating │──────────────┐ - │ │ │ - └──────┬───────┘ │ - │ │ - eval returns EvalResult │ - set storePath, closurePaths │ - set phase = Provisioning │ - │ │ - ┌──────▼───────┐ │ - │ │ create error │ - │ Provisioning │──────────┐ │ - │ │ │ │ - └──────┬───────┘ │ │ - │ │ │ - child resources created │ │ - pods becoming ready │ │ - │ │ │ - ┌──────▼───────┐ │ │ - │ │ │ │ - │ Running │ │ │ - │ │ │ │ - └──────┬───────┘ │ │ - │ ┌─────▼────▼──┐ - spec changed │ │ - (generation++) │ Failed │ - ───────────────> │ │ - back to Evaluating └─────────────┘ -``` - -### Transition rules - -| From | To | Trigger | -|------|----|---------| -| (none) | Pending | CRD created | -| Pending | Evaluating | Operator sees new resource | -| Evaluating | Provisioning | Eval webhook returns success | -| Evaluating | Failed | Eval error (timeout, flake not allowed, eval crash) | -| Provisioning | Running | At least 1 replica is Ready | -| Provisioning | Failed | Child resource creation fails | -| Running | Evaluating | `observedGeneration < generation` (spec updated) | -| Running | Degraded | Some replicas not ready, NAR corruption detected | -| Failed | Evaluating | User updates spec (generation increments) | - -## Reconciler loop - -The operator uses kube-rs `Controller` with a single reconciler function. - -```rust -async fn reconcile( - workload: Arc, - ctx: Arc, -) -> Result { - let name = workload.name_any(); - let ns = workload.namespace().unwrap(); - let generation = workload.metadata.generation.unwrap_or(0); - - // 1. Check if we already processed this generation - let status = workload.status.as_ref(); - let observed = status.and_then(|s| s.observed_generation).unwrap_or(0); - - if observed >= generation && status.map(|s| s.phase.as_str()) == Some("Running") { - // Nothing to do. Requeue in 5 minutes for health check. - return Ok(Action::requeue(Duration::from_secs(300))); - } - - // 2. Evaluate (if needed) - let eval_result = if needs_eval(&workload, observed, generation) { - set_phase(&ctx, &workload, "Evaluating").await?; - match call_eval_webhook(&ctx, &workload).await { - Ok(result) => result, - Err(e) => { - set_failed(&ctx, &workload, "EvalFailed", &e.to_string()).await?; - return Ok(Action::requeue(Duration::from_secs(30))); - } - } - } else { - // Use cached eval result from status - EvalResult::from_status(status.unwrap()) - }; - - // 3. Provision child resources - set_phase(&ctx, &workload, "Provisioning").await?; - apply_child_resources(&ctx, &workload, &eval_result).await?; - - // 4. Check readiness - let ready = count_ready_replicas(&ctx, &workload).await?; - let desired = workload.spec.replicas.unwrap_or(1); - - let phase = if ready >= desired { "Running" } else { "Provisioning" }; - update_status(&ctx, &workload, phase, &eval_result, ready, generation).await?; - - // 5. Requeue - let interval = if ready < desired { - Duration::from_secs(5) // poll frequently while provisioning - } else { - Duration::from_secs(300) // stable, check every 5 min - }; - Ok(Action::requeue(interval)) -} -``` - -### needs_eval() - -```rust -fn needs_eval(workload: &NiphasWorkload, observed: i64, generation: i64) -> bool { - // New or updated resource - if observed < generation { return true; } - - // No store path yet (first reconciliation after crash) - let status = workload.status.as_ref(); - status.and_then(|s| s.store_path.as_ref()).is_none() -} -``` - -## Eval webhook integration - -The operator calls niphas-eval via HTTP (internal Service, not public): - -### Request - -``` -POST http://niphas-eval.niphas-system.svc:8443/evaluate -Content-Type: application/json -``` - -```json -{ - "flakeRef": "github:myorg/myapp", - "attribute": "packages.x86_64-linux.default", - "revision": "a1b2c3d4e5f6", - "binaryCache": "https://cache.company.com" -} -``` - -`revision` and `binaryCache` are optional. If `binaryCache` is omitted, -niphas-eval uses the caches from its own ConfigMap. - -### Response (success) - -```json -{ - "storePath": "/nix/store/abc123-myapp-1.0.0", - "name": "myapp-1.0.0", - "mainProgram": "myapp", - "closurePaths": [ - "/nix/store/abc123-myapp-1.0.0", - "/nix/store/def456-glibc-2.38", - "/nix/store/ghi789-openssl-3.1" - ] -} -``` - -### Response (error) - -```json -{ - "error": "StorePathNotCached", - "message": "/nix/store/abc123-myapp-1.0.0 not found in any binary cache" -} -``` - -Error codes: - -| Code | Meaning | Operator action | -|------|---------|-----------------| -| `FlakeNotAllowed` | flakeRef not in allowlist | Set Failed, reason=FlakeNotAllowed | -| `EvalFailed` | Nix evaluation error | Set Failed, reason=EvalFailed | -| `EvalTimeout` | Evaluation exceeded timeout | Set Failed, reason=EvalTimeout | -| `StorePathNotCached` | NAR not in any binary cache | Set Failed, reason=StorePathNotCached | -| `ClosureResolutionFailed` | Could not resolve full closure | Set Failed, reason=ClosureResolutionFailed | - -### Timeout - -The operator sets a per-request timeout of 300 seconds (configurable). -If the eval webhook does not respond within this window, the operator -cancels the request and sets `Failed` with reason `EvalTimeout`. - -### Multi-architecture - -When `spec.architectures` is set, the operator calls the eval webhook -once per architecture, substituting `{arch}` in the attribute: - -``` -POST /evaluate { attribute: "packages.x86_64-linux.default", ... } -POST /evaluate { attribute: "packages.aarch64-linux.default", ... } -``` - -These calls are made concurrently (`tokio::join!`). - -## Child resource generation - -After successful eval, the operator creates/updates these child resources: - -### Deployment - -Generated for every NiphasWorkload. If `spec.architectures` is set, -one Deployment per architecture. - -```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: # or - for multi-arch - namespace: - ownerReferences: [...] - labels: - niphas.io/workload: - niphas.io/managed-by: niphas-operator -spec: - replicas: - selector: - matchLabels: - niphas.io/workload: - template: - metadata: - labels: - niphas.io/workload: - niphas.io/store-hash: - spec: - nodeSelector: - niphas.io/store: "true" - # + spec.nodeSelector (merged) - # + kubernetes.io/arch: (if multi-arch) - - tolerations: - # fast failover (always injected) - - key: node.kubernetes.io/not-ready - operator: Exists - effect: NoExecute - tolerationSeconds: 30 - - key: node.kubernetes.io/unreachable - operator: Exists - effect: NoExecute - tolerationSeconds: 30 - # + spec.tolerations (appended) - - topologySpreadConstraints: # only if replicas >= 2 - - maxSkew: 1 - topologyKey: kubernetes.io/hostname - whenUnsatisfiable: DoNotSchedule - labelSelector: - matchLabels: - niphas.io/workload: - - maxSkew: 1 - topologyKey: topology.kubernetes.io/zone - whenUnsatisfiable: ScheduleAnyway - labelSelector: - matchLabels: - niphas.io/workload: - - containers: - - name: app - image: ghcr.io/fullzer4/niphas-runner:latest - command: [] - args: - env: - ports: - resources: - livenessProbe: - readinessProbe: - startupProbe: - volumeMounts: - - name: nix-store - mountPath: /nix/store - readOnly: true - # + spec.extraVolumeMounts - - volumes: - - name: nix-store - csi: - driver: niphas.io.csi - volumeAttributes: - closurePaths: "" - # + spec.extraVolumes -``` - -### Service (conditional) - -Created only if `spec.service` is set. - -```yaml -apiVersion: v1 -kind: Service -metadata: - name: - namespace: - ownerReferences: [...] -spec: - type: - selector: - niphas.io/workload: - ports: -``` - -### Ingress (conditional) - -Created only if `spec.ingress` is set and `spec.ingress.enabled` is true. - -```yaml -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: - namespace: - ownerReferences: [...] -spec: - ingressClassName: - rules: - tls: -``` - -### PodDisruptionBudget (conditional) - -Created only if `spec.replicas >= 2`. - -```yaml -apiVersion: policy/v1 -kind: PodDisruptionBudget -metadata: - name: -pdb - namespace: - ownerReferences: [...] -spec: - maxUnavailable: 1 - selector: - matchLabels: - niphas.io/workload: - unhealthyPodEvictionPolicy: AlwaysAllow -``` - -## Server-side apply - -The operator uses **server-side apply** (SSA) for all child resources: - -```rust -let patch = Patch::Apply(&deployment); -let params = PatchParams::apply("niphas-operator").force(); -deployments.patch(&name, ¶ms, &patch).await?; -``` - -Benefits: -- Idempotent: applying the same spec twice is a no-op -- Conflict detection: if another controller modified the resource, SSA - merges fields by field manager -- No read-modify-write race: atomic update - -## Update and rollout - -When the user changes `flakeRef`, `attribute`, `revision`, or any spec field: - -1. `metadata.generation` increments (K8s does this automatically) -2. Reconciler detects `observedGeneration < generation` -3. If `flakeRef`, `attribute`, or `revision` changed: re-evaluates -4. If store path changed: updates Deployment's CSI volumeAttributes + command -5. K8s performs standard rolling update (default strategy: `RollingUpdate`) -6. Old pods drain, new pods mount new closure via CSI -7. Operator watches replica readiness, updates status - -For spec-only changes (e.g. `replicas`, `resources`, `env`) that don't -affect the Nix closure, the operator skips eval and directly patches -the Deployment. - -```rust -fn needs_eval(workload: &NiphasWorkload, observed: i64, generation: i64) -> bool { - if observed < generation { - // Check if only non-closure fields changed - let prev = workload.status.as_ref(); - let same_flake = prev.map(|s| /* compare flakeRef, attribute, revision */); - return !same_flake.unwrap_or(false); - } - false -} -``` - -## Finalizer - -The operator adds `niphas.io/workload-cleanup` to every NiphasWorkload. - -### Deletion flow - -``` -1. User deletes NiphasWorkload -2. K8s sets deletionTimestamp (object is now Terminating) -3. Reconciler runs: - a. Sees deletionTimestamp is set - b. Child resources have ownerReferences --> K8s GC deletes them - c. Operator removes the finalizer -4. K8s completes deletion -``` - -The finalizer exists to ensure the reconciler runs at least once during -deletion (e.g. to emit events, update metrics). The actual resource -cleanup is handled by K8s garbage collection via ownerReferences. - -```rust -async fn reconcile(workload: Arc, ctx: Arc) -> Result { - // Handle deletion first - if workload.metadata.deletion_timestamp.is_some() { - // Emit deletion event - ctx.recorder.publish(Event { - type_: EventType::Normal, - reason: "Deleting".into(), - note: Some(format!("Cleaning up workload {}", workload.name_any())), - action: "Delete".into(), - secondary: None, - }).await?; - - // Remove finalizer (child resources cleaned up by ownerReferences) - finalizer::remove(&ctx.client, &workload).await?; - return Ok(Action::await_change()); - } - - // Ensure finalizer exists - if !finalizer::exists(&workload) { - finalizer::add(&ctx.client, &workload).await?; - return Ok(Action::requeue(Duration::from_secs(0))); - } - - // ... normal reconciliation -} -``` - -## Leader election - -The operator runs with 2-3 replicas for HA. Only the leader reconciles. - -```rust -let lease = LeaseLock::new(client.clone(), "niphas-operator", LeaseLockParams { - holder_id: pod_name, - lease_ttl: Duration::from_secs(15), - retry_period: Duration::from_secs(5), - renew_period: Duration::from_secs(5), -}); -``` - -| Parameter | Value | Rationale | -|-----------|-------|-----------| -| TTL | 15s | Time before a dead leader is considered gone | -| Renew | 5s | Leader heartbeat interval | -| Retry | 5s | Non-leader check interval | - -Failover takes at most ~15 seconds. Reconciliation is idempotent, -so brief dual-leader windows (unlikely but possible) are safe. - -## Error handling and retry - -| Error type | Retry strategy | -|------------|---------------| -| Eval webhook timeout | Requeue after 30s, retry up to 3 times, then set Failed | -| Eval webhook unreachable | Requeue after 10s, exponential backoff up to 5 min | -| Child resource conflict (SSA) | Immediate retry (SSA handles conflicts) | -| K8s API transient error | Requeue after 5s, exponential backoff | -| K8s API permanent error (403, 404) | Set Failed, do not retry | - -The kube-rs `Controller` provides built-in exponential backoff for -reconciler errors. The operator supplements this with domain-specific -retry logic for eval webhook calls. - -## Watches - -The controller watches these resources for changes: - -| Resource | Why | -|----------|-----| -| `NiphasWorkload` | Primary resource (triggers reconciliation) | -| `Deployment` (owned) | Detect readiness changes, rollout progress | -| `Pod` (owned) | Count ready replicas, detect failures | - -Watches use `ownerReferences` filtering: only Deployments/Pods owned -by a NiphasWorkload trigger reconciliation of that specific workload. - -```rust -Controller::new(workloads, Default::default()) - .owns(deployments, Default::default()) - .owns(pods, Default::default()) - .run(reconcile, error_policy, ctx) -``` - -## Health probes - -The operator exposes an Axum HTTP server on port 8080: - -| Endpoint | Purpose | -|----------|---------| -| `/healthz` | Liveness probe. Returns 200 if the process is alive | -| `/readyz` | Readiness probe. Returns 200 if the leader election is resolved and the controller is watching | -| `/metrics` | Prometheus metrics (reconciliation count, duration, errors) | - -## Events - -The operator emits Kubernetes Events for key lifecycle transitions: - -| Event | Type | Reason | -|-------|------|--------| -| Eval started | Normal | `EvalStarted` | -| Eval succeeded | Normal | `EvalSucceeded` | -| Eval failed | Warning | `EvalFailed` | -| Flake not allowed | Warning | `FlakeNotAllowed` | -| Store path not cached | Warning | `StorePathNotCached` | -| Child resources created | Normal | `Provisioned` | -| All replicas ready | Normal | `Available` | -| Replica degraded | Warning | `Degraded` | -| Workload deleting | Normal | `Deleting` | - -Events are visible via `kubectl describe niphasworkload `. diff --git a/PROBLEM.md b/PROBLEM.md deleted file mode 100644 index 960e10f..0000000 --- a/PROBLEM.md +++ /dev/null @@ -1,473 +0,0 @@ -# Niphas -- Problemas Conhecidos e Plano de Correção - -Análise completa da codebase em 2026-06-09. 80 testes passando, 0 warnings. - ---- - -## Severidade: Crítico - -### P1. Injeção de expressão Nix via flake_ref/attribute - -**Arquivo:** `niphas-eval/src/evaluator.rs:92-98` - -O `flake_ref` e `attribute` do usuário são interpolados diretamente numa string Nix: - -```rust -let expr = format!( - r#"let drv = (builtins.getFlake "{pinned_ref}").{attribute}; in ..."# -); -``` - -Um flake ref contendo `"` ou escape sequences Nix pode injetar expressões arbitrárias. -O allowlist (glob matching) valida apenas o padrão, não o conteúdo de caracteres especiais. - -**Impacto:** Execução arbitrária de código Nix no node do eval service. - -**Correção:** - -1. Validar `flake_ref` com regex restrita: `^[a-zA-Z][a-zA-Z0-9+\-\.]*:[a-zA-Z0-9/_\-\.]+$` -2. Validar `attribute` com regex: `^[a-zA-Z_][a-zA-Z0-9_\-]*(\.[a-zA-Z_][a-zA-Z0-9_\-]*)*$` -3. Rejeitar qualquer input com `"`, `\`, `$`, `;`, `(`, `)` antes da interpolação -4. Adicionar funções `validate_flake_ref()` e `validate_attribute()` em `niphas-core/src/eval.rs` - -``` -niphas-core/src/eval.rs -- adicionar validate_flake_ref(), validate_attribute() -niphas-eval/src/evaluator.rs -- chamar validações antes de construir expr -``` - ---- - -### P2. Sem leader election no operator - -**Arquivo:** `niphas-operator/src/main.rs` - -Múltiplas réplicas do operator reconciliam simultaneamente, causando chamadas eval -duplicadas, conflitos de status, e race conditions no SSA. - -**Impacto:** Comportamento indefinido em HA. Recursos duplicados ou corrompidos. - -**Correção:** - -Usar `kube::runtime::Controller` com `LeaderElection` via `coordination.k8s.io/v1` Lease. -O `kube-runtime` já suporta isso nativamente. - -```rust -// main.rs -use kube::runtime::watcher::Config as WatcherConfig; - -let lease = kube::runtime::controller::LeaseConfig { - lease_name: "niphas-operator-leader".into(), - lease_namespace: std::env::var("POD_NAMESPACE").unwrap_or("niphas-system".into()), - identity: std::env::var("POD_NAME").unwrap_or_else(|_| hostname()), - lease_duration: Duration::from_secs(15), - renew_deadline: Duration::from_secs(10), - retry_period: Duration::from_secs(2), -}; -``` - -Alternativa mais simples (fase 1): usar `--replicas=1` no Deployment do operator e -documentar que HA requer leader election. Implementar Lease-based election como fase 2. - -``` -niphas-operator/src/main.rs -- adicionar leader election -niphas-operator/Cargo.toml -- verificar feature flags do kube-runtime -``` - ---- - -## Severidade: Alto - -### P3. Campo revision sem validação - -**Arquivo:** `niphas-core/src/crd.rs:32-33` - -Doc diz "Must be a 6-40 char hex string" mas nenhuma validação existe. -Qualquer string flui direto para `format!("{}/{}", flake_ref, rev)` no subprocess Nix. - -**Impacto:** Input malicioso pode manipular o flake ref resultante. - -**Correção:** - -Adicionar validação no `evaluate()` do eval service, antes de construir o pinned_ref: - -```rust -fn validate_revision(rev: &str) -> Result<(), NiphasError> { - if rev.len() < 6 || rev.len() > 40 { - return Err(NiphasError::InvalidInput("revision must be 6-40 chars".into())); - } - if !rev.chars().all(|c| c.is_ascii_hexdigit()) { - return Err(NiphasError::InvalidInput("revision must be hex".into())); - } - Ok(()) -} -``` - -``` -niphas-core/src/eval.rs -- adicionar validate_revision() -niphas-eval/src/evaluator.rs:42 -- chamar antes de usar -``` - ---- - -### P4. Sem limite de concorrência no eval subprocess - -**Arquivo:** `niphas-eval/src/main.rs` - -Cada request POST /evaluate spawna um subprocess `nix eval` sem nenhum limite. -Sob carga, pode fork-bomb o node. - -**Impacto:** DoS no node do eval service. - -**Correção:** - -Adicionar `tokio::sync::Semaphore` ao `Evaluator` com permits configuráveis: - -```rust -pub struct Evaluator { - config: NiphasConfig, - cache_client: CacheClient, - warm: AtomicBool, - eval_semaphore: Semaphore, // novo -} - -// Em evaluate(): -let _permit = self.eval_semaphore.acquire().await - .map_err(|_| NiphasError::NixEval("eval service shutting down".into()))?; -``` - -Adicionar campo `max_concurrent_evals` ao `NiphasConfig` (default: 4). - -``` -niphas-core/src/config.rs -- adicionar max_concurrent_evals -niphas-eval/src/evaluator.rs -- adicionar Semaphore -``` - ---- - -### P5. NAR inteiro em memória (3x) - -**Arquivos:** `niphas-core/src/nix/nar.rs:28`, `niphas-csi/src/cache.rs:178-200` - -Fluxo atual: bytes comprimidos (mem) -> descomprimidos (mem) -> árvore parsed com -todos os conteúdos (mem) -> escrito em disco. Para pacotes grandes (gcc ~500MB), -consome ~1.5GB de RAM. - -**Impacto:** OOM kill em nodes com memória limitada. DoS via pacote grande. - -**Correção (fase 1 -- limitar):** - -Adicionar limite global ao `decompress_nar`: rejeitar NARs descomprimidos > 2GB. -Isso é simples e cobre o caso de DoS. - -**Correção (fase 2 -- streaming):** - -Redesenhar `NarReader` para streaming: em vez de retornar `NarNode` com `Vec`, -usar um visitor/callback pattern que escreve diretamente no disco: - -```rust -pub trait NarVisitor { - fn regular_file(&mut self, path: &Path, executable: bool, contents: &mut dyn AsyncRead) -> Result<()>; - fn symlink(&mut self, path: &Path, target: &str) -> Result<()>; - fn directory(&mut self, path: &Path) -> Result<()>; -} -``` - -Isso elimina a árvore em memória completamente. É um refactor grande que muda a API -do `NarReader`. Fazer depois do MVP. - -``` -niphas-csi/src/cache.rs -- fase 1: limite de 2GB no decompress -niphas-core/src/nix/nar.rs -- fase 2: NarVisitor streaming -``` - ---- - -### P6. Ready flag antes do controller iniciar - -**Arquivo:** `niphas-operator/src/main.rs:58` - -```rust -ready.store(true, Ordering::Relaxed); // linha 58 -Controller::new(workloads, ...) // linha 60 -``` - -O readiness probe retorna 200 antes do controller sequer começar a assistir. -Em rolling updates, requests podem ser roteados para uma instância que ainda -não está processando. - -**Impacto:** Window de indisponibilidade durante rolling updates. - -**Correção:** - -Mover o `ready.store(true)` para dentro do primeiro `for_each` callback, -ou melhor, usar o `Controller::graceful_shutdown_on` e setar ready após -o primeiro reconcile: - -```rust -// Setar ready quando o controller começar a receber eventos -let ready_flag = ready.clone(); -Controller::new(workloads, Default::default()) - .owns(deployments, Default::default()) - .owns(pods, Default::default()) - .run(reconciler::reconcile, reconciler::error_policy, ctx) - .for_each(|res| { - ready_flag.store(true, Ordering::Relaxed); - async move { /* ... */ } - }) - .await; -``` - -``` -niphas-operator/src/main.rs:58 -- mover ready flag para dentro do for_each -``` - ---- - -## Severidade: Médio - -### P7. Falhas silenciosas em resource building - -**Arquivo:** `niphas-operator/src/resources.rs` (10 ocorrências) - -`serde_json::to_value(...).unwrap_or_default()` insere `null` silenciosamente se -a serialização falhar. Pode criar Deployments quebrados sem nenhum log. - -**Correção:** - -Mudar a signature dos builders para `Result` e propagar o erro: - -```rust -pub(crate) fn build_deployment(...) -> Result { - // ... - if let Some(ref env) = workload.spec.env { - container_obj.insert("env".into(), serde_json::to_value(env)?); - } -} -``` - -Isso é seguro porque `serde_json::to_value` em tipos K8s que já implementam -Serialize nunca deveria falhar, mas se falhar, é melhor saber. - -``` -niphas-operator/src/resources.rs -- builders retornam Result -niphas-operator/src/reconciler.rs -- propagar ? no call site -``` - ---- - -### P8. Glob matching com complexidade exponencial - -**Arquivo:** `niphas-eval/src/allowlist.rs:49` - -A recursão do `matches_glob` para `*` pode ter comportamento exponencial com -patterns adversariais como `*a*a*a*a*b` contra `aaaaaaa...`. - -**Impacto:** Potencial ReDoS no path crítico de segurança (allowlist). - -**Correção:** - -Substituir pelo crate `glob-match` (zero-alloc, O(n) worst-case) ou implementar -matching iterativo com stack explícito: - -```rust -// Cargo.toml -glob-match = "0.2" - -// allowlist.rs -fn matches_glob(pattern: &str, value: &str) -> bool { - glob_match::glob_match(pattern, value) -} -``` - -``` -niphas-eval/Cargo.toml -- adicionar glob-match -niphas-eval/src/allowlist.rs -- substituir matches_glob -``` - ---- - -### P9. Lock map cresce sem limite - -**Arquivo:** `niphas-csi/src/cache.rs:27-28` - -`locks: HashMap>>` nunca é limpo. Em nodes de longa duração, -acumula uma entrada por store path já baixado. - -**Correção:** - -Limpar o lock do mapa após o download completar: - -```rust -// Após fetch_and_extract: -{ - let mut locks = self.locks.lock().await; - locks.remove(basename); -} -``` - -Cada entrada é ~100 bytes (String + Arc + Mutex), então mesmo 100k paths -são apenas ~10MB. Baixa prioridade, mas bom higiene. - -``` -niphas-csi/src/cache.rs:95 -- remover lock após uso -``` - ---- - -### P10. Imagem runner hardcoded com :latest - -**Arquivo:** `niphas-operator/src/resources.rs:14` - -```rust -const RUNNER_IMAGE: &str = "ghcr.io/fullzer4/niphas-runner:latest"; -``` - -`:latest` em produção é anti-pattern. Impede rollbacks, cache inconsistente, -e comportamento não-reproduzível. - -**Correção:** - -Adicionar `runner_image` ao `NiphasConfig` com default versionado: - -```rust -// config.rs -#[serde(default = "default_runner_image")] -pub runner_image: String, - -fn default_runner_image() -> String { - format!("ghcr.io/fullzer4/niphas-runner:v{}", env!("CARGO_PKG_VERSION")) -} -``` - -Permitir override por workload no CRD: - -```rust -// crd.rs -#[serde(default, skip_serializing_if = "Option::is_none")] -pub runner_image: Option, -``` - -``` -niphas-core/src/config.rs -- adicionar runner_image -niphas-core/src/crd.rs -- adicionar runner_image override -niphas-operator/src/resources.rs:14 -- usar config.runner_image -``` - ---- - -### P11. ownerReference com UID vazio - -**Arquivo:** `niphas-operator/src/resources.rs:447` - -```rust -"uid": workload.metadata.uid.as_deref().unwrap_or("") -``` - -UID vazio quebra garbage collection do K8s silenciosamente. Se o workload -não tiver UID, os child resources nunca serão limpos. - -**Correção:** - -Retornar erro se UID for None em vez de usar string vazia: - -```rust -fn owner_reference(workload: &NiphasWorkload) -> Result { - let uid = workload.metadata.uid.as_deref() - .ok_or_else(|| OperatorError::Internal("workload missing UID".into()))?; - Ok(json!({ - "uid": uid, - // ... - })) -} -``` - -``` -niphas-operator/src/resources.rs:442-451 -- retornar Result -``` - ---- - -## Severidade: Baixo - -### P12. Dependências workspace não usadas - -**Arquivo:** `Cargo.toml:40-80` - -Declaradas mas não usadas por nenhum crate ativo: -`libp2p`, `rkyv`, `smallvec`, `compact_str`, `bumpalo`, `memmap2`, -`lockfree-object-pool`, `garde`, `serde_yaml`. - -**Correção:** Remover do workspace `Cargo.toml`. Re-adicionar quando forem necessárias. - ---- - -### P13. niphas-mesh é stub vazio - -**Arquivo:** `niphas-mesh/src/main.rs` - -Crate inteiro é `fn main() { info!("starting"); }`. Puxa deps desnecessariamente. - -**Correção:** Remover do workspace até ter implementação real, ou manter apenas -com `niphas-core` e `tokio` como deps mínimas (já foi feito parcialmente). - ---- - -### P14. AppError sem Display/Error trait - -**Arquivo:** `niphas-eval/src/error.rs:5-13` - -`AppError` implementa `IntoResponse` mas não `Display` nem `Error`. -Não pode ser usado com `?` fora de handlers Axum. - -**Correção:** Adicionar `#[derive(Debug, thiserror::Error)]` com `#[error("...")]` em cada variante. - ---- - -### P15. humantime_serde hand-rolled - -**Arquivo:** `niphas-core/src/config.rs:288-333` - -Módulo `humantime_serde` reimplementa o que o crate `humantime-serde` já faz, -e suporta menos formatos ("300s", "5m", "1h" mas não "5m30s", "2h 30m"). - -**Correção:** Substituir pelo crate `humantime-serde = "1"`. - ---- - -### P16. Sem integration tests - -Nenhum crate tem diretório `tests/`. O fluxo operator -> eval -> CSI nunca é -testado end-to-end. - -**Correção:** Implementar conforme o plano de testes Tier 2 existente (kind cluster + nix binary). - ---- - -## Ordem de implementação sugerida - -``` -Fase 1 (segurança): - P1 Validação de flake_ref/attribute - P3 Validação de revision - P4 Semaphore no eval - P8 glob-match crate - -Fase 2 (correções operacionais): - P6 Ready flag - P11 UID vazio - P7 Builders retornam Result - P10 Runner image configurável - P12 Limpar deps não usadas - -Fase 3 (robustez): - P2 Leader election - P5 Limite de NAR size (fase 1) - P9 Cleanup do lock map - P14 AppError com thiserror - P15 humantime-serde crate - -Fase 4 (futuro): - P5 NAR streaming (fase 2) - P16 Integration tests - P13 Decidir sobre niphas-mesh -``` diff --git a/RESILIENCE.md b/RESILIENCE.md deleted file mode 100644 index 23f2400..0000000 --- a/RESILIENCE.md +++ /dev/null @@ -1,372 +0,0 @@ -# Niphas -- Resilience & Failure Handling - -Every design decision assumes the worst case: nodes die, networks partition, -caches go offline, and disks corrupt. Niphas follows K8s-native patterns -for all failure scenarios. - -## Node failure timeline - -When a node dies, K8s follows this sequence: - -| Time | Event | -|------|-------| -| T+0 | Node stops heartbeating | -| T+40s | `node-monitor-grace-period` expires. Node tainted `NotReady` + `unreachable:NoExecute` | -| T+40s + tolerationSeconds | Pods evicted. Default tolerationSeconds = 300s (5 min) | -| T+5m40s | Pods rescheduled on healthy nodes | - -Niphas workloads override the default toleration for faster failover: - -```yaml -tolerations: - - key: node.kubernetes.io/not-ready - operator: Exists - effect: NoExecute - tolerationSeconds: 30 - - key: node.kubernetes.io/unreachable - operator: Exists - effect: NoExecute - tolerationSeconds: 30 -``` - -Total failover: ~70 seconds instead of ~5m40s. - -## What happens when a pod is rescheduled - -``` -node-A dies - --> K8s evicts pods after tolerationSeconds - --> scheduler places pod on node-B - --> kubelet on node-B calls NodePublishVolume on niphas-csi - --> niphas-csi needs the closure - --> fetch chain: - 1. local cache /var/lib/niphas/cache/ (instant) - 2. niphas-mesh peers on other nodes (LAN, fast) - 3. binary cache HTTP (WAN, slower) - 4. fail --> kubelet retries with backoff -``` - -The pod enters `ContainerCreating` during fetch. kubelet retries -`NodePublishVolume` with exponential backoff up to 5 minutes, -identical to `ImagePullBackOff` behavior. - -## Fetch fallback chain - -The CSI driver tries sources in order. Each source is independent; -failure of one does not block the next. - -``` -1. Local NAR cache --> /var/lib/niphas/cache/-/ - instant, no network - always valid (content-addressed) - common case for workloads already running on this node - -2. Mesh P2P (libp2p) --> query local NAR index (gossipsub-populated) - "who has /nix/store/abc123?" - fetch NAR directly from peer (LAN speed) - timeout: 5s (configurable) - only available if mesh is enabled on this node - -3. Binary caches (HTTP) --> ordered by priority: - a. primary: company cache (e.g. Attic/Cachix) - b. secondary: cache.nixos.org - verify NarHash + signature - -4. gRPC Unavailable --> kubelet retries with backoff -``` - -Config via ConfigMap: - -```yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: niphas-csi-config - namespace: niphas-system -data: - config.yaml: | - binaryCaches: - - url: "https://cache.company.com" - priority: 1 - publicKey: "cache.company.com-1:..." - - url: "https://cache.nixos.org" - priority: 2 - publicKey: "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" - meshFetchEnabled: true - meshFetchTimeout: 5s - cache: - path: /var/lib/niphas/cache - highWatermark: 85 # start GC at 85% disk usage - lowWatermark: 75 # GC down to 75% -``` - -## Per-node lazy cache (no full replication) - -Niphas does **not** replicate closures to all nodes. Each node only -caches the NARs that its pods actually used. This is the standard -approach for storage drivers (Longhorn, OpenEBS, Rook-Ceph). - -When a pod is scheduled on a node, the CSI driver fetches the closure -through the fallback chain above. After extraction, the NAR stays in -the local cache at `/var/lib/niphas/cache/` for future use. - -Benefits: -- **Minimal resource usage**: nodes without Niphas workloads use zero - disk, zero RAM, zero CPU -- **No bandwidth overhead**: no background replication traffic -- **Scales to any cluster size**: 10 nodes or 10,000 nodes, same model -- **Simple**: no coordination, no anti-entropy, no cluster-wide sync - -The mesh (optional) accelerates fetches by allowing nodes to pull NARs -from peers that already have them (LAN speed vs WAN binary cache). -Nodes announce cached NARs via gossipsub `Have` messages so peers know -where to find them. - -Details: [`docs/MESH_PROTOCOL.md`](MESH_PROTOCOL.md) - -## Selective deployment - -CSI and mesh DaemonSets only run on nodes labeled `niphas.io/store=true`. -Nodes without this label have zero Niphas overhead. - -```bash -# Enable Niphas on specific nodes -kubectl label node worker-1 worker-2 worker-3 niphas.io/store=true - -# Mesh is optional -- enable per-node -kubectl label node worker-1 worker-2 worker-3 niphas.io/mesh=true -``` - -The operator adds `nodeSelector: { niphas.io/store: "true" }` to -generated Deployments so pods only land on prepared nodes. - -See the Helm chart section in [`docs/DEPLOY_FLOW.md`](DEPLOY_FLOW.md) -for installation details. - -## PodDisruptionBudget - -The operator creates a PDB for every NiphasWorkload with replicas >= 2: - -```yaml -apiVersion: policy/v1 -kind: PodDisruptionBudget -metadata: - name: -pdb - ownerReferences: - - apiVersion: niphas.io/v1alpha1 - kind: NiphasWorkload - name: - controller: true -spec: - maxUnavailable: 1 - selector: - matchLabels: - niphas.io/workload: - unhealthyPodEvictionPolicy: AlwaysAllow -``` - -- `maxUnavailable: 1` adapts automatically when replica count changes -- `unhealthyPodEvictionPolicy: AlwaysAllow` prevents broken pods from - blocking node drains (stable since K8s 1.31) -- No PDB for single-replica workloads (would block all voluntary disruptions) - -## Topology spread - -The operator injects topology constraints into workload pod templates: - -```yaml -topologySpreadConstraints: - - maxSkew: 1 - topologyKey: kubernetes.io/hostname - whenUnsatisfiable: DoNotSchedule - labelSelector: - matchLabels: - niphas.io/workload: - - maxSkew: 1 - topologyKey: topology.kubernetes.io/zone - whenUnsatisfiable: ScheduleAnyway - labelSelector: - matchLabels: - niphas.io/workload: -``` - -- Node spread: hard constraint (no two replicas on same node) -- Zone spread: best-effort (don't block scheduling if zones are unbalanced) -- If replicas > nodes, allows multiple pods per node gracefully - -## PriorityClass - -Infrastructure pods must survive resource pressure: - -```yaml -# CSI DaemonSet -- must be on every labeled node -priorityClassName: system-cluster-critical - -# Operator + Mesh -- important but below system-critical -apiVersion: scheduling.k8s.io/v1 -kind: PriorityClass -metadata: - name: niphas-infrastructure -value: 1000000 -preemptionPolicy: PreemptLowerPriority - -# User workloads -apiVersion: scheduling.k8s.io/v1 -kind: PriorityClass -metadata: - name: niphas-workload -value: 100000 -preemptionPolicy: PreemptLowerPriority -``` - -CSI uses `system-cluster-critical` because if the driver is evicted, -all pods on that node lose their volumes. Same pattern as EBS CSI, -GCE PD CSI, and every other production storage driver. - -## Leader election (operator) - -The operator runs with 2-3 replicas for HA. Only the leader reconciles. -Uses Lease-based election (coordination.k8s.io/v1): - -- Lease TTL: 15 seconds -- Renewal: every 5 seconds -- On leader death: new leader acquired within ~15s -- Reconciliation is idempotent, so brief dual-leader windows are safe - -```rust -// niphas-operator uses kube-leader-election crate -let lease = LeaseLock::new(client, "niphas-operator", LeaseLockParams { - holder_id: pod_name, - lease_ttl: Duration::from_secs(15), - retry_period: Duration::from_secs(5), - renew_period: Duration::from_secs(5), -}); -``` - -## Finalizers - -The operator adds `niphas.io/workload-cleanup` to every NiphasWorkload. -On deletion: -1. Operator sees `deletionTimestamp` is set -2. Cleans up: removes child resources (Deployment, Service, PDB) -3. Removes the finalizer -4. K8s completes deletion -5. CSI local cache GC eventually evicts unused NARs (LRU) - -If cleanup fails, the finalizer stays, the object remains in -`Terminating`, and the controller retries on next reconciliation. - -The operator manages the finalizer. No mesh-level finalizer needed -- -cache cleanup is handled by per-node LRU GC. - -## CSI error handling - -`NodePublishVolume` follows strict idempotency: - -``` -1. Already mounted correctly? --> return Ok (idempotent) -2. Partial mount from previous failure? --> cleanup first -3. Fetch closure (fallback chain) -4. Mount -5. On any failure: clean up partial state, return gRPC error -``` - -gRPC error codes: -- `Unavailable` -- transient (cache down, mesh timeout). kubelet retries aggressively -- `Internal` -- mount syscall failed. kubelet retries with backoff -- `InvalidArgument` -- bad storePath in volumeAttributes. User must fix CRD -- `NotFound` -- store path doesn't exist in any cache - -Critical: **never leave a partial mount at target_path on failure**. -kubelet may skip `NodePublishVolume` if it sees a filesystem at the -target path, assuming the mount succeeded. - -## Status conditions (eventual consistency) - -The CRD status follows K8s conventions for detecting desync: - -```yaml -status: - observedGeneration: 3 # matches metadata.generation when in sync - phase: Running - storePath: "/nix/store/abc123-hello-2.12.1" - closurePaths: # full closure for rescheduling - - "/nix/store/abc123-hello-2.12.1" - - "/nix/store/def456-glibc-2.38" - readyReplicas: 3 - conditions: - - type: Evaluated - status: "True" - reason: EvalSucceeded - message: "flake eval completed, store path resolved" - lastTransitionTime: "2026-06-05T12:00:00Z" - - type: ClosureCached - status: "True" - reason: CacheVerified - message: "closure available in binary cache" - lastTransitionTime: "2026-06-05T12:00:05Z" - - type: Available - status: "True" - reason: ReplicasReady - message: "3/3 replicas running" - lastTransitionTime: "2026-06-05T12:00:10Z" -``` - -Condition types: - -| Condition | True | False | -|-----------|------|-------| -| `Evaluated` | Nix eval succeeded | Eval failed or pending | -| `ClosureCached` | NAR available in binary cache | Fetch failed/pending | -| `Available` | >= 1 replica running | No replicas ready | -| `Progressing` | Rollout in progress | Stable | -| `Degraded` | Partial failure | Everything healthy | - -If `observedGeneration < metadata.generation`, the controller hasn't -processed the latest spec change. GitOps tools (Argo, Flux) use this -to detect drift. - -## Health checks - -### CSI DaemonSet - -```yaml -livenessProbe: - httpGet: - path: /healthz - port: 9808 - initialDelaySeconds: 10 - periodSeconds: 10 - timeoutSeconds: 3 - failureThreshold: 5 -``` - -The `livenessprobe` sidecar calls `Probe()` on the gRPC socket and -exposes `/healthz` HTTP. If the CSI driver hangs or crashes, kubelet -restarts it. - -### Closure integrity - -Store paths are content-addressed: the hash is the identity. The CSI -driver can verify integrity by re-hashing the cached NAR and comparing -against the expected NarHash. If corrupted: -1. Evict from local cache -2. Re-fetch from mesh or binary cache -3. Set `Degraded` condition on the NiphasWorkload - -## Failure scenario matrix - -| Failure | Impact | Recovery | -|---------|--------|----------| -| Single node dies | Pods rescheduled in ~70s. CSI on new node fetches closure: local cache (if previously used) -> mesh peers (LAN) -> binary cache (WAN) | Automatic | -| Binary cache down | No impact on existing workloads (local cache). New closures can still be fetched from mesh peers if any node has them | Automatic (if mesh or local cache available) | -| Binary cache + mesh both down | Existing closures still available on local cache. Only new, never-fetched closures fail | Partial manual | -| niphas-csi DaemonSet pod crashes | kubelet restarts it (liveness probe). Existing mounts survive | Automatic | -| niphas-operator pod dies | New leader elected in ~15s. Existing workloads continue | Automatic | -| niphas-eval pod dies | New workload evaluations fail. Existing workloads unaffected. Deployment restarts pod, eval cache on PVC survives | Automatic | -| Eval hangs (malicious flake) | Per-evaluation timeout (300s) enforced in Rust. Eval cancelled, workload marked Failed | Automatic | -| niphas-mesh pod dies | No P2P fetch from peers. CSI still reads local cache at `/var/lib/niphas/cache/` (shared hostPath). Falls back to binary cache HTTP for uncached paths. Mesh is optional -- CSI works standalone | Automatic | -| NAR corrupted on disk | CSI re-hashes, detects mismatch, evicts, re-fetches | Automatic | -| CRD deleted while pods running | Finalizer holds deletion. Cleanup runs. Then pods terminate | Automatic | -| etcd data loss | CRDs gone. Workloads stop. Must reapply from GitOps | Manual (GitOps redeploy) | -| All nodes die simultaneously | Total cluster failure. Standard K8s DR applies | Manual (cluster restore) | diff --git a/RUNTIME_MODEL.md b/RUNTIME_MODEL.md deleted file mode 100644 index 14f7301..0000000 --- a/RUNTIME_MODEL.md +++ /dev/null @@ -1,384 +0,0 @@ -# Niphas -- Runtime Model - -How a Nix package runs inside a Kubernetes pod without a container image. - -## The core insight - -A Nix closure is already more self-contained than an OCI image. -Every binary, every library, every data file lives at absolute -`/nix/store/--/` paths. No `/usr/lib`, no `/lib`, -no FHS paths. The closure contains everything needed to run the program. - -The OCI image is unnecessary. Niphas mounts the closure directly. - -## How Nix makes binaries self-contained - -### patchelf: the foundation - -When Nix builds a package, `patchelf` rewrites two ELF header fields: - -**1. PT_INTERP (the dynamic linker path)** - -Standard Linux binary: -``` -/lib64/ld-linux-x86-64.so.2 -``` - -Nix-built binary: -``` -/nix/store/4nlgxhb09sdr51nc9hdm8az5b08vzkgx-glibc-2.38/lib/ld-linux-x86-64.so.2 -``` - -**2. DT_RUNPATH (shared library search path)** - -Standard Linux binary: -``` -(empty, uses /lib, /usr/lib, ld.so.cache) -``` - -Nix-built binary: -``` -/nix/store/abc123-openssl-3.1/lib:/nix/store/def456-zlib-1.3/lib -``` - -The dynamic linker finds every `.so` via absolute paths. No system -library search. No `/etc/ld.so.cache`. No `LD_LIBRARY_PATH` needed. - -This happens automatically in stdenv's `postFixup` phase: -```bash -patchelf --set-interpreter "$(cat $NIX_CC/nix-support/dynamic-linker)" \ - --set-rpath "${lib.makeLibraryPath buildInputs}" \ - $out/bin/myprogram -``` - -### Closures: transitive dependency completeness - -After building, Nix scans the output for hash references to compute -runtime dependencies. If the binary contains the string -`4nlgxhb09sdr51nc9hdm8az5b08vzkgx` (the hash portion of a glibc store -path), that store path is marked as a runtime dependency. This is -recursive: each dependency's own references are included. - -The result is the **closure** -- every store path transitively referenced -by the root package. For a typical application: - -``` -/nix/store/abc123-myapp-1.0.0 # the application -/nix/store/def456-glibc-2.38 # C library + dynamic linker -/nix/store/ghi789-openssl-3.1 # TLS -/nix/store/jkl012-zlib-1.3 # compression -/nix/store/mno345-gcc-libs-13.2 # C++ runtime -/nix/store/pqr678-ca-certificates # root CAs -... # every transitive dependency -``` - -**Invariant**: copying the closure to another machine is sufficient to -run the program. No package manager, no install step, no dependency -resolution at runtime. - -### Static (musl) builds - -For `pkgsStatic` builds (musl libc, static linking): - -- No PT_INTERP (kernel loads the binary directly) -- No DT_RUNPATH (no shared libraries) -- Closure is just the single binary (+ data files if any) -- The simplest case: even `FROM scratch` works - -### patchShebangs - -Scripts get the same treatment. During `fixupPhase`, Nix rewrites: - -``` -#!/usr/bin/env python3 --> #!/nix/store/-python3-3.11/bin/python3 -#!/bin/sh --> #!/nix/store/-bash-5.2/bin/sh -``` - -No script references FHS paths after a Nix build. - -## How it runs in Kubernetes - -### The problem: K8s requires an image - -Every container in a Pod spec must have an `image` field. The API server -rejects pods without it. There is no escape from this requirement. - -### The solution: stub image + CSI volume - -``` -+---------------------------------------------+ -| Pod | -| | -| container: | -| image: niphas-runner:latest (~1 MB) | -| command: ["/nix/store/abc123-.../bin/app"]| -| | -| volumes: | -| - csi: | -| driver: niphas.io.csi | -| /nix/store (bind mount, read-only) | -| | | -+-------------|-------------------------------+ - | - v - /var/lib/niphas/cache/abc123-myapp-1.0.0/ - /var/lib/niphas/cache/def456-glibc-2.38/ - /var/lib/niphas/cache/ghi789-openssl-3.1/ - ... -``` - -The stub image satisfies K8s. The actual binary comes from the CSI volume. - -### The stub image: niphas-runner - -`niphas-runner` is a scratch-based OCI image (~1 MB) containing: - -``` -/etc/passwd # root:x:0:0:root:/root:/sbin/nologin - # nobody:x:65534:65534:nobody:/:/sbin/nologin -/etc/group # root:x:0: - # nobody:x:65534: -/etc/nsswitch.conf # passwd: files - # group: files - # hosts: files dns -/tmp/ # writable directory for programs that need it -``` - -That's it. No shell. No package manager. No libc. No dynamic linker. - -**Why no dynamic linker in the stub?** Because the Nix-built binary's -PT_INTERP already points to `/nix/store/-glibc-2.38/lib/ld-linux-x86-64.so.2`, -which is inside the CSI-mounted closure. The binary finds its own linker. - -**For musl-static binaries**: even the stub is overkill. A true `FROM scratch` -(0 bytes) image works because the binary has no PT_INTERP and the kernel -loads it directly. - -### The CSI volume: mounting the closure - -Niphas uses CSI inline ephemeral volumes (GA since K8s 1.25): - -```yaml -volumes: - - name: nix-closure - csi: - driver: niphas.io.csi - volumeAttributes: - closurePaths: "/nix/store/abc123-myapp-1.0.0,/nix/store/def456-glibc-2.38,..." -``` - -No PersistentVolume, no PersistentVolumeClaim, no StorageClass. The volume -lifecycle is tied to the pod. - -**Timing guarantee**: kubelet calls `NodePublishVolume` on the CSI driver -and waits for success *before* starting any container. There is no race -condition. The closure is fully mounted when the process starts. - -### Execution sequence - -``` -[1] kubelet reads pod spec, sees CSI volume - | - v -[2] kubelet calls NodePublishVolume on niphas-csi - | - v -[3] niphas-csi checks local cache at /var/lib/niphas/cache/ - for each store path in closurePaths: - - if cached: skip (content-addressed, always valid) - - if not cached: fetch via fallback chain: - mesh peers (LAN) -> binary cache (WAN) - - verify Ed25519 signature - - extract NAR to cache - | - v -[4] niphas-csi bind-mounts the cache directory at the target path - mount(source, target, NULL, - MS_BIND | MS_RDONLY | MS_NOSUID | MS_NODEV, NULL) - | - v -[5] kubelet sees NodePublishVolume returned OK - starts the container - | - v -[6] container runtime (containerd/CRI-O): - - creates rootfs from stub image (basically empty) - - adds /nix/store bind mount from CSI volume - - adds kubelet-managed mounts: /etc/resolv.conf, /etc/hosts, /proc, /sys - - execs the command: /nix/store/abc123-myapp-1.0.0/bin/myapp - | - v -[7] kernel loads the ELF binary - reads PT_INTERP: /nix/store/def456-glibc-2.38/lib/ld-linux-x86-64.so.2 - this file exists (it's in the mounted closure) - | - v -[8] dynamic linker (ld-linux) starts - reads DT_RUNPATH from the binary - loads all shared libraries from /nix/store/... paths - all exist (they're in the closure) - | - v -[9] program runs -``` - -For static (musl) binaries, steps 7-8 simplify: the kernel loads -the binary directly, no dynamic linker involved. - -## What kubelet provides automatically - -These are mounted into every container by the container runtime, -independent of the image or CSI volumes: - -| Path | Source | Writable | -|------|--------|----------| -| `/proc` | procfs | read-only (mostly) | -| `/sys` | sysfs | read-only | -| `/dev` | devtmpfs | device-specific | -| `/etc/resolv.conf` | kubelet (from node or pod dnsConfig) | no | -| `/etc/hosts` | kubelet (from pod spec + hostAliases) | no | -| `/etc/hostname` | kubelet | no | -| `/dev/termination-log` | kubelet | yes | - -Niphas does not need to provide any of these. - -## What the stub image provides - -| Path | Why | -|------|-----| -| `/etc/passwd` | `getpwuid()` calls (some programs check user identity) | -| `/etc/group` | `getgrgid()` calls | -| `/etc/nsswitch.conf` | tells glibc's NSS where to look for users/hosts | -| `/tmp/` | writable dir for programs that need temporary files | - -The operator also injects an `emptyDir` volume at `/tmp` in the generated -Deployment spec for programs that need writable temp space. - -## NSS and dlopen - -glibc uses `dlopen()` to load NSS modules (`libnss_files.so`, -`libnss_dns.so`) at runtime. This is a well-known challenge for Nix. - -**How it works in Niphas:** - -1. The Nix-patched glibc searches `$NIX_GLIBC_NSS_PATH` (a Nix extension) - for NSS modules -2. Basic NSS modules (`files`, `dns`) are shipped with glibc itself and - are included in every closure that depends on glibc -3. The stub image's `/etc/nsswitch.conf` restricts lookups to `files dns`, - which are the modules available in the closure - -For most server applications (HTTP APIs, databases, CLI tools), this is -sufficient. Complex NSS configurations (LDAP, SSSD) would require -additional packages in the closure. - -## Edge cases - -### Programs that hardcode /bin/sh - -C programs calling `system()` exec `/bin/sh`. Two solutions: - -1. **Package-level fix**: patch the source to use `execvp("sh", ...)` - instead of `system()` (the proper Nix approach) -2. **Stub image fallback**: include `/bin/sh` as a symlink to the - bash in the closure. Niphas can do this if `meta.needsShell = true` - -Well-packaged nixpkgs derivations already handle this via -`patchShebangs` and wrapper scripts. - -### GPU workloads (NVIDIA) - -GPU drivers have two components: - -- **Kernel modules** (`nvidia.ko`): run on the host, loaded by the - node's OS. Not part of any container. -- **Userspace libraries** (`libcuda.so`): must match the kernel module - version exactly. - -Niphas handles GPUs the same way as every other K8s storage driver: - -1. NVIDIA device plugin runs as a DaemonSet on GPU nodes -2. The device plugin injects userspace driver libraries via a volume mount -3. `LD_LIBRARY_PATH` is set to include the injected driver path -4. The Nix closure does not include GPU drivers (they're host-specific) - -This is the standard Kubernetes NVIDIA pattern. Niphas doesn't change it. - -### setuid binaries - -The Nix store cannot contain setuid binaries (security invariant). The -CSI volume is mounted with `MS_NOSUID`, so even if a setuid bit existed -it would not function. - -Programs that need elevated capabilities (e.g., `ping` needs -`CAP_NET_RAW`) should use Kubernetes `securityContext.capabilities.add` -instead of filesystem setuid bits. - -### dlopen for plugins - -Programs that `dlopen("libplugin.so")` by relative name need help -finding the library. Nix handles this via: - -1. **Patching dlopen calls**: the nixpkgs package patches source code - to use absolute `/nix/store/...` paths (most common approach) -2. **wrapProgram**: sets `LD_LIBRARY_PATH` in a wrapper script -3. **CRD env field**: the user can set `LD_LIBRARY_PATH` in - `spec.env` to include plugin paths from the closure - -Well-maintained nixpkgs packages already have dlopen paths patched. - -## Comparison with traditional containers - -| Aspect | OCI Container | Niphas (Nix closure) | -|--------|--------------|----------------------| -| **Image build** | Dockerfile, layer by layer | `nix build`, content-addressed | -| **Image size** | 50 MB - 2 GB typical | Closure: 50-500 MB, stub: ~1 MB | -| **Deduplication** | Per-layer (coarse, 256 max) | Per-store-path (fine-grained, unlimited) | -| **Reproducibility** | Best-effort (apt install = non-deterministic) | Guaranteed (same inputs = same hash) | -| **Registry** | Required (Docker Hub, ECR, GCR) | Not needed (binary cache serves NARs) | -| **Pull time** | Download all layers | Download only missing store paths | -| **Update granularity** | Re-push entire changed layers | Only new/changed store paths | -| **Rollback** | Re-tag or re-pull old image | Switch store path (atomic) | -| **Runtime deps** | Implicit (whatever is in the image) | Explicit (closure is the complete set) | -| **Security scanning** | Scan image layers for CVEs | Scan closure for CVEs (more precise) | - -## Why not just use dockerTools? - -nixpkgs provides `dockerTools.buildLayeredImage` which converts a Nix -closure into OCI layers. This works, but: - -1. **Adds a build step**: `nix build` -> OCI image -> push to registry -> pull -2. **Loses deduplication**: OCI layers are coarser than Nix store paths. - Two images sharing glibc but built at different times get different - layers (different hashes due to layer ordering) -3. **Requires a registry**: adds infrastructure dependency -4. **Layer limit**: OCI spec allows 128 layers. A closure with 300+ store - paths must merge some into fat layers, losing granularity -5. **Redundant wrapping**: the OCI image is just the closure in a different - format. Mounting the closure directly is more efficient - -Niphas skips the middleman. The binary cache *is* the registry. -The closure *is* the image. The CSI driver *is* the image puller. - -## Summary - -``` -Nix build patchelf closure binary cache - | | | | - v v v v -source code -> ELF binary -> all store paths -> NARs on HTTP - (absolute (glibc, ssl, (content- - /nix/store zlib, ...) addressed) - paths) - - CSI driver kubelet - | | - v v - fetch NARs -> extract to cache -> bind mount -> exec binary - /var/lib/niphas/ /nix/store /nix/store/ - cache/ (read-only) /bin/app -``` - -The entire chain is content-addressed. Same inputs produce the same -store path hash. Same hash fetches the same NAR. Same NAR extracts -the same files. Reproducible from source to running process. diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 4acd677..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,20 +0,0 @@ -# Security Policy - -## Reporting a Vulnerability - -If you discover a security vulnerability, please report it responsibly. - -- Email: security@niphas.io -- Expected response time: within 48 hours -- We will provide a fix within 30 days of disclosure - -## Supported Versions - -| Version | Supported | -|---------|-----------| -| 0.1.x | Yes | - -## Disclosure Policy - -We follow coordinated vulnerability disclosure. Please do not open public -issues for security vulnerabilities. diff --git a/SECURITY_DESIGN.md b/SECURITY_DESIGN.md deleted file mode 100644 index 039468f..0000000 --- a/SECURITY_DESIGN.md +++ /dev/null @@ -1,539 +0,0 @@ -# Niphas -- Security Design - -## Threat model - -Niphas runs privileged infrastructure on K8s clusters. Attack surface: - -1. **Compromised binary cache** serves malicious NARs with valid signatures -2. **Malicious flakeRef** in a NiphasWorkload CRD runs arbitrary Nix code -3. **Rogue mesh peer** joins the P2P network and injects bad data -4. **Privilege escalation** from CSI driver mount operations -5. **Lateral movement** through overly permissive RBAC or network access - -Every component follows least privilege. Each gets its own ServiceAccount, -RBAC, and NetworkPolicy. - -## RBAC - -### niphas-operator - -Broadest permissions. Reconciles CRDs, creates Deployments and PDBs. - -```yaml -apiVersion: v1 -kind: ServiceAccount -metadata: - name: niphas-operator - namespace: niphas-system ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: niphas-operator -rules: - - apiGroups: ["niphas.io"] - resources: ["niphasworkloads"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - - apiGroups: ["niphas.io"] - resources: ["niphasworkloads/status"] - verbs: ["get", "update", "patch"] - - apiGroups: ["niphas.io"] - resources: ["niphasworkloads/finalizers"] - verbs: ["update"] - - apiGroups: ["apps"] - resources: ["deployments"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - - apiGroups: ["policy"] - resources: ["poddisruptionbudgets"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - - apiGroups: [""] - resources: ["events"] - verbs: ["create", "patch"] - - apiGroups: [""] - resources: ["pods"] - verbs: ["get", "list", "watch"] ---- -# Leader election (namespace-scoped) -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: niphas-operator-leader-election - namespace: niphas-system -rules: - - apiGroups: ["coordination.k8s.io"] - resources: ["leases"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] -``` - -### niphas-csi - -**Zero RBAC.** Communicates with kubelet over Unix domain socket only. -Never contacts the K8s API. - -```yaml -apiVersion: v1 -kind: ServiceAccount -metadata: - name: niphas-csi-node - namespace: niphas-system - annotations: - automountServiceAccountToken: "false" -``` - -### niphas-mesh - -Read-only access to discover peers and read closure info. - -```yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: niphas-mesh -rules: - - apiGroups: ["niphas.io"] - resources: ["niphasworkloads"] - verbs: ["get", "list", "watch"] - - apiGroups: [""] - resources: ["pods"] - verbs: ["get", "list", "watch"] - - apiGroups: [""] - resources: ["nodes"] - verbs: ["get", "list", "watch"] -``` - -### niphas-eval - -Evaluates flakes via Nix C API (in-process, no Jobs), updates workload status. - -```yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: niphas-eval -rules: - - apiGroups: ["niphas.io"] - resources: ["niphasworkloads"] - verbs: ["get", "list", "watch"] - - apiGroups: ["niphas.io"] - resources: ["niphasworkloads/status"] - verbs: ["get", "update", "patch"] - - apiGroups: [""] - resources: ["events"] - verbs: ["create", "patch"] -``` - -No batch/jobs or pods RBAC needed -- evaluation happens in-process -via C FFI, not in separate Jobs. - -## Pod Security - -### CSI DaemonSet (needs mount privileges) - -```yaml -securityContext: - privileged: true - runAsUser: 0 - readOnlyRootFilesystem: true -``` - -The CSI driver requires `privileged: true` for bind mount operations -with `mountPropagation: Bidirectional`. This is the same approach used -by every production CSI driver (EBS CSI, GCE PD, Ceph, Longhorn). - -`CAP_SYS_ADMIN` alone is insufficient because Bidirectional mount -propagation requires the `privileged` flag in most container runtimes -(containerd, CRI-O). Attempting `CAP_SYS_ADMIN` without `privileged` -fails silently on many K8s distributions. - -The blast radius is contained: the CSI driver has zero RBAC (no K8s -API access), read-only rootfs, and only communicates via Unix socket -with kubelet. - -### All other components (Restricted PSS) - -```yaml -securityContext: - runAsNonRoot: true - runAsUser: 65534 - runAsGroup: 65534 - seccompProfile: - type: RuntimeDefault -containers: - - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - capabilities: - drop: ["ALL"] -``` - -### Namespace enforcement - -```yaml -apiVersion: v1 -kind: Namespace -metadata: - name: niphas-system - labels: - pod-security.kubernetes.io/enforce: baseline - pod-security.kubernetes.io/enforce-version: latest - pod-security.kubernetes.io/warn: restricted - pod-security.kubernetes.io/warn-version: latest - pod-security.kubernetes.io/audit: restricted - pod-security.kubernetes.io/audit-version: latest -``` - -Baseline enforced (CSI needs it). Restricted on warn/audit to track -which pods need elevation. - -## NetworkPolicy - -Default deny for the namespace, then explicit allow per component. - -```yaml -# Default deny all -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - name: default-deny-all - namespace: niphas-system -spec: - podSelector: {} - policyTypes: [Ingress, Egress] -``` - -### Per-component network access - -| Component | Ingress | Egress | -|-----------|---------|--------| -| operator | health probes (monitoring) | K8s API (443/6443), DNS | -| csi | liveness probe (kubelet) | binary cache HTTPS, mesh peer (4001), DNS | -| mesh | mesh peers (4001 TCP+UDP), CSI peer requests, monitoring | mesh peers (4001), K8s API, DNS | -| eval | webhook endpoint (8443 from operator) | git HTTPS (443), binary cache HTTPS, DNS | - -Each component has its own NetworkPolicy. The eval pod only needs -outbound HTTPS for fetching flake sources (git) and resolving closures -(binary cache .narinfo). No K8s API access needed beyond the minimal -RBAC for status updates. - -## NAR signature verification - -Every NAR fetched from any source (binary cache or mesh peer) must -be verified before extraction or use. - -### How Nix signatures work - -`.narinfo` contains: -``` -Sig: : -``` - -Signature covers the fingerprint: -``` -1;;;; -``` - -### Verification in niphas-csi - -```rust -use ed25519_dalek::{Signature, VerifyingKey, Verifier}; - -fn verify_narinfo( - narinfo: &NarInfo, - trusted_keys: &[TrustedKey], -) -> Result<(), NarVerificationError> { - let fingerprint = format!( - "1;{};{};{};{}", - narinfo.store_path, - narinfo.nar_hash, - narinfo.nar_size, - narinfo.references.join(",") - ); - - for sig in &narinfo.signatures { - for key in trusted_keys { - if sig.key_name == key.name { - let sig_bytes = base64_decode(&sig.signature)?; - let signature = Signature::from_bytes(&sig_bytes.try_into()?); - if key.pubkey.verify(fingerprint.as_bytes(), &signature).is_ok() { - return Ok(()); - } - } - } - } - - Err(NarVerificationError::NoTrustedSignature) -} -``` - -### On verification failure - -1. Reject the NAR. Never extract or cache it. -2. Log warning event with store path, cache URL, signatures present. -3. Set `Degraded` condition with reason `NarSignatureVerificationFailed`. -4. Fall back to next source in fetch chain. -5. If all sources fail: return gRPC `Unavailable`, kubelet retries. - -**Invariant: an unverified NAR never reaches a pod.** - -Mesh-fetched NARs go through the same verification. The mesh is a -transport layer, not a trust layer. - -### Trusted keys config - -```yaml -binaryCaches: - - url: "https://cache.nixos.org" - publicKey: "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" - requireSignature: true - - url: "https://cache.company.com" - publicKey: "cache.company.com-1:..." - requireSignature: true -``` - -## Eval sandboxing (highest risk) - -Nix evaluation executes arbitrary Nix code. A malicious flake can -read files, make network requests, and with IFD trigger builds. - -niphas-eval calls the Nix evaluator via C FFI (in-process, no Jobs). -The evaluator runs inside the niphas-eval Deployment pod with -multiple layers of defense. - -### Defense in depth (4 layers) - -**Layer 1: Nix evaluator settings (via C API)** - -```rust -// Applied before every evaluation via nix-bindings-rust -settings.sandbox = true; -settings.restrict_eval = true; -settings.allowed_uris = vec![ - "https://github.com", - "https://cache.nixos.org", -]; -settings.allow_import_from_derivation = false; // critical -settings.max_jobs = 0; // eval only, no builds -``` - -`allow-import-from-derivation = false` is critical: blocks IFD which -would allow executing derivations during eval. - -`max-jobs = 0` ensures no builds can happen even if IFD is somehow -bypassed. - -**Layer 2: Flake allowlist (deny-by-default)** - -```yaml -allowedFlakeOrigins: - - "github:myorg/*" - - "github:nixos/nixpkgs" -``` - -The eval webhook rejects any flakeRef not matching the allowlist -before the evaluator is invoked. Deny-by-default: if a flakeRef -does not match any entry, it is rejected. - -**Layer 3: Container isolation** - -```yaml -# niphas-eval Deployment pod -securityContext: - runAsNonRoot: true - runAsUser: 65534 - seccompProfile: - type: RuntimeDefault -containers: - - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - capabilities: - drop: ["ALL"] - resources: - limits: - cpu: "2" - memory: "4Gi" -``` - -The eval process runs as non-root, no capabilities, read-only rootfs. -The Nix store for eval is mounted on a writable volume (PVC or hostPath) -at `/nix/store` for caching evaluated derivations. - -Per-evaluation timeout enforced in Rust (e.g. `tokio::time::timeout(300s)`). - -**Layer 4: Network isolation** - -```yaml -# NetworkPolicy for niphas-eval pod -spec: - podSelector: - matchLabels: - app: niphas-eval - policyTypes: [Ingress, Egress] - ingress: - - from: - - podSelector: - matchLabels: - app: niphas-operator - ports: - - port: 8443 # webhook endpoint - egress: - - to: [] # git HTTPS (443) + DNS (53) - ports: - - port: 443 - protocol: TCP - - port: 53 - protocol: UDP - - port: 53 - protocol: TCP -``` - -niphas-eval can reach git (HTTPS) and DNS only. Cannot reach K8s API -(no RBAC for sensitive resources), cannot reach other pods except -via the webhook endpoint. - -## Mount isolation - -### Symlink attack prevention - -NAR extraction validates every entry: - -```rust -fn validate_nar_entry(entry: &NarEntry) -> Result<()> { - match entry { - NarEntry::Symlink { target } => { - // Reject symlinks pointing outside /nix/store/ - if !target.starts_with("/nix/store/") && !is_relative_within_store(target) { - return Err(NarExtractionError::SymlinkEscape); - } - } - NarEntry::Directory { name } => { - // Reject path traversal - if name.contains("..") || name.contains('/') { - return Err(NarExtractionError::PathTraversal); - } - } - _ => {} - } - Ok(()) -} -``` - -### Mount flags - -All bind mounts use: -- `MS_BIND | MS_RDONLY` (read-only) -- `MS_NOSUID` (no setuid binaries) -- `MS_NODEV` (no device nodes) - -```rust -mount( - Some(cache_path), target_path, - None::<&str>, - MsFlags::MS_BIND | MsFlags::MS_RDONLY | MsFlags::MS_NOSUID | MsFlags::MS_NODEV, - None::<&str>, -)?; -``` - -### Target path validation - -Before mounting, verify target is within kubelet's pod directory: - -```rust -fn validate_target_path(target: &Path) -> Result<()> { - let canonical = target.canonicalize()?; - if !canonical.starts_with("/var/lib/kubelet/pods/") { - return Err(CsiError::InvalidTargetPath(canonical)); - } - Ok(()) -} -``` - -Prevents a compromised kubelet from tricking the CSI driver into -mounting at an arbitrary host path. - -## Mesh authentication - -libp2p Noise XX handshake provides: -- **Mutual authentication**: both peers prove identity key possession -- **Perfect forward secrecy**: ephemeral Curve25519 keys per session -- **Encryption**: ChaCha20-Poly1305 post-handshake - -### Closed mesh - -Only cluster members can participate. Each mesh pod registers its -PeerId via K8s API (Pod annotation). Peers reject connections from -unknown PeerIds. - -```rust -let allowed_peers: HashSet = discover_cluster_peers().await?; - -swarm.behaviour_mut().set_connection_handler(move |peer_id| { - if !allowed_peers.contains(peer_id) { - return Err(ConnectionDenied::new("peer not in cluster")); - } - Ok(()) -}); -``` - -### CSI-to-mesh communication - -Uses Unix domain socket at `/var/run/niphas/mesh.sock` for same-node -communication. No TLS needed (inherently node-scoped, no network -exposure). - -## Secrets management - -| Secret | Storage | -|--------|---------| -| Binary cache signing keys (Ed25519 private) | External Secrets Operator (Vault, AWS SM) | -| Binary cache trusted public keys | ConfigMap (public data) | -| Webhook TLS certs (eval) | cert-manager with internal CA | -| Mesh identity keys (libp2p Ed25519) | K8s Secret per node, rotated | -| Git SSH keys (private flake repos) | K8s Secret type `kubernetes.io/ssh-auth` | - -Encryption at rest must be enabled for K8s Secrets (`EncryptionConfiguration`). - -## Container images - -| Component | Base image | User | Root FS | -|-----------|-----------|------|---------| -| operator | `scratch` (static musl binary) | 65534 | read-only | -| csi | distroless or `scratch` + musl | 0 (root, for mount) | read-only | -| mesh | `scratch` (static musl binary) | 65534 | read-only | -| eval | Nix-based image (contains Nix libs for C FFI) | 65534 | read-only + PVC for /nix/store | - -Built via `dockerTools.buildLayeredImage` in Nix (no shell, no pkg manager). -The eval image includes `libexpr-c`, `libstore-c`, `libflake-c` shared -libraries for the Nix C API, but no `nix` CLI binary. - -## Audit logging - -K8s API server audit policy: - -| Resource | Verbs | Level | -|----------|-------|-------| -| niphasworkloads | create, update, patch, delete | RequestResponse | -| niphasworkloads | get, list, watch | Metadata | -| niphasworkloads/status | update, patch | Request | -| secrets (niphas-system) | all | Metadata | - -Each component also emits structured logs: -- **operator**: reconciliation actions + results -- **eval**: flake evaluation requests + pass/fail -- **csi**: NAR fetch source + signature verification result -- **mesh**: peer connections + NAR transfers - -## Summary - -| Area | Approach | -|------|----------| -| RBAC | Separate SA per component. CSI gets zero RBAC. | -| PSS | CSI: privileged (required for mount propagation). Others: Restricted. | -| Network | Default deny + explicit allow per component. | -| NAR integrity | Mandatory Ed25519 verification on all sources. | -| Eval sandbox | 4 layers: Nix C API settings (IFD=false), allowlist, container isolation, network policy. | -| Mount safety | Symlink validation, read-only + nosuid + nodev, target path validation. | -| Mesh auth | libp2p Noise XX, closed mesh (PeerId allowlist). | -| Secrets | External Secrets Operator for private keys. cert-manager for TLS. | -| Images | scratch/distroless, read-only rootfs, non-root where possible. | -| Audit | K8s audit policy + structured component logs. | diff --git a/TESTING.md b/TESTING.md deleted file mode 100644 index a1b7ba9..0000000 --- a/TESTING.md +++ /dev/null @@ -1,723 +0,0 @@ -# Niphas -- Design de Testabilidade - -## Problema - -4.569 linhas de Rust, 32 testes cobrindo apenas parsing/crypto. Zero testes para operator, CSI, eval pipeline. A causa raiz nao e falta de testes -- e que o codigo mistura logica de negocio com IO, tornando impossivel testar sem infraestrutura real (cluster K8s, root, nix binary, rede). - -``` -Hoje: - reconcile() --> constroi JSON + chama K8s API + chama HTTP eval (tudo junto) - node_publish() --> valida input + chama cache HTTP + chama libc mount (tudo junto) - evaluate() --> valida allowlist + spawn nix + resolve closure HTTP (tudo junto) - -Queremos: - reconcile() --> build_deployment() [puro] + apply() [IO] - node_publish() --> validate() [puro] + cache.ensure() [injetavel] + mount [injetavel] - evaluate() --> validate() [puro] + eval [injetavel] + resolve [injetavel] -``` - ---- - -## Rust Patterns para Testabilidade - -### 1. Sans-IO: Separar logica de efeitos - -O pattern mais importante. A ideia: funcoes de negocio recebem dados e retornam dados. Nunca fazem IO diretamente. - -**Antes** (resources.rs atual): -```rust -async fn apply_deployment(ctx: &Context, workload: &NiphasWorkload, eval: &EvalResult) -> Result<()> { - // 200 linhas construindo JSON - let json = serde_json::json!({ /* ... */ }); - // IO misturado no fim - let api: Api = Api::namespaced(ctx.client.clone(), ns); - api.patch(name, ¶ms, &Patch::Apply(&json)).await?; - Ok(()) -} -``` - -**Depois**: -```rust -// PURO -- recebe dados, retorna dados. Testavel sem K8s. -fn build_deployment(workload: &NiphasWorkload, eval: &EvalResult, name: &str, ns: &str) -> serde_json::Value { - serde_json::json!({ /* ... */ }) -} - -// IO fino -- so aplica -async fn apply_resource(ctx: &Context, name: &str, ns: &str, manifest: &serde_json::Value) -> Result<()> { - let api: Api = Api::namespaced(ctx.client.clone(), ns); - api.patch(name, &PatchParams::apply(FIELD_MANAGER).force(), &Patch::Apply(manifest)).await?; - Ok(()) -} -``` - -Teste: -```rust -#[test] -fn deployment_always_has_nix_store_node_selector() { - let json = build_deployment(&workload, &eval, "app", "ns"); - assert_eq!(json["spec"]["template"]["spec"]["nodeSelector"]["niphas.io/store"], "true"); -} -``` - -**Onde aplicar no Niphas:** - -| Arquivo | Funcao atual | Separar em | -|---------|-------------|------------| -| `resources.rs` | `apply_deployment` | `build_deployment` (puro) + `apply_resource` (IO) | -| `resources.rs` | `apply_service` | `build_service` (puro) + `apply_resource` (IO) | -| `resources.rs` | `apply_ingress` | `build_ingress` (puro) + `apply_resource` (IO) | -| `resources.rs` | `apply_pdb` | `build_pdb` (puro) + `apply_resource` (IO) | -| `reconciler.rs` | `set_phase` | `build_status_patch` (puro) + `apply_status` (IO) | -| `reconciler.rs` | `set_failed` | `build_failed_patch` (puro) + `apply_status` (IO) | -| `evaluator.rs` | `nix_eval` (subprocess) | `build_nix_expr` (puro) + `run_nix` (IO) | - ---- - -### 2. Trait Injection: Generics com static dispatch - -Para dependencias que precisam ser substituidas em testes. Preferir generics sobre `dyn Trait` quando possivel (zero-cost, sem vtable). - -**Pattern: trait + generic no struct** - -```rust -// Definir a capability -#[async_trait] -pub trait BinaryCacheClient: Send + Sync { - async fn fetch_narinfo(&self, store_path: &StorePath) -> Result; - async fn fetch_nar(&self, narinfo: &NarInfo) -> Result; - async fn fetch_narinfo_by_hash(&self, hash: &str) -> Result; -} - -// Struct real implementa -impl BinaryCacheClient for CacheClient { /* reqwest calls */ } - -// Consumidores sao genericos com default -pub struct NarCache { - cache_dir: PathBuf, - client: C, - locks: Mutex>>>, -} - -// Em producao: NarCache (default, nao precisa anotar) -// Em testes: NarCache -``` - -**Tres traits a extrair no Niphas:** - -#### Trait 1: `BinaryCacheClient` (maior impacto) - -Desbloqueia testes de: `closure.rs`, `evaluator.rs`, CSI `cache.rs`. - -```rust -// niphas-core/src/nix/cache_client.rs -#[async_trait] -pub trait BinaryCacheClient: Send + Sync { - async fn fetch_narinfo(&self, store_path: &StorePath) -> Result; - async fn fetch_nar(&self, narinfo: &NarInfo) -> Result; - async fn fetch_narinfo_by_hash(&self, hash: &str) -> Result; -} -``` - -Consumidores mudam: -```rust -// closure.rs: antes -pub async fn resolve_closure(client: &CacheClient, ...) -> ... -// closure.rs: depois -pub async fn resolve_closure(client: &C, ...) -> ... -``` - -#### Trait 2: `MountOps` (CSI) - -Desbloqueia testes de: `node.rs` (12 branches de publish/unpublish). - -```rust -// niphas-csi/src/mount.rs -pub trait MountOps: Send + Sync { - fn is_mountpoint(&self, path: &str) -> bool; - fn setup_target_dir(&self, path: &str) -> io::Result<()>; - fn bind_mount_readonly(&self, source: &str, target: &str) -> io::Result<()>; - fn unmount(&self, target: &str) -> io::Result<()>; - fn cleanup_target(&self, target: &str) -> io::Result<()>; -} - -pub struct LinuxMountOps; -impl MountOps for LinuxMountOps { /* libc::mount calls */ } - -// NodeService fica generico -pub struct NodeService { - node_id: String, - cache: Arc, - mount: M, -} -``` - -#### Trait 3: `NixEval` (eval subprocess) - -Desbloqueia testes de: `evaluator.rs` (pipeline completo). - -```rust -// niphas-eval/src/evaluator.rs -#[async_trait] -pub trait NixEval: Send + Sync { - async fn eval(&self, pinned_ref: &str, attribute: &str) -> Result; -} - -// Producao: chama tokio::process::Command("nix") -pub struct SubprocessNixEval { timeout: Duration } -impl NixEval for SubprocessNixEval { /* spawn nix eval */ } - -// Evaluator generico -pub struct Evaluator { - config: NiphasConfig, - nix: E, - cache: C, - warm: AtomicBool, -} -``` - ---- - -### 3. Fakes sobre Mocks - -Preferir **fakes** (implementacoes simplificadas que funcionam) sobre **mocks** (verificam chamadas). Fakes sao mais robustos a refactoring e capturam bugs reais. - -```rust -// FAKE -- implementacao in-memory que funciona -struct FakeCacheClient { - entries: HashMap, -} - -#[async_trait] -impl BinaryCacheClient for FakeCacheClient { - async fn fetch_narinfo(&self, sp: &StorePath) -> Result { - self.entries.get(&sp.hash_str()) - .cloned() - .ok_or(NiphasError::StorePathNotCached(sp.hash_str())) - } - async fn fetch_nar(&self, _ni: &NarInfo) -> Result { - Ok(Bytes::from_static(b"fake")) - } - async fn fetch_narinfo_by_hash(&self, hash: &str) -> Result { - self.entries.get(hash) - .cloned() - .ok_or(NiphasError::StorePathNotCached(hash.into())) - } -} - -// FAKE mount -- sem syscalls, tracking de chamadas -struct FakeMountOps { - mounted: Mutex>, -} - -impl MountOps for FakeMountOps { - fn is_mountpoint(&self, path: &str) -> bool { - self.mounted.lock().unwrap().contains(path) - } - fn bind_mount_readonly(&self, _source: &str, target: &str) -> io::Result<()> { - self.mounted.lock().unwrap().insert(target.into()); - Ok(()) - } - fn unmount(&self, target: &str) -> io::Result<()> { - self.mounted.lock().unwrap().remove(target); - Ok(()) - } - fn setup_target_dir(&self, _: &str) -> io::Result<()> { Ok(()) } - fn cleanup_target(&self, _: &str) -> io::Result<()> { Ok(()) } -} -``` - -**Quando usar mock (mockall) em vez de fake:** -- Quando voce precisa verificar que uma funcao foi chamada N vezes -- Quando precisa verificar a ordem das chamadas -- No Niphas: apenas para testes do reconciler verificando que status patches especificos foram enviados - ---- - -### 4. Test Fixtures: Builder Pattern - -Construir CRDs e structs de teste e verboso. Builders com defaults sensiveis eliminam boilerplate. - -```rust -// niphas-core/src/testutils.rs (feature-gated) - -pub struct WorkloadBuilder { - name: String, - ns: String, - flake_ref: String, - replicas: i32, - phase: Option, - store_path: Option, - generation: i64, - finalizers: Vec, - deletion: bool, -} - -impl WorkloadBuilder { - pub fn new(name: &str) -> Self { - Self { - name: name.into(), - ns: "default".into(), - flake_ref: "github:test/repo#pkg".into(), - replicas: 1, - phase: None, - store_path: None, - generation: 1, - finalizers: vec![], - deletion: false, - } - } - - pub fn replicas(mut self, n: i32) -> Self { self.replicas = n; self } - pub fn phase(mut self, p: &str) -> Self { self.phase = Some(p.into()); self } - pub fn evaluated(mut self, store_path: &str) -> Self { - self.phase = Some("Evaluated".into()); - self.store_path = Some(store_path.into()); - self - } - pub fn with_finalizer(mut self) -> Self { - self.finalizers.push("niphas.io/workload-cleanup".into()); - self - } - pub fn deleting(mut self) -> Self { self.deletion = true; self } - pub fn generation(mut self, g: i64) -> Self { self.generation = g; self } - - pub fn build(self) -> NiphasWorkload { /* constroi o CRD */ } -} - -// Uso em testes: -let w = WorkloadBuilder::new("app").replicas(3).evaluated("/nix/store/abc-hello").build(); -``` - -**Cross-crate sharing**: usar feature flag `testutils` em vez de `#[cfg(test)]`: - -```toml -# niphas-core/Cargo.toml -[features] -testutils = [] - -# niphas-operator/Cargo.toml -[dev-dependencies] -niphas-core = { workspace = true, features = ["testutils"] } -``` - -Isso resolve o problema de `#[cfg(test)]` nao ser visivel entre crates (cargo issue #8379). - ---- - -### 5. Snapshot Testing com insta - -Para outputs complexos (JSON de K8s resources, respostas HTTP, schemas CRD), snapshots sao melhores que assertions manuais. Eles capturam regressoes que voce nao pensou em testar. - -```rust -use insta::assert_json_snapshot; - -#[test] -fn deployment_snapshot_basic() { - let w = WorkloadBuilder::new("hello").replicas(1).build(); - let e = EvalResultBuilder::new("/nix/store/abc-hello").build(); - let json = build_deployment(&w, &e, "hello", "default"); - - assert_json_snapshot!("deployment-basic", json); - // Primeira execucao: cria snapshots/deployment-basic.snap - // Proximas: compara. cargo insta review para aceitar mudancas. -} - -#[test] -fn deployment_snapshot_multi_replica() { - let w = WorkloadBuilder::new("hello").replicas(3).build(); - let e = EvalResultBuilder::new("/nix/store/abc-hello").build(); - let json = build_deployment(&w, &e, "hello", "default"); - - assert_json_snapshot!("deployment-multi-replica", json); - // Verifica: topologySpreadConstraints presente, PDB criado -} -``` - -**Redactions para campos nao-deterministicos:** -```rust -assert_json_snapshot!(status, { - ".lastTransitionTime" => "[timestamp]", - ".observedGeneration" => "[gen]", -}); -``` - -**Onde usar no Niphas:** - -| Alvo | Por que snapshot | -|------|-----------------| -| `build_deployment` JSON | 200+ linhas de construcao condicional, regressoes sutis | -| `build_service` JSON | Mapeamento de portas, tipo de servico | -| `build_ingress` JSON | TLS, paths, hosts -- facil quebrar | -| `build_pdb` JSON | Condicional em replicas >= 2 | -| `AppError::into_response()` | Status codes + JSON body por variante | -| CRD schema (`NiphasWorkload::crd()`) | Schema drift entre versoes | -| Status patches do reconciler | JSON patches complexos | - ---- - -### 6. Property Testing com proptest - -Para parsers de protocolo e codecs, property tests encontram edge cases que testes manuais nunca cobrem. Especialmente valioso para o formato NAR (binary protocol com invariantes estritas). - -```rust -use proptest::prelude::*; - -// Gerar caracteres validos de nix-base32 -fn nix_base32_char() -> impl Strategy { - prop::sample::select("0123456789abcdfghijklmnpqrsvwxyz".chars().collect::>()) -} - -// Gerar store paths validos -fn arb_store_path() -> impl Strategy { - ( - prop::collection::vec(nix_base32_char(), 32), - "[a-z][a-z0-9._-]{0,20}" - ).prop_map(|(hash, name)| { - let h: String = hash.into_iter().collect(); - format!("/nix/store/{h}-{name}") - }) -} - -proptest! { - // Roundtrip: parse(display(x)) == x - #[test] - fn store_path_roundtrip(path in arb_store_path()) { - let parsed = StorePath::parse(&path).unwrap(); - let roundtripped = StorePath::parse(&parsed.to_path_string()).unwrap(); - prop_assert_eq!(parsed.hash_str(), roundtripped.hash_str()); - prop_assert_eq!(parsed.name(), roundtripped.name()); - } - - // Nix-base32: decode(encode(x)) == x - #[test] - fn nix_base32_roundtrip(data in prop::collection::vec(any::(), 0..64)) { - let encoded = nix_base32_encode(&data); - let decoded = nix_base32_decode(&encoded).unwrap(); - prop_assert_eq!(data, decoded); - } - - // narinfo parser nunca faz panic em input arbitrario - #[test] - fn narinfo_no_panic(input in "\\PC*") { - let _ = NarInfo::parse(&input); // Err e ok, panic nao - } - - // NAR entry names: validate rejeita todos os padroes proibidos - #[test] - fn nar_entry_name_rejects_forbidden( - name in prop_oneof![ - Just("".to_string()), - Just(".".to_string()), - Just("..".to_string()), - ".*/.+", // contem / - ".*\0.+", // contem null - ] - ) { - prop_assert!(validate_entry_name(&name).is_err()); - } -} -``` - -**Invariantes property-testaveis no Niphas:** - -| Modulo | Invariante | Property | -|--------|-----------|----------| -| `hash.rs` | base32 roundtrip | `decode(encode(x)) == x` | -| `store_path.rs` | parse roundtrip | `parse(display(x)) == x` | -| `nar.rs` | padding e 0..7 bytes de zeros | `pad_len(n) == (8 - n%8) % 8` | -| `nar.rs` | entries devem ser sorted | entries fora de ordem -> Err | -| `nar.rs` | depth limit funciona | depth > max -> Err | -| `narinfo.rs` | no panic on arbitrary input | `parse(random) != panic` | -| `config.rs` | duration parse roundtrip | `parse(format(d)) == d` | - ---- - -### 7. kube-rs Operator Testing: tower_test - -O pattern canonico de [controller-rs](https://github.com/kube-rs/controller-rs). `kube::Client` aceita qualquer `tower::Service` -- injetamos um mock. - -```rust -use tower_test::mock; -use kube::client::Body; - -fn mock_client() -> (kube::Client, mock::Handle, http::Response>) { - let (svc, handle) = mock::pair(); - let client = kube::Client::new(svc, "default"); - (client, handle) -} - -// Verificador de cenario -struct ApiServerVerifier(mock::Handle, http::Response>); - -impl ApiServerVerifier { - async fn expect_status_patch(mut self, expected_phase: &str) -> Self { - let (req, send) = self.0.next_request().await.expect("expected API call"); - assert_eq!(req.method(), http::Method::PATCH); - assert!(req.uri().to_string().contains("/status")); - // Verificar body contem a phase esperada - send.send_response(http::Response::builder() - .body(Body::from("{}")) - .unwrap()); - self - } -} - -#[tokio::test] -async fn reconcile_new_workload_adds_finalizer() { - let (client, handle) = mock_client(); - let ctx = Arc::new(Context::test(client)); - let workload = WorkloadBuilder::new("app").build(); // sem finalizer - - let verifier = ApiServerVerifier(handle); - let mock_task = tokio::spawn(async move { - verifier.expect_finalizer_patch().await; - }); - - let result = reconcile(Arc::new(workload), ctx).await.unwrap(); - assert_eq!(result, Action::requeue(Duration::ZERO)); - mock_task.await.unwrap(); -} -``` - ---- - -### 8. Tonic gRPC Testing: Chamada direta - -Servicos tonic sao traits Rust. Chamar os metodos diretamente sem transporte de rede: - -```rust -use tonic::Request; -use crate::csi::identity_server::Identity; - -#[tokio::test] -async fn identity_returns_correct_name() { - let svc = IdentityService; - let resp = svc.get_plugin_info(Request::new(GetPluginInfoRequest {})) - .await - .unwrap(); - assert_eq!(resp.into_inner().name, "niphas.io.csi"); -} - -#[tokio::test] -async fn publish_rejects_empty_volume_id() { - let svc = NodeService::new("node-1".into(), cache, FakeMountOps::new()); - let req = NodePublishVolumeRequest { - volume_id: "".into(), - ..Default::default() - }; - let err = svc.node_publish_volume(Request::new(req)).await.unwrap_err(); - assert_eq!(err.code(), tonic::Code::InvalidArgument); -} -``` - ---- - -### 9. Axum Handler Testing: tower oneshot - -```rust -use axum::body::Body; -use http::Request; -use tower::ServiceExt; - -#[tokio::test] -async fn healthz_returns_200() { - let app = health_router(HealthState { ready: Arc::new(AtomicBool::new(false)) }); - let resp = app - .oneshot(Request::get("/healthz").body(Body::empty()).unwrap()) - .await - .unwrap(); - assert_eq!(resp.status(), 200); -} - -#[tokio::test] -async fn readyz_returns_503_when_cold() { - let app = health_router(HealthState { ready: Arc::new(AtomicBool::new(false)) }); - let resp = app - .oneshot(Request::get("/readyz").body(Body::empty()).unwrap()) - .await - .unwrap(); - assert_eq!(resp.status(), 503); -} -``` - ---- - -## Tiering - -### Tier 1 -- Fast (< 5s, todo commit) - -Sem rede, sem K8s, sem root, sem nix. Usa fakes, builders, snapshots. - -```bash -cargo test --workspace -``` - -### Tier 2 -- Integration (CI com setup) - -Precisa de infra. Marcados `#[ignore]`. - -```bash -cargo test --workspace -- --ignored -``` - -| Tier | Requer | Exemplos | -|------|--------|----------| -| T1 | Nada | builders puros, closure BFS com fake cache, CSI publish com fake mount | -| T2 | kind cluster | reconciler contra API real, CRD lifecycle | -| T2 | nix binary | nix eval real, closure resolution contra cache.nixos.org | -| T2 | root | bind mount + unmount real | - ---- - -## Organizacao de Arquivos - -``` -crates/ - niphas-core/ - src/ - testutils.rs # feature "testutils": builders, fakes, helpers - nix/ - cache_client.rs # BinaryCacheClient trait + CacheClient impl - closure.rs # generico sobre BinaryCacheClient, inline tests com fake - nar.rs # inline tests existentes + proptest - hash.rs # inline tests existentes + proptest roundtrip - narinfo.rs # inline tests existentes - store_path.rs # inline tests existentes + proptest roundtrip - signature.rs # inline tests existentes - config.rs # inline tests novos - Cargo.toml # [features] testutils = [] - - niphas-operator/ - src/ - reconciler.rs # inline tests para funcoes puras - resources.rs # build_* puras + inline tests + snapshots - eval.rs # inline tests para EvalResult::from_status, resolved_command - health.rs # inline tests com oneshot - context.rs # http_client adicionado - tests/ - reconciler_mock.rs # tower_test scenarios (T1) - kind_integration.rs # #[ignore] (T2) - Cargo.toml # [dev-dependencies] tower-test, insta, niphas-core/testutils - - niphas-eval/ - src/ - evaluator.rs # generico sobre NixEval + BinaryCacheClient - error.rs # inline tests + snapshots - handlers.rs # inline tests com oneshot - tests/ - eval_integration.rs # #[ignore] (T2) - - niphas-csi/ - src/ - mount.rs # MountOps trait + LinuxMountOps - node.rs # generico sobre MountOps, inline tests com FakeMountOps - identity.rs # inline tests (chamada direta tonic) - cache.rs # generico sobre BinaryCacheClient - tests/ - mount_integration.rs # #[ignore] (T2) -``` - ---- - -## Dependencias de Teste - -```toml -# Cargo.toml (workspace) -[workspace.dependencies] -async-trait = "0.1" -proptest = "1" -insta = { version = "1", features = ["yaml", "json"] } -tower-test = "0.4" -``` - -```toml -# niphas-core/Cargo.toml -[dependencies] -async-trait = { workspace = true } # BinaryCacheClient trait e publico - -[dev-dependencies] -proptest = { workspace = true } -insta = { workspace = true } -tokio = { workspace = true, features = ["test-util"] } -``` - -```toml -# niphas-operator/Cargo.toml -[dev-dependencies] -tower-test = { workspace = true } -tower = { workspace = true, features = ["util"] } -insta = { workspace = true } -niphas-core = { workspace = true, features = ["testutils"] } -http = "1" -``` - -```toml -# niphas-csi/Cargo.toml -[dev-dependencies] -niphas-core = { workspace = true, features = ["testutils"] } -``` - -```toml -# niphas-eval/Cargo.toml -[dev-dependencies] -tower = { workspace = true, features = ["util"] } -niphas-core = { workspace = true, features = ["testutils"] } -http = "1" -http-body-util = "0.1" -``` - ---- - -## Ordem de Execucao - -``` -Passo 1: Foundation - 1.1 async-trait + BinaryCacheClient trait em cache_client.rs - 1.2 Atualizar closure.rs para generico - 1.3 Feature "testutils" com WorkloadBuilder + FakeCacheClient - 1.4 Testes de closure com FakeCacheClient (8 testes) - -Passo 2: Operator - 2.1 Split build_*/apply_* em resources.rs - 2.2 Snapshot tests dos builders (insta) - 2.3 Testes puros: needs_eval, has_finalizer, error_policy - 2.4 Testes puros: EvalResult::from_status, resolved_command - 2.5 Health handler tests com oneshot - 2.6 HTTP client no Context - -Passo 3: CSI - 3.1 MountOps trait + LinuxMountOps - 3.2 NodeService generico - 3.3 FakeMountOps + testes node publish/unpublish - 3.4 Identity service tests (chamada direta) - -Passo 4: Eval - 4.1 NixEval trait + SubprocessNixEval - 4.2 Evaluator generico - 4.3 Error response tests + snapshots - 4.4 Handler tests com oneshot - -Passo 5: Property Tests - 5.1 proptest para hash roundtrip - 5.2 proptest para store_path roundtrip - 5.3 proptest para narinfo no-panic - 5.4 proptest para NAR entry name validation - -Passo 6: Tier 2 - 6.1 mount_integration.rs (#[ignore]) - 6.2 eval_integration.rs (#[ignore]) - 6.3 kind_integration.rs (#[ignore]) -``` - -## Resultado Esperado - -| Metrica | Antes | Depois | -|---------|-------|--------| -| Testes Tier 1 | 32 | ~100 | -| Crates com testes | 2/5 | 4/5 | -| Tempo Tier 1 | <1s | <5s | -| Traits extraidos | 0 | 3 | -| Snapshot tests | 0 | ~15 | -| Property tests | 0 | ~8 | -| Test builders | 0 | WorkloadBuilder, EvalResultBuilder, NarInfoBuilder | From 7f419c1b91f763dc68e3753986684da11acb96dd Mon Sep 17 00:00:00 2001 From: fullzer4 Date: Tue, 16 Jun 2026 19:40:06 -0300 Subject: [PATCH 24/24] chore: remove LICENSE --- LICENSE | 190 -------------------------------------------------------- 1 file changed, 190 deletions(-) delete mode 100644 LICENSE diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 8acfc37..0000000 --- a/LICENSE +++ /dev/null @@ -1,190 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to the Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by the Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding any notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - Copyright 2025 Niphas Contributors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License.