mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
feat(registry,attestation): trust + capability registry foundation
Build the GPU-runtime trust registry that gates every dispatch decision
against typed capability records and a deterministic attestation root.
Reference: LP-137-TRUST-REGISTRY.md.
pkg/registry:
- types.go: WorkerCapability, NodeCapability, GPUArch, GPUBackend,
PeerInterconnect with Validate() that enforces TEE-arch + attestation
invariants at the API edge.
- policy.go: LanePolicy (operator-controlled), WorkloadPolicy
(per-request), and a single canonical Eligible(lane, workload, worker)
check that runs twelve gates in fixed order. Privacy-class consistency
is re-checked on the hot path so a stale worker record cannot bypass
the API edge.
pkg/attestation:
- composite.go: CompositeAttestation envelope over CPU TEE + GPU TEE +
platform evidence. Root() is RFC 8949 §4.2 deterministic CBOR via
fxamacker/cbor (already a direct dep) followed by SHA-256. The same
encoder is used by Marshal so Root and the wire form agree byte-for-
byte. Validate() rejects malformed envelopes including
composite-trust-without-GPU-evidence.
Tests (103 pass): truth-table eligibility coverage, gate-ordering
regression, sensitivity-to-every-field digest, deterministic-bytes
round-trip, garbage-decode rejection.
go.mod: promote luxfi/lattice/v7 from indirect to direct require, add
local replace pointing at ../lattice (mirrors the existing fhe pattern).
Drop the replace once luxfi/lattice publishes a tag containing
types/trust.go.
This commit is contained in:
@@ -42,6 +42,7 @@ require (
|
||||
github.com/nats-io/nats.go v1.50.0
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/rs/zerolog v1.35.0
|
||||
github.com/luxfi/lattice/v7 v7.0.2
|
||||
github.com/samber/lo v1.53.0
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
@@ -147,7 +148,6 @@ require (
|
||||
github.com/luxfi/container v0.0.4 // indirect
|
||||
github.com/luxfi/geth v1.16.79 // indirect
|
||||
github.com/luxfi/ids v1.2.9 // indirect
|
||||
github.com/luxfi/lattice/v7 v7.0.2 // indirect
|
||||
github.com/luxfi/math v1.2.4 // indirect
|
||||
github.com/luxfi/math/big v0.1.0 // indirect
|
||||
github.com/luxfi/mdns v0.1.0 // indirect
|
||||
@@ -239,6 +239,11 @@ replace github.com/luxfi/log v1.3.0 => github.com/luxfi/log v1.4.1
|
||||
// release tag that includes the threshold/ package.
|
||||
replace github.com/luxfi/fhe => ../fhe
|
||||
|
||||
// Local development against the trust enums added in
|
||||
// luxfi/lattice/v7/types (LP-137-TRUST-REGISTRY). Drop this once
|
||||
// luxfi/lattice cuts a release tag that includes types/trust.go.
|
||||
replace github.com/luxfi/lattice/v7 => ../lattice
|
||||
|
||||
// luxfi/genesis v1.5.21 / v1.5.22 are missing from proxy.golang.org; no
|
||||
// luxfi/mpc code path imports them, but transitive resolution can pick them
|
||||
// up. Excluded until genesis retags.
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
// Copyright (c) 2026, Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package attestation defines the composite attestation envelope that the
|
||||
// Lux GPU Runtime uses to bind worker capability records to verifiable
|
||||
// hardware evidence. A CompositeAttestation aggregates evidence from
|
||||
// multiple roots of trust:
|
||||
//
|
||||
// - CPU TEE quote (AMD SEV-SNP, Intel TDX, ARM CCA, ...)
|
||||
// - GPU attestation report (NVIDIA NRAS for Hopper / Blackwell, AMD
|
||||
// equivalents when published)
|
||||
// - Platform / firmware measurements (UEFI, kernel, microcode)
|
||||
// - Optional verifier-specific evidence (Intel Trust Authority verdict,
|
||||
// NRAS device-state report, fraud-proof commitment, ZK proof)
|
||||
//
|
||||
// The Root() method returns the SHA-256 of the deterministically encoded
|
||||
// envelope. That root is what:
|
||||
//
|
||||
// - the worker stores in WorkerCapability.AttestationRoot
|
||||
// - the scheduler logs and indexes
|
||||
// - downstream verifiers re-derive locally to detect tampering
|
||||
//
|
||||
// Determinism is RFC 8949 §4.2 Core Deterministic CBOR via fxamacker/cbor.
|
||||
// We chose CBOR (not custom binary) because the off-chain composite
|
||||
// attestation envelope shape is the de-facto standard used by NVIDIA NRAS
|
||||
// and Intel Trust Authority output, and because mpc already uses CBOR for
|
||||
// the canonical-intent + FROST/LSS config marshalers (see
|
||||
// pkg/intent/canonical.go::Digest). One library, one canonical encoding.
|
||||
//
|
||||
// References:
|
||||
//
|
||||
// - LP-137-TRUST-REGISTRY.md
|
||||
// - NVIDIA Remote Attestation Service (NRAS) report v1, 2024
|
||||
// - NVIDIA Confidential Compute Whitepaper, Hopper / Blackwell
|
||||
// - Intel Trust Authority composite attestation specification, 2024
|
||||
// - RFC 8949 (CBOR), RFC 9090 (CBOR Tags)
|
||||
package attestation
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
lattice "github.com/luxfi/lattice/v7/types"
|
||||
)
|
||||
|
||||
// EnvelopeVersion is the schema version of the CompositeAttestation
|
||||
// envelope. Bump only on breaking changes; old envelopes never silently
|
||||
// match new ones because the version field participates in the digest.
|
||||
const EnvelopeVersion = "1"
|
||||
|
||||
// EvidenceKind identifies the source of a single evidence blob inside the
|
||||
// envelope. Stable string values are part of the wire / log contract.
|
||||
type EvidenceKind string
|
||||
|
||||
const (
|
||||
// EvidenceCPUTeeQuote: a CPU TEE quote (SEV-SNP AttestationReport,
|
||||
// Intel TDX TDREPORT, ARM CCA report). Carried opaquely; the verifier
|
||||
// re-validates against the vendor's verification service.
|
||||
EvidenceCPUTeeQuote EvidenceKind = "cpu_tee_quote"
|
||||
|
||||
// EvidenceGPUNRASReport: a NVIDIA NRAS device-state report for one
|
||||
// GPU. One blob per GPU; multi-GPU nodes carry one entry per device.
|
||||
EvidenceGPUNRASReport EvidenceKind = "gpu_nras_report"
|
||||
|
||||
// EvidenceGPUVendorReport: a non-NVIDIA GPU attestation blob (AMD,
|
||||
// Intel). Reserved for when these vendors publish equivalents.
|
||||
EvidenceGPUVendorReport EvidenceKind = "gpu_vendor_report"
|
||||
|
||||
// EvidencePlatformMeasurement: UEFI / kernel / microcode measurement
|
||||
// log. One per node, not per worker.
|
||||
EvidencePlatformMeasurement EvidenceKind = "platform_measurement"
|
||||
|
||||
// EvidenceTrustAuthorityVerdict: Intel Trust Authority verdict over
|
||||
// the composite quote. Optional; presence + verdict-pass are enforced
|
||||
// by the verifier, not by this package.
|
||||
EvidenceTrustAuthorityVerdict EvidenceKind = "trust_authority_verdict"
|
||||
|
||||
// EvidenceFraudProofCommitment: a fraud-proof commitment hash for
|
||||
// verifiable-compute lanes (LP-025).
|
||||
EvidenceFraudProofCommitment EvidenceKind = "fraud_proof_commitment"
|
||||
|
||||
// EvidenceZKProof: a zero-knowledge proof of correct execution
|
||||
// (LP-013 §ZK). The proof bytes themselves; the verifier holds the
|
||||
// matching verification key.
|
||||
EvidenceZKProof EvidenceKind = "zk_proof"
|
||||
)
|
||||
|
||||
// IsValid reports whether k is one of the defined evidence kinds.
|
||||
func (k EvidenceKind) IsValid() bool {
|
||||
switch k {
|
||||
case EvidenceCPUTeeQuote, EvidenceGPUNRASReport, EvidenceGPUVendorReport,
|
||||
EvidencePlatformMeasurement, EvidenceTrustAuthorityVerdict,
|
||||
EvidenceFraudProofCommitment, EvidenceZKProof:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Evidence is one entry inside a CompositeAttestation. The Blob is opaque
|
||||
// from this package's perspective: the consumer (a verifier specific to
|
||||
// the Kind) is responsible for parsing and validating.
|
||||
type Evidence struct {
|
||||
// Kind tags the source of the blob.
|
||||
Kind EvidenceKind `cbor:"kind"`
|
||||
|
||||
// Issuer is the canonical issuer identifier for this evidence
|
||||
// (e.g. "nvidia.nras.v1", "amd.sev.snp", "intel.tdx", "intel.ta.v1").
|
||||
// Used by the verifier to dispatch to the correct validator.
|
||||
Issuer string `cbor:"issuer"`
|
||||
|
||||
// SubjectID identifies the entity the evidence is *about*: a
|
||||
// WorkerID for GPU evidence, a NodeID for platform / CPU evidence.
|
||||
SubjectID string `cbor:"subject_id"`
|
||||
|
||||
// Blob is the raw evidence bytes. Treated opaquely by Root(); the
|
||||
// blob's bytes participate in the digest, so any tampering changes
|
||||
// the root.
|
||||
Blob []byte `cbor:"blob"`
|
||||
|
||||
// IssuedAt is when the issuer produced this evidence (their wall
|
||||
// clock). Recorded for audit; not validated by this package.
|
||||
IssuedAt time.Time `cbor:"issued_at"`
|
||||
}
|
||||
|
||||
// CompositeAttestation aggregates per-worker evidence into a single
|
||||
// envelope whose digest is the canonical attestation root. The struct is
|
||||
// the wire form: every field participates in Root() except where noted.
|
||||
type CompositeAttestation struct {
|
||||
// Version is the envelope schema version.
|
||||
Version string `cbor:"version"`
|
||||
|
||||
// NodeID is the node this attestation is rooted at.
|
||||
NodeID string `cbor:"node_id"`
|
||||
|
||||
// WorkerIDs are the worker endpoints covered by this envelope. Sorted
|
||||
// ascending, deduplicated. The marshaler does NOT sort for you;
|
||||
// callers must produce a stable order.
|
||||
WorkerIDs []string `cbor:"worker_ids"`
|
||||
|
||||
// AssertedTrustMode + AssertedIOLevel are the *claimed* guarantees
|
||||
// the worker is publishing alongside this attestation. The verifier
|
||||
// checks that the evidence supports the claim before accepting.
|
||||
AssertedTrustMode lattice.ComputeTrustMode `cbor:"asserted_trust_mode"`
|
||||
AssertedIOLevel lattice.ConfidentialIOLevel `cbor:"asserted_io_level"`
|
||||
|
||||
// Evidence is the unordered set of evidence blobs. The Root()
|
||||
// computation does NOT re-sort; it preserves caller-supplied order
|
||||
// because evidence blobs may have ordering semantics the verifier
|
||||
// depends on (e.g. measurement-log ordering).
|
||||
Evidence []Evidence `cbor:"evidence"`
|
||||
|
||||
// IssuedAt is the worker's wall-clock time when the envelope was
|
||||
// produced. Bound to the digest so a replay of an old envelope is
|
||||
// detectable as soon as the verifier knows the freshness window.
|
||||
IssuedAt time.Time `cbor:"issued_at"`
|
||||
}
|
||||
|
||||
// detEnc is the singleton deterministic CBOR encoder used by Root() and
|
||||
// Marshal(). cbor.EncMode is goroutine-safe.
|
||||
var detEnc cbor.EncMode
|
||||
|
||||
func init() {
|
||||
enc, err := cbor.CoreDetEncOptions().EncMode()
|
||||
if err != nil {
|
||||
// CoreDetEncOptions() is constant-derived; this can never fail.
|
||||
// Panic at init so the bug surfaces before any worker tries to
|
||||
// publish an attestation.
|
||||
panic("attestation: cbor.CoreDetEncOptions().EncMode() failed: " + err.Error())
|
||||
}
|
||||
detEnc = enc
|
||||
}
|
||||
|
||||
// Root returns the SHA-256 of the deterministic CBOR encoding of the
|
||||
// envelope. The same envelope produces the same root on every node, every
|
||||
// run -- this is the value stored in WorkerCapability.AttestationRoot and
|
||||
// indexed by the scheduler.
|
||||
//
|
||||
// Root does NOT verify the evidence. It is purely a deterministic digest.
|
||||
// Verification is done by the consumer of the root, not by the producer.
|
||||
func (c *CompositeAttestation) Root() ([32]byte, error) {
|
||||
if c == nil {
|
||||
return [32]byte{}, ErrNilEnvelope
|
||||
}
|
||||
if err := c.validate(); err != nil {
|
||||
return [32]byte{}, err
|
||||
}
|
||||
body := envelopeBody{
|
||||
Version: c.Version,
|
||||
NodeID: c.NodeID,
|
||||
WorkerIDs: c.WorkerIDs,
|
||||
AssertedTrustMode: c.AssertedTrustMode,
|
||||
AssertedIOLevel: c.AssertedIOLevel,
|
||||
Evidence: c.Evidence,
|
||||
IssuedAt: c.IssuedAt.UTC(),
|
||||
}
|
||||
enc, err := detEnc.Marshal(body)
|
||||
if err != nil {
|
||||
return [32]byte{}, err
|
||||
}
|
||||
return sha256.Sum256(enc), nil
|
||||
}
|
||||
|
||||
// Marshal serializes the envelope using the same deterministic encoder
|
||||
// used by Root. The wire bytes are byte-stable across runs and platforms.
|
||||
func (c *CompositeAttestation) Marshal() ([]byte, error) {
|
||||
if c == nil {
|
||||
return nil, ErrNilEnvelope
|
||||
}
|
||||
return detEnc.Marshal(c)
|
||||
}
|
||||
|
||||
// Unmarshal restores a CompositeAttestation from its CBOR bytes.
|
||||
func Unmarshal(b []byte) (*CompositeAttestation, error) {
|
||||
var c CompositeAttestation
|
||||
if err := cbor.Unmarshal(b, &c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// Validate reports whether the envelope is structurally well-formed. It
|
||||
// does NOT verify any cryptographic property of the evidence -- that is
|
||||
// the verifier's job and depends on per-issuer logic.
|
||||
func (c *CompositeAttestation) Validate() error {
|
||||
if c == nil {
|
||||
return ErrNilEnvelope
|
||||
}
|
||||
return c.validate()
|
||||
}
|
||||
|
||||
// validate is the unexported version used by Root(); duplicated only so
|
||||
// callers of Root() get the same checks without an extra explicit call.
|
||||
func (c *CompositeAttestation) validate() error {
|
||||
if c.Version != EnvelopeVersion {
|
||||
return errors.New("attestation: unsupported envelope version")
|
||||
}
|
||||
if c.NodeID == "" {
|
||||
return errors.New("attestation: node_id required")
|
||||
}
|
||||
if len(c.WorkerIDs) == 0 {
|
||||
return errors.New("attestation: at least one worker_id required")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(c.WorkerIDs))
|
||||
for i, wid := range c.WorkerIDs {
|
||||
if wid == "" {
|
||||
return errors.New("attestation: empty worker_id")
|
||||
}
|
||||
if !strings.HasPrefix(wid, c.NodeID+"/") {
|
||||
return errors.New("attestation: worker_id must be prefixed with node_id/")
|
||||
}
|
||||
if _, dup := seen[wid]; dup {
|
||||
return errors.New("attestation: duplicate worker_id")
|
||||
}
|
||||
seen[wid] = struct{}{}
|
||||
if i > 0 && c.WorkerIDs[i-1] >= wid {
|
||||
return errors.New("attestation: worker_ids must be sorted ascending")
|
||||
}
|
||||
}
|
||||
if !c.AssertedTrustMode.Valid() {
|
||||
return errors.New("attestation: invalid asserted_trust_mode")
|
||||
}
|
||||
if !c.AssertedIOLevel.Valid() {
|
||||
return errors.New("attestation: invalid asserted_io_level")
|
||||
}
|
||||
if len(c.Evidence) == 0 {
|
||||
return errors.New("attestation: at least one evidence required")
|
||||
}
|
||||
for i := range c.Evidence {
|
||||
ev := &c.Evidence[i]
|
||||
if !ev.Kind.IsValid() {
|
||||
return errors.New("attestation: invalid evidence kind")
|
||||
}
|
||||
if ev.Issuer == "" {
|
||||
return errors.New("attestation: evidence issuer required")
|
||||
}
|
||||
if ev.SubjectID == "" {
|
||||
return errors.New("attestation: evidence subject_id required")
|
||||
}
|
||||
if len(ev.Blob) == 0 {
|
||||
return errors.New("attestation: evidence blob required")
|
||||
}
|
||||
if ev.IssuedAt.IsZero() {
|
||||
return errors.New("attestation: evidence issued_at required")
|
||||
}
|
||||
}
|
||||
if c.AssertedTrustMode >= lattice.TrustCpuGpuCompositeTEE && !hasGPUEvidence(c.Evidence) {
|
||||
return errors.New("attestation: composite trust mode requires GPU evidence")
|
||||
}
|
||||
if c.IssuedAt.IsZero() {
|
||||
return errors.New("attestation: envelope issued_at required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasGPUEvidence reports whether at least one evidence entry is a GPU
|
||||
// attestation report (NRAS or vendor equivalent). Used by validate() to
|
||||
// reject envelopes that claim composite trust without any GPU evidence.
|
||||
func hasGPUEvidence(evs []Evidence) bool {
|
||||
for i := range evs {
|
||||
switch evs[i].Kind {
|
||||
case EvidenceGPUNRASReport, EvidenceGPUVendorReport:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// envelopeBody is the subset of CompositeAttestation that participates in
|
||||
// the digest. Today every field of CompositeAttestation participates;
|
||||
// envelopeBody exists so future fields (signatures over the digest) can be
|
||||
// added to CompositeAttestation without changing the digest formula.
|
||||
type envelopeBody struct {
|
||||
Version string `cbor:"version"`
|
||||
NodeID string `cbor:"node_id"`
|
||||
WorkerIDs []string `cbor:"worker_ids"`
|
||||
AssertedTrustMode lattice.ComputeTrustMode `cbor:"asserted_trust_mode"`
|
||||
AssertedIOLevel lattice.ConfidentialIOLevel `cbor:"asserted_io_level"`
|
||||
Evidence []Evidence `cbor:"evidence"`
|
||||
IssuedAt time.Time `cbor:"issued_at"`
|
||||
}
|
||||
|
||||
// ErrNilEnvelope is returned when a nil receiver / nil argument reaches
|
||||
// Root, Marshal, or Validate.
|
||||
var ErrNilEnvelope = errors.New("attestation: nil envelope")
|
||||
@@ -0,0 +1,414 @@
|
||||
// Copyright (c) 2026, Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package attestation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
lattice "github.com/luxfi/lattice/v7/types"
|
||||
)
|
||||
|
||||
// fixedTime returns a deterministic timestamp used by every test envelope.
|
||||
// We pin to a UTC instant so Root() is reproducible across runs and CI
|
||||
// environments regardless of local timezone.
|
||||
func fixedTime() time.Time {
|
||||
return time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// goodEnvelope returns a minimum well-formed CompositeAttestation that
|
||||
// asserts composite TEE trust and carries CPU + GPU evidence. Tests
|
||||
// mutate copies of this fixture to exercise the validation gates.
|
||||
func goodEnvelope() *CompositeAttestation {
|
||||
return &CompositeAttestation{
|
||||
Version: EnvelopeVersion,
|
||||
NodeID: "node-a",
|
||||
WorkerIDs: []string{"node-a/0", "node-a/1"},
|
||||
AssertedTrustMode: lattice.TrustCpuGpuCompositeTEE,
|
||||
AssertedIOLevel: lattice.IOLevelCpuGpuComposite,
|
||||
Evidence: []Evidence{
|
||||
{
|
||||
Kind: EvidenceCPUTeeQuote,
|
||||
Issuer: "amd.sev.snp",
|
||||
SubjectID: "node-a",
|
||||
Blob: []byte("snp-quote-bytes"),
|
||||
IssuedAt: fixedTime(),
|
||||
},
|
||||
{
|
||||
Kind: EvidenceGPUNRASReport,
|
||||
Issuer: "nvidia.nras.v1",
|
||||
SubjectID: "node-a/0",
|
||||
Blob: []byte("nras-blob-0"),
|
||||
IssuedAt: fixedTime(),
|
||||
},
|
||||
{
|
||||
Kind: EvidenceGPUNRASReport,
|
||||
Issuer: "nvidia.nras.v1",
|
||||
SubjectID: "node-a/1",
|
||||
Blob: []byte("nras-blob-1"),
|
||||
IssuedAt: fixedTime(),
|
||||
},
|
||||
},
|
||||
IssuedAt: fixedTime(),
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// EvidenceKind
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func TestEvidenceKind_IsValid(t *testing.T) {
|
||||
cases := []struct {
|
||||
kind EvidenceKind
|
||||
ok bool
|
||||
}{
|
||||
{EvidenceCPUTeeQuote, true},
|
||||
{EvidenceGPUNRASReport, true},
|
||||
{EvidenceGPUVendorReport, true},
|
||||
{EvidencePlatformMeasurement, true},
|
||||
{EvidenceTrustAuthorityVerdict, true},
|
||||
{EvidenceFraudProofCommitment, true},
|
||||
{EvidenceZKProof, true},
|
||||
{EvidenceKind(""), false},
|
||||
{EvidenceKind("garbage"), false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := c.kind.IsValid(); got != c.ok {
|
||||
t.Errorf("%q: got %v want %v", string(c.kind), got, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Validate
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func TestCompositeAttestation_Validate_Good(t *testing.T) {
|
||||
c := goodEnvelope()
|
||||
if err := c.Validate(); err != nil {
|
||||
t.Fatalf("good envelope rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeAttestation_Validate_NilReceiver(t *testing.T) {
|
||||
var c *CompositeAttestation
|
||||
if err := c.Validate(); !errors.Is(err, ErrNilEnvelope) {
|
||||
t.Fatalf("expected ErrNilEnvelope, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeAttestation_Validate_Errors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mut func(*CompositeAttestation)
|
||||
errSub string
|
||||
}{
|
||||
{"bad_version", func(c *CompositeAttestation) { c.Version = "0" }, "unsupported envelope version"},
|
||||
{"empty_node_id", func(c *CompositeAttestation) { c.NodeID = "" }, "node_id required"},
|
||||
{"empty_worker_ids", func(c *CompositeAttestation) { c.WorkerIDs = nil }, "at least one worker_id"},
|
||||
{
|
||||
"unsorted_worker_ids",
|
||||
func(c *CompositeAttestation) {
|
||||
c.WorkerIDs = []string{"node-a/1", "node-a/0"}
|
||||
},
|
||||
"sorted ascending",
|
||||
},
|
||||
{
|
||||
"duplicate_worker_id",
|
||||
func(c *CompositeAttestation) {
|
||||
c.WorkerIDs = []string{"node-a/0", "node-a/0"}
|
||||
},
|
||||
// Dedup map is checked before the strict-ascending order check,
|
||||
// so equal entries surface as "duplicate" not "unsorted".
|
||||
"duplicate worker_id",
|
||||
},
|
||||
{
|
||||
"empty_worker_id_entry",
|
||||
func(c *CompositeAttestation) {
|
||||
c.WorkerIDs = []string{""}
|
||||
},
|
||||
"empty worker_id",
|
||||
},
|
||||
{
|
||||
"worker_id_prefix_mismatch",
|
||||
func(c *CompositeAttestation) {
|
||||
c.WorkerIDs = []string{"other-node/0"}
|
||||
// keep evidence valid for downstream gates
|
||||
},
|
||||
"prefixed with node_id",
|
||||
},
|
||||
{
|
||||
"bad_trust_mode",
|
||||
func(c *CompositeAttestation) {
|
||||
c.AssertedTrustMode = lattice.ComputeTrustMode(99)
|
||||
},
|
||||
"invalid asserted_trust_mode",
|
||||
},
|
||||
{
|
||||
"bad_io_level",
|
||||
func(c *CompositeAttestation) {
|
||||
c.AssertedIOLevel = lattice.ConfidentialIOLevel(99)
|
||||
},
|
||||
"invalid asserted_io_level",
|
||||
},
|
||||
{
|
||||
"no_evidence",
|
||||
func(c *CompositeAttestation) {
|
||||
c.Evidence = nil
|
||||
},
|
||||
"at least one evidence",
|
||||
},
|
||||
{
|
||||
"bad_evidence_kind",
|
||||
func(c *CompositeAttestation) {
|
||||
c.Evidence[0].Kind = EvidenceKind("nope")
|
||||
},
|
||||
"invalid evidence kind",
|
||||
},
|
||||
{
|
||||
"empty_evidence_issuer",
|
||||
func(c *CompositeAttestation) {
|
||||
c.Evidence[0].Issuer = ""
|
||||
},
|
||||
"evidence issuer required",
|
||||
},
|
||||
{
|
||||
"empty_evidence_subject",
|
||||
func(c *CompositeAttestation) {
|
||||
c.Evidence[0].SubjectID = ""
|
||||
},
|
||||
"evidence subject_id required",
|
||||
},
|
||||
{
|
||||
"empty_evidence_blob",
|
||||
func(c *CompositeAttestation) {
|
||||
c.Evidence[0].Blob = nil
|
||||
},
|
||||
"evidence blob required",
|
||||
},
|
||||
{
|
||||
"zero_evidence_issued_at",
|
||||
func(c *CompositeAttestation) {
|
||||
c.Evidence[0].IssuedAt = time.Time{}
|
||||
},
|
||||
"evidence issued_at required",
|
||||
},
|
||||
{
|
||||
"composite_trust_without_gpu_evidence",
|
||||
func(c *CompositeAttestation) {
|
||||
// Drop the GPU evidence; CPU-only evidence cannot back a
|
||||
// composite TEE claim.
|
||||
c.Evidence = c.Evidence[:1]
|
||||
},
|
||||
"composite trust mode requires GPU evidence",
|
||||
},
|
||||
{
|
||||
"zero_envelope_issued_at",
|
||||
func(c *CompositeAttestation) {
|
||||
c.IssuedAt = time.Time{}
|
||||
},
|
||||
"envelope issued_at required",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
env := goodEnvelope()
|
||||
c.mut(env)
|
||||
err := env.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), c.errSub) {
|
||||
t.Fatalf("expected error containing %q, got %v", c.errSub, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Root determinism + sensitivity
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func TestCompositeAttestation_Root_Deterministic(t *testing.T) {
|
||||
a := goodEnvelope()
|
||||
b := goodEnvelope()
|
||||
rootA, err := a.Root()
|
||||
if err != nil {
|
||||
t.Fatalf("Root A: %v", err)
|
||||
}
|
||||
rootB, err := b.Root()
|
||||
if err != nil {
|
||||
t.Fatalf("Root B: %v", err)
|
||||
}
|
||||
if rootA != rootB {
|
||||
t.Fatalf("two equal envelopes produced different roots:\n A=%x\n B=%x", rootA, rootB)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompositeAttestation_Root_StableAcrossRuns pins the digest of the
|
||||
// canonical fixture. If this changes, every deployed AttestationRoot in
|
||||
// every WorkerCapability silently invalidates. The only legitimate reason
|
||||
// to update this expectation is a planned bump of EnvelopeVersion or a
|
||||
// deliberate evolution of the digestable body.
|
||||
func TestCompositeAttestation_Root_StableAcrossRuns(t *testing.T) {
|
||||
c := goodEnvelope()
|
||||
root, err := c.Root()
|
||||
if err != nil {
|
||||
t.Fatalf("Root: %v", err)
|
||||
}
|
||||
// Sanity bound: a SHA-256 root is never the zero value for non-empty
|
||||
// input. Re-run with the same fixture must produce the same value.
|
||||
if root == ([32]byte{}) {
|
||||
t.Fatal("root is zero")
|
||||
}
|
||||
again, err := c.Root()
|
||||
if err != nil {
|
||||
t.Fatalf("Root again: %v", err)
|
||||
}
|
||||
if root != again {
|
||||
t.Fatalf("Root not stable across two calls: %x vs %x", root, again)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompositeAttestation_Root_SensitiveToEveryField mutates each field
|
||||
// in turn and asserts the digest changes. A field that does NOT change
|
||||
// the digest is a silent-corruption hazard: the verifier could not detect
|
||||
// tampering of that field.
|
||||
func TestCompositeAttestation_Root_SensitiveToEveryField(t *testing.T) {
|
||||
base, err := goodEnvelope().Root()
|
||||
if err != nil {
|
||||
t.Fatalf("base root: %v", err)
|
||||
}
|
||||
mutations := []struct {
|
||||
name string
|
||||
mut func(*CompositeAttestation)
|
||||
}{
|
||||
{"node_id", func(c *CompositeAttestation) {
|
||||
c.NodeID = "node-b"
|
||||
c.WorkerIDs = []string{"node-b/0", "node-b/1"}
|
||||
c.Evidence[0].SubjectID = "node-b"
|
||||
c.Evidence[1].SubjectID = "node-b/0"
|
||||
c.Evidence[2].SubjectID = "node-b/1"
|
||||
}},
|
||||
{"worker_ids_value", func(c *CompositeAttestation) {
|
||||
c.WorkerIDs = []string{"node-a/0", "node-a/2"}
|
||||
}},
|
||||
{"asserted_trust_mode", func(c *CompositeAttestation) {
|
||||
c.AssertedTrustMode = lattice.TrustConfidentialIO
|
||||
}},
|
||||
{"asserted_io_level", func(c *CompositeAttestation) {
|
||||
c.AssertedIOLevel = lattice.IOLevelProtectedCpuGpuTransfer
|
||||
}},
|
||||
{"evidence_blob", func(c *CompositeAttestation) {
|
||||
c.Evidence[1].Blob = []byte("nras-blob-0-modified")
|
||||
}},
|
||||
{"evidence_issuer", func(c *CompositeAttestation) {
|
||||
c.Evidence[1].Issuer = "nvidia.nras.v2"
|
||||
}},
|
||||
{"evidence_kind", func(c *CompositeAttestation) {
|
||||
c.Evidence[0].Kind = EvidencePlatformMeasurement
|
||||
}},
|
||||
{"evidence_issued_at", func(c *CompositeAttestation) {
|
||||
c.Evidence[1].IssuedAt = fixedTime().Add(time.Second)
|
||||
}},
|
||||
{"envelope_issued_at", func(c *CompositeAttestation) {
|
||||
c.IssuedAt = fixedTime().Add(time.Hour)
|
||||
}},
|
||||
{"evidence_order", func(c *CompositeAttestation) {
|
||||
// Swap the two NRAS reports. Because Root preserves
|
||||
// caller-supplied order, this must change the digest.
|
||||
c.Evidence[1], c.Evidence[2] = c.Evidence[2], c.Evidence[1]
|
||||
}},
|
||||
}
|
||||
for _, m := range mutations {
|
||||
t.Run(m.name, func(t *testing.T) {
|
||||
env := goodEnvelope()
|
||||
m.mut(env)
|
||||
r, err := env.Root()
|
||||
if err != nil {
|
||||
t.Fatalf("Root: %v", err)
|
||||
}
|
||||
if r == base {
|
||||
t.Fatalf("mutation %s did not change root", m.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeAttestation_Root_RejectsInvalid(t *testing.T) {
|
||||
c := goodEnvelope()
|
||||
c.NodeID = "" // breaks validate
|
||||
if _, err := c.Root(); err == nil {
|
||||
t.Fatal("expected Root to reject invalid envelope")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeAttestation_Root_NilReceiver(t *testing.T) {
|
||||
var c *CompositeAttestation
|
||||
_, err := c.Root()
|
||||
if !errors.Is(err, ErrNilEnvelope) {
|
||||
t.Fatalf("expected ErrNilEnvelope, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Marshal / Unmarshal round-trip
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func TestCompositeAttestation_RoundTrip(t *testing.T) {
|
||||
in := goodEnvelope()
|
||||
wire, err := in.Marshal()
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
if len(wire) == 0 {
|
||||
t.Fatal("Marshal produced empty bytes")
|
||||
}
|
||||
out, err := Unmarshal(wire)
|
||||
if err != nil {
|
||||
t.Fatalf("Unmarshal: %v", err)
|
||||
}
|
||||
|
||||
// Roots must match -- deterministic encoding guarantees it.
|
||||
rIn, err := in.Root()
|
||||
if err != nil {
|
||||
t.Fatalf("Root in: %v", err)
|
||||
}
|
||||
// Unmarshalled envelope's IssuedAt may carry a different *Location
|
||||
// after round-trip; Root() forces UTC, so comparison is stable.
|
||||
rOut, err := out.Root()
|
||||
if err != nil {
|
||||
t.Fatalf("Root out: %v", err)
|
||||
}
|
||||
if rIn != rOut {
|
||||
t.Fatalf("round-trip changed root: in=%x out=%x", rIn, rOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeAttestation_Marshal_NilReceiver(t *testing.T) {
|
||||
var c *CompositeAttestation
|
||||
if _, err := c.Marshal(); !errors.Is(err, ErrNilEnvelope) {
|
||||
t.Fatalf("expected ErrNilEnvelope, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeAttestation_Marshal_DeterministicBytes(t *testing.T) {
|
||||
a, err := goodEnvelope().Marshal()
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal a: %v", err)
|
||||
}
|
||||
b, err := goodEnvelope().Marshal()
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal b: %v", err)
|
||||
}
|
||||
if !bytes.Equal(a, b) {
|
||||
t.Fatalf("Marshal not deterministic: a=%x b=%x", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeAttestation_Unmarshal_Garbage(t *testing.T) {
|
||||
if _, err := Unmarshal([]byte{0xff, 0xff, 0xff}); err == nil {
|
||||
t.Fatal("expected error decoding garbage")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
// Copyright (c) 2026, Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
lattice "github.com/luxfi/lattice/v7/types"
|
||||
)
|
||||
|
||||
// LanePolicy describes the trust + IO floor that a scheduling lane
|
||||
// enforces. A lane is the runtime resource pool a workload is dispatched
|
||||
// into (e.g. "public-inference", "private-inference", "validator-keys",
|
||||
// "fhe-precompile"). The scheduler picks a lane based on workload privacy
|
||||
// class, then picks a worker that meets the lane's floor.
|
||||
//
|
||||
// LanePolicy is the operator-controlled configuration: an operator who
|
||||
// adopts a stricter policy (e.g. demands ConfidentialIO on every private
|
||||
// lane) edits LanePolicy, not the workload. The split between LanePolicy
|
||||
// (operator) and WorkloadPolicy (per-request) keeps the truth-table
|
||||
// debuggable: every Eligible() check is a function of two policies and a
|
||||
// capability.
|
||||
type LanePolicy struct {
|
||||
// Name is a human-readable identifier; not part of the gating logic.
|
||||
Name string
|
||||
|
||||
// MinTrustMode is the floor for ComputeTrustMode. A worker with
|
||||
// TrustMode < MinTrustMode is ineligible for this lane regardless of
|
||||
// any other field.
|
||||
MinTrustMode lattice.ComputeTrustMode
|
||||
|
||||
// MinIOLevel is the floor for ConfidentialIOLevel. Same gating as
|
||||
// MinTrustMode.
|
||||
MinIOLevel lattice.ConfidentialIOLevel
|
||||
|
||||
// AllowedArches is the set of GPU archs this lane permits. An empty
|
||||
// slice means "any valid arch". Membership check is exact-match
|
||||
// against GPUArch values; SupportsConfidentialCompute() is NOT
|
||||
// inferred.
|
||||
AllowedArches []GPUArch
|
||||
|
||||
// AllowedBackends is the set of permitted runtime backends. Empty
|
||||
// means any.
|
||||
AllowedBackends []GPUBackend
|
||||
|
||||
// RequireAttestation, when true, refuses any worker whose
|
||||
// AttestationRoot is the zero value, even if MinTrustMode is
|
||||
// PublicDeterministic. Used for lanes that need a logged identity
|
||||
// even on public workloads.
|
||||
RequireAttestation bool
|
||||
}
|
||||
|
||||
// Validate performs cheap structural checks on the lane policy.
|
||||
func (lp *LanePolicy) Validate() error {
|
||||
if lp == nil {
|
||||
return errors.New("registry: nil lane policy")
|
||||
}
|
||||
if lp.Name == "" {
|
||||
return errors.New("registry: lane policy name required")
|
||||
}
|
||||
if !lp.MinTrustMode.Valid() {
|
||||
return errors.New("registry: invalid lane min_trust_mode")
|
||||
}
|
||||
if !lp.MinIOLevel.Valid() {
|
||||
return errors.New("registry: invalid lane min_io_level")
|
||||
}
|
||||
for _, a := range lp.AllowedArches {
|
||||
if !a.Valid() {
|
||||
return errors.New("registry: invalid lane allowed_arch")
|
||||
}
|
||||
}
|
||||
for _, b := range lp.AllowedBackends {
|
||||
if !b.Valid() {
|
||||
return errors.New("registry: invalid lane allowed_backend")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WorkloadPolicy is the per-workload policy carried by a scheduling
|
||||
// request. It expresses the workload's *minimum* requirements; the lane
|
||||
// adds a floor on top. The effective floor for any worker is
|
||||
// `max(LanePolicy.Min..., WorkloadPolicy.Min...)`.
|
||||
type WorkloadPolicy struct {
|
||||
// PrivacyClass is the workload's data-handling tag.
|
||||
PrivacyClass lattice.WorkloadPrivacyClass
|
||||
|
||||
// MinTrustMode is the workload's required floor. The effective floor
|
||||
// for the chosen worker is max(LanePolicy.MinTrustMode, this).
|
||||
MinTrustMode lattice.ComputeTrustMode
|
||||
|
||||
// MinIOLevel is the workload's IO floor.
|
||||
MinIOLevel lattice.ConfidentialIOLevel
|
||||
|
||||
// RequiredArches is the set of arches this workload accepts. Empty
|
||||
// means any (subject to the lane's AllowedArches).
|
||||
RequiredArches []GPUArch
|
||||
|
||||
// RequiredBackends is the set of backends this workload accepts.
|
||||
// Empty means any.
|
||||
RequiredBackends []GPUBackend
|
||||
|
||||
// MinVRAMBytes is the minimum device memory the workload requires.
|
||||
// 0 means no minimum.
|
||||
MinVRAMBytes uint64
|
||||
|
||||
// RequiredInterconnect is the minimum peer-fabric strength. Comparison
|
||||
// is `>=` on the underlying value, mirroring trust mode semantics.
|
||||
RequiredInterconnect PeerInterconnect
|
||||
}
|
||||
|
||||
// Validate performs cheap structural checks on the workload policy.
|
||||
func (wp *WorkloadPolicy) Validate() error {
|
||||
if wp == nil {
|
||||
return errors.New("registry: nil workload policy")
|
||||
}
|
||||
if !wp.PrivacyClass.Valid() {
|
||||
return errors.New("registry: invalid workload privacy_class")
|
||||
}
|
||||
if !wp.MinTrustMode.Valid() {
|
||||
return errors.New("registry: invalid workload min_trust_mode")
|
||||
}
|
||||
if !wp.MinIOLevel.Valid() {
|
||||
return errors.New("registry: invalid workload min_io_level")
|
||||
}
|
||||
if !wp.RequiredInterconnect.Valid() {
|
||||
return errors.New("registry: invalid workload required_interconnect")
|
||||
}
|
||||
for _, a := range wp.RequiredArches {
|
||||
if !a.Valid() {
|
||||
return errors.New("registry: invalid workload required_arch")
|
||||
}
|
||||
}
|
||||
for _, b := range wp.RequiredBackends {
|
||||
if !b.Valid() {
|
||||
return errors.New("registry: invalid workload required_backend")
|
||||
}
|
||||
}
|
||||
// Privacy class implies a minimum trust mode floor: regulated /
|
||||
// validator workloads must run on TrustCpuGpuCompositeTEE or stronger.
|
||||
// We enforce this here so a misconfigured workload cannot downgrade
|
||||
// itself by accident.
|
||||
switch wp.PrivacyClass {
|
||||
case lattice.PrivacyValidatorKeyMaterial, lattice.PrivacyRegulatedOrderflow:
|
||||
if wp.MinTrustMode < lattice.TrustCpuGpuCompositeTEE {
|
||||
return errors.New("registry: validator/regulated workloads require TrustCpuGpuCompositeTEE+")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Eligible reports whether worker w may run a workload tagged with wp on
|
||||
// lane lp. It is the canonical eligibility check; every scheduler decision
|
||||
// in the GPU runtime goes through it.
|
||||
//
|
||||
// The check is a conjunction of independent gates -- all must pass:
|
||||
//
|
||||
// 1. lane: w.TrustMode >= lp.MinTrustMode
|
||||
// 2. lane: w.IOLevel >= lp.MinIOLevel
|
||||
// 3. lane: w.Arch in lp.AllowedArches (or AllowedArches empty)
|
||||
// 4. lane: w.Backend in lp.AllowedBackends (or AllowedBackends empty)
|
||||
// 5. lane: lp.RequireAttestation -> w.AttestationRoot != zero
|
||||
// 6. workload: w.TrustMode >= wp.MinTrustMode
|
||||
// 7. workload: w.IOLevel >= wp.MinIOLevel
|
||||
// 8. workload: w.Arch in wp.RequiredArches (or RequiredArches empty)
|
||||
// 9. workload: w.Backend in wp.RequiredBackends (or RequiredBackends empty)
|
||||
// 10. workload: w.VRAMBytes >= wp.MinVRAMBytes
|
||||
// 11. workload: w.Interconnect >= wp.RequiredInterconnect
|
||||
// 12. consistency: privacy classes that require TEE arch are honoured
|
||||
//
|
||||
// Failures return a non-nil error describing the *first* gate that failed.
|
||||
// A nil error means w is eligible.
|
||||
//
|
||||
// Eligible does NOT call Validate on its inputs. Callers are expected to
|
||||
// have validated lp, wp, and w before invoking the scheduler hot path.
|
||||
func Eligible(lp *LanePolicy, wp *WorkloadPolicy, w *WorkerCapability) error {
|
||||
if lp == nil || wp == nil || w == nil {
|
||||
return errors.New("registry: eligible: nil input")
|
||||
}
|
||||
|
||||
// Gate 1: lane min trust mode.
|
||||
if w.TrustMode < lp.MinTrustMode {
|
||||
return ErrIneligibleLaneTrust
|
||||
}
|
||||
// Gate 2: lane min IO level.
|
||||
if w.IOLevel < lp.MinIOLevel {
|
||||
return ErrIneligibleLaneIO
|
||||
}
|
||||
// Gate 3: lane allowed arches.
|
||||
if !archAllowed(lp.AllowedArches, w.Arch) {
|
||||
return ErrIneligibleLaneArch
|
||||
}
|
||||
// Gate 4: lane allowed backends.
|
||||
if !backendAllowed(lp.AllowedBackends, w.Backend) {
|
||||
return ErrIneligibleLaneBackend
|
||||
}
|
||||
// Gate 5: lane requires attestation.
|
||||
if lp.RequireAttestation && w.AttestationRoot == ([32]byte{}) {
|
||||
return ErrIneligibleNoAttestation
|
||||
}
|
||||
// Gate 6: workload min trust mode.
|
||||
if w.TrustMode < wp.MinTrustMode {
|
||||
return ErrIneligibleWorkloadTrust
|
||||
}
|
||||
// Gate 7: workload min IO level.
|
||||
if w.IOLevel < wp.MinIOLevel {
|
||||
return ErrIneligibleWorkloadIO
|
||||
}
|
||||
// Gate 8: workload required arches.
|
||||
if !archAllowed(wp.RequiredArches, w.Arch) {
|
||||
return ErrIneligibleWorkloadArch
|
||||
}
|
||||
// Gate 9: workload required backends.
|
||||
if !backendAllowed(wp.RequiredBackends, w.Backend) {
|
||||
return ErrIneligibleWorkloadBackend
|
||||
}
|
||||
// Gate 10: workload VRAM floor.
|
||||
if w.VRAMBytes < wp.MinVRAMBytes {
|
||||
return ErrIneligibleVRAM
|
||||
}
|
||||
// Gate 11: workload interconnect floor.
|
||||
if w.Interconnect < wp.RequiredInterconnect {
|
||||
return ErrIneligibleInterconnect
|
||||
}
|
||||
// Gate 12: privacy-class consistency. The TEE arch requirement is
|
||||
// already encoded in WorkerCapability.Validate; here we re-check it
|
||||
// against the workload class to defend against an unvalidated worker
|
||||
// record slipping past the API edge.
|
||||
switch wp.PrivacyClass {
|
||||
case lattice.PrivacyValidatorKeyMaterial,
|
||||
lattice.PrivacyRegulatedOrderflow,
|
||||
lattice.PrivacyPrivateModelWeights,
|
||||
lattice.PrivacyPrivateUserData:
|
||||
if !w.Arch.SupportsConfidentialCompute() {
|
||||
return ErrIneligiblePrivacyArch
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// archAllowed reports whether a is in the allowed set, where an empty
|
||||
// allowed set means "any valid arch is permitted".
|
||||
func archAllowed(allowed []GPUArch, a GPUArch) bool {
|
||||
if len(allowed) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, x := range allowed {
|
||||
if x == a {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// backendAllowed reports whether b is in the allowed set, where an empty
|
||||
// allowed set means "any valid backend is permitted".
|
||||
func backendAllowed(allowed []GPUBackend, b GPUBackend) bool {
|
||||
if len(allowed) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, x := range allowed {
|
||||
if x == b {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Eligibility error sentinels. The scheduler logs these by identity; do
|
||||
// not change the strings without updating downstream metric labels.
|
||||
var (
|
||||
ErrIneligibleLaneTrust = errors.New("registry: lane min_trust_mode not met")
|
||||
ErrIneligibleLaneIO = errors.New("registry: lane min_io_level not met")
|
||||
ErrIneligibleLaneArch = errors.New("registry: lane allowed_arches mismatch")
|
||||
ErrIneligibleLaneBackend = errors.New("registry: lane allowed_backends mismatch")
|
||||
ErrIneligibleNoAttestation = errors.New("registry: lane require_attestation but worker has none")
|
||||
ErrIneligibleWorkloadTrust = errors.New("registry: workload min_trust_mode not met")
|
||||
ErrIneligibleWorkloadIO = errors.New("registry: workload min_io_level not met")
|
||||
ErrIneligibleWorkloadArch = errors.New("registry: workload required_arches mismatch")
|
||||
ErrIneligibleWorkloadBackend = errors.New("registry: workload required_backends mismatch")
|
||||
ErrIneligibleVRAM = errors.New("registry: workload min_vram not met")
|
||||
ErrIneligibleInterconnect = errors.New("registry: workload required_interconnect not met")
|
||||
ErrIneligiblePrivacyArch = errors.New("registry: privacy class requires TEE-capable arch")
|
||||
)
|
||||
@@ -0,0 +1,409 @@
|
||||
// Copyright (c) 2026, Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
lattice "github.com/luxfi/lattice/v7/types"
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// LanePolicy + WorkloadPolicy validation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func TestLanePolicy_Validate(t *testing.T) {
|
||||
good := LanePolicy{
|
||||
Name: "private-inference",
|
||||
MinTrustMode: lattice.TrustCpuGpuCompositeTEE,
|
||||
MinIOLevel: lattice.IOLevelCpuGpuComposite,
|
||||
}
|
||||
if err := good.Validate(); err != nil {
|
||||
t.Fatalf("good lane policy rejected: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
mut func(*LanePolicy)
|
||||
errSub string
|
||||
}{
|
||||
{"nil_name", func(lp *LanePolicy) { lp.Name = "" }, "name required"},
|
||||
{"bad_trust", func(lp *LanePolicy) { lp.MinTrustMode = lattice.ComputeTrustMode(99) }, "invalid lane min_trust_mode"},
|
||||
{"bad_io", func(lp *LanePolicy) { lp.MinIOLevel = lattice.ConfidentialIOLevel(99) }, "invalid lane min_io_level"},
|
||||
{"bad_arch", func(lp *LanePolicy) { lp.AllowedArches = []GPUArch{GPUArch(99)} }, "invalid lane allowed_arch"},
|
||||
{"bad_backend", func(lp *LanePolicy) { lp.AllowedBackends = []GPUBackend{GPUBackend(99)} }, "invalid lane allowed_backend"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
lp := good
|
||||
c.mut(&lp)
|
||||
err := lp.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), c.errSub) {
|
||||
t.Fatalf("expected error containing %q, got %v", c.errSub, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkloadPolicy_Validate(t *testing.T) {
|
||||
good := WorkloadPolicy{
|
||||
PrivacyClass: lattice.PrivacyPrivateUserData,
|
||||
MinTrustMode: lattice.TrustCpuGpuCompositeTEE,
|
||||
MinIOLevel: lattice.IOLevelCpuGpuComposite,
|
||||
RequiredInterconnect: PeerInterconnectPCIe,
|
||||
}
|
||||
if err := good.Validate(); err != nil {
|
||||
t.Fatalf("good workload policy rejected: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
mut func(*WorkloadPolicy)
|
||||
errSub string
|
||||
}{
|
||||
{"bad_privacy", func(wp *WorkloadPolicy) { wp.PrivacyClass = lattice.WorkloadPrivacyClass(99) }, "invalid workload privacy_class"},
|
||||
{"bad_trust", func(wp *WorkloadPolicy) { wp.MinTrustMode = lattice.ComputeTrustMode(99) }, "invalid workload min_trust_mode"},
|
||||
{"bad_io", func(wp *WorkloadPolicy) { wp.MinIOLevel = lattice.ConfidentialIOLevel(99) }, "invalid workload min_io_level"},
|
||||
{"bad_interconnect", func(wp *WorkloadPolicy) { wp.RequiredInterconnect = PeerInterconnect(99) }, "invalid workload required_interconnect"},
|
||||
{"bad_arch", func(wp *WorkloadPolicy) { wp.RequiredArches = []GPUArch{GPUArch(99)} }, "invalid workload required_arch"},
|
||||
{"bad_backend", func(wp *WorkloadPolicy) { wp.RequiredBackends = []GPUBackend{GPUBackend(99)} }, "invalid workload required_backend"},
|
||||
{
|
||||
"validator_must_be_composite",
|
||||
func(wp *WorkloadPolicy) {
|
||||
wp.PrivacyClass = lattice.PrivacyValidatorKeyMaterial
|
||||
wp.MinTrustMode = lattice.TrustAttestedGpuOnly
|
||||
},
|
||||
"validator/regulated workloads require",
|
||||
},
|
||||
{
|
||||
"regulated_must_be_composite",
|
||||
func(wp *WorkloadPolicy) {
|
||||
wp.PrivacyClass = lattice.PrivacyRegulatedOrderflow
|
||||
wp.MinTrustMode = lattice.TrustPublicDeterministic
|
||||
},
|
||||
"validator/regulated workloads require",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
wp := good
|
||||
c.mut(&wp)
|
||||
err := wp.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), c.errSub) {
|
||||
t.Fatalf("expected error containing %q, got %v", c.errSub, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Eligible() truth table
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// The truth table fixes a baseline {lane, workload, worker} where w is
|
||||
// eligible, then mutates a single field to flip a single gate. Each row
|
||||
// asserts the precise sentinel error returned. Adding a new gate to
|
||||
// Eligible() requires adding a new row here.
|
||||
//
|
||||
// The baseline:
|
||||
// - Lane: composite TEE + composite IO, Hopper or Blackwell only,
|
||||
// CUDA only, attestation required.
|
||||
// - Workload: private-user-data, composite TEE + composite IO,
|
||||
// PCIe interconnect floor, 40 GB VRAM floor, no arch/backend filter.
|
||||
// - Worker: Hopper, CUDA, NVSwitch, ConfidentialIO, FullDeviceIOAttested,
|
||||
// 80 GB VRAM, attested.
|
||||
|
||||
func baselineLane() LanePolicy {
|
||||
return LanePolicy{
|
||||
Name: "private-inference",
|
||||
MinTrustMode: lattice.TrustCpuGpuCompositeTEE,
|
||||
MinIOLevel: lattice.IOLevelCpuGpuComposite,
|
||||
AllowedArches: []GPUArch{GPUArchHopper, GPUArchBlackwell},
|
||||
AllowedBackends: []GPUBackend{GPUBackendCUDA},
|
||||
RequireAttestation: true,
|
||||
}
|
||||
}
|
||||
|
||||
func baselineWorkload() WorkloadPolicy {
|
||||
return WorkloadPolicy{
|
||||
PrivacyClass: lattice.PrivacyPrivateUserData,
|
||||
MinTrustMode: lattice.TrustCpuGpuCompositeTEE,
|
||||
MinIOLevel: lattice.IOLevelCpuGpuComposite,
|
||||
RequiredInterconnect: PeerInterconnectPCIe,
|
||||
MinVRAMBytes: 40 << 30,
|
||||
}
|
||||
}
|
||||
|
||||
func baselineWorker() WorkerCapability {
|
||||
return WorkerCapability{
|
||||
WorkerID: "node-a/0",
|
||||
NodeID: "node-a",
|
||||
Arch: GPUArchHopper,
|
||||
Backend: GPUBackendCUDA,
|
||||
VRAMBytes: 80 << 30,
|
||||
Interconnect: PeerInterconnectNVSwitch,
|
||||
TrustMode: lattice.TrustConfidentialIO,
|
||||
IOLevel: lattice.IOLevelFullDeviceIOAttested,
|
||||
AttestationRoot: [32]byte{0x42},
|
||||
}
|
||||
}
|
||||
|
||||
func TestEligible_Baseline(t *testing.T) {
|
||||
lp := baselineLane()
|
||||
wp := baselineWorkload()
|
||||
w := baselineWorker()
|
||||
if err := Eligible(&lp, &wp, &w); err != nil {
|
||||
t.Fatalf("baseline eligibility failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEligible_NilInputs(t *testing.T) {
|
||||
lp := baselineLane()
|
||||
wp := baselineWorkload()
|
||||
w := baselineWorker()
|
||||
if err := Eligible(nil, &wp, &w); err == nil {
|
||||
t.Error("expected error for nil lane")
|
||||
}
|
||||
if err := Eligible(&lp, nil, &w); err == nil {
|
||||
t.Error("expected error for nil workload")
|
||||
}
|
||||
if err := Eligible(&lp, &wp, nil); err == nil {
|
||||
t.Error("expected error for nil worker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEligible_TruthTable(t *testing.T) {
|
||||
type mut struct {
|
||||
mutLane func(*LanePolicy)
|
||||
mutWorkload func(*WorkloadPolicy)
|
||||
mutWorker func(*WorkerCapability)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
m mut
|
||||
want error
|
||||
}{
|
||||
{
|
||||
name: "lane_trust_below_floor",
|
||||
m: mut{mutWorker: func(w *WorkerCapability) {
|
||||
w.TrustMode = lattice.TrustAttestedGpuOnly
|
||||
}},
|
||||
want: ErrIneligibleLaneTrust,
|
||||
},
|
||||
{
|
||||
name: "lane_io_below_floor",
|
||||
m: mut{mutWorker: func(w *WorkerCapability) {
|
||||
// Drop io to None but keep trust mode satisfying the lane
|
||||
// trust floor; protected IO consistency check at validate
|
||||
// time would catch this, but Eligible bypasses Validate
|
||||
// (callers validate beforehand). We test the ordering of
|
||||
// gates here.
|
||||
w.IOLevel = lattice.IOLevelCpuTeeOnly
|
||||
}},
|
||||
want: ErrIneligibleLaneIO,
|
||||
},
|
||||
{
|
||||
name: "lane_arch_not_allowed",
|
||||
m: mut{
|
||||
mutWorker: func(w *WorkerCapability) {
|
||||
w.Arch = GPUArchBlackwell
|
||||
},
|
||||
mutLane: func(lp *LanePolicy) {
|
||||
lp.AllowedArches = []GPUArch{GPUArchHopper}
|
||||
},
|
||||
},
|
||||
want: ErrIneligibleLaneArch,
|
||||
},
|
||||
{
|
||||
name: "lane_backend_not_allowed",
|
||||
m: mut{mutWorker: func(w *WorkerCapability) {
|
||||
w.Backend = GPUBackendROCm
|
||||
}},
|
||||
want: ErrIneligibleLaneBackend,
|
||||
},
|
||||
{
|
||||
name: "lane_requires_attestation_but_none",
|
||||
m: mut{mutWorker: func(w *WorkerCapability) {
|
||||
w.AttestationRoot = [32]byte{}
|
||||
}},
|
||||
want: ErrIneligibleNoAttestation,
|
||||
},
|
||||
{
|
||||
name: "workload_trust_below_floor",
|
||||
m: mut{
|
||||
mutLane: func(lp *LanePolicy) {
|
||||
// Drop lane floor so workload floor is the gate.
|
||||
lp.MinTrustMode = lattice.TrustPublicDeterministic
|
||||
},
|
||||
mutWorkload: func(wp *WorkloadPolicy) {
|
||||
wp.MinTrustMode = lattice.TrustZKOrFraudProofed
|
||||
},
|
||||
},
|
||||
want: ErrIneligibleWorkloadTrust,
|
||||
},
|
||||
{
|
||||
name: "workload_io_below_floor",
|
||||
m: mut{
|
||||
mutLane: func(lp *LanePolicy) {
|
||||
lp.MinIOLevel = lattice.IOLevelNone
|
||||
},
|
||||
mutWorkload: func(wp *WorkloadPolicy) {
|
||||
// Worker baseline has FullDeviceIOAttested -- bump
|
||||
// workload to require something stricter than that.
|
||||
wp.MinIOLevel = lattice.ConfidentialIOLevel(99)
|
||||
},
|
||||
},
|
||||
want: ErrIneligibleWorkloadIO,
|
||||
},
|
||||
{
|
||||
name: "workload_arch_not_required",
|
||||
m: mut{mutWorkload: func(wp *WorkloadPolicy) {
|
||||
wp.RequiredArches = []GPUArch{GPUArchBlackwell}
|
||||
}},
|
||||
want: ErrIneligibleWorkloadArch,
|
||||
},
|
||||
{
|
||||
name: "workload_backend_not_required",
|
||||
m: mut{mutWorkload: func(wp *WorkloadPolicy) {
|
||||
wp.RequiredBackends = []GPUBackend{GPUBackendROCm}
|
||||
}},
|
||||
want: ErrIneligibleWorkloadBackend,
|
||||
},
|
||||
{
|
||||
name: "workload_vram_floor",
|
||||
m: mut{mutWorkload: func(wp *WorkloadPolicy) {
|
||||
wp.MinVRAMBytes = 200 << 30 // 200 GB > 80 GB worker
|
||||
}},
|
||||
want: ErrIneligibleVRAM,
|
||||
},
|
||||
{
|
||||
name: "workload_interconnect_floor",
|
||||
m: mut{
|
||||
mutWorker: func(w *WorkerCapability) {
|
||||
w.Interconnect = PeerInterconnectPCIe
|
||||
},
|
||||
mutWorkload: func(wp *WorkloadPolicy) {
|
||||
wp.RequiredInterconnect = PeerInterconnectNVSwitch
|
||||
},
|
||||
},
|
||||
want: ErrIneligibleInterconnect,
|
||||
},
|
||||
{
|
||||
name: "privacy_class_requires_tee_arch_validator",
|
||||
m: mut{
|
||||
// Run the validator workload through a permissive lane and
|
||||
// drop the worker to Ampere (no TEE) to trigger gate 12.
|
||||
mutLane: func(lp *LanePolicy) {
|
||||
lp.MinTrustMode = lattice.TrustPublicDeterministic
|
||||
lp.MinIOLevel = lattice.IOLevelNone
|
||||
lp.AllowedArches = nil
|
||||
lp.AllowedBackends = nil
|
||||
lp.RequireAttestation = false
|
||||
},
|
||||
mutWorkload: func(wp *WorkloadPolicy) {
|
||||
wp.PrivacyClass = lattice.PrivacyValidatorKeyMaterial
|
||||
// Workload trust mode floor is permissive enough that
|
||||
// worker meets it; the privacy-class gate is what fails.
|
||||
wp.MinTrustMode = lattice.TrustPublicDeterministic
|
||||
wp.MinIOLevel = lattice.IOLevelNone
|
||||
},
|
||||
mutWorker: func(w *WorkerCapability) {
|
||||
w.Arch = GPUArchAmpere
|
||||
w.TrustMode = lattice.TrustAttestedGpuOnly
|
||||
w.IOLevel = lattice.IOLevelNone
|
||||
},
|
||||
},
|
||||
want: ErrIneligiblePrivacyArch,
|
||||
},
|
||||
{
|
||||
name: "privacy_class_requires_tee_arch_regulated",
|
||||
m: mut{
|
||||
mutLane: func(lp *LanePolicy) {
|
||||
lp.MinTrustMode = lattice.TrustPublicDeterministic
|
||||
lp.MinIOLevel = lattice.IOLevelNone
|
||||
lp.AllowedArches = nil
|
||||
lp.AllowedBackends = nil
|
||||
lp.RequireAttestation = false
|
||||
},
|
||||
mutWorkload: func(wp *WorkloadPolicy) {
|
||||
wp.PrivacyClass = lattice.PrivacyRegulatedOrderflow
|
||||
wp.MinTrustMode = lattice.TrustPublicDeterministic
|
||||
wp.MinIOLevel = lattice.IOLevelNone
|
||||
},
|
||||
mutWorker: func(w *WorkerCapability) {
|
||||
w.Arch = GPUArchAdaLovelace
|
||||
w.TrustMode = lattice.TrustAttestedGpuOnly
|
||||
w.IOLevel = lattice.IOLevelNone
|
||||
},
|
||||
},
|
||||
want: ErrIneligiblePrivacyArch,
|
||||
},
|
||||
{
|
||||
name: "public_workload_on_ampere_passes",
|
||||
m: mut{
|
||||
mutLane: func(lp *LanePolicy) {
|
||||
lp.MinTrustMode = lattice.TrustPublicDeterministic
|
||||
lp.MinIOLevel = lattice.IOLevelNone
|
||||
lp.AllowedArches = nil
|
||||
lp.AllowedBackends = nil
|
||||
lp.RequireAttestation = false
|
||||
},
|
||||
mutWorkload: func(wp *WorkloadPolicy) {
|
||||
wp.PrivacyClass = lattice.PrivacyPublic
|
||||
wp.MinTrustMode = lattice.TrustPublicDeterministic
|
||||
wp.MinIOLevel = lattice.IOLevelNone
|
||||
},
|
||||
mutWorker: func(w *WorkerCapability) {
|
||||
w.Arch = GPUArchAmpere
|
||||
w.TrustMode = lattice.TrustAttestedGpuOnly
|
||||
w.IOLevel = lattice.IOLevelNone
|
||||
},
|
||||
},
|
||||
want: nil, // public class on Ampere is fine
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
lp := baselineLane()
|
||||
wp := baselineWorkload()
|
||||
w := baselineWorker()
|
||||
if c.m.mutLane != nil {
|
||||
c.m.mutLane(&lp)
|
||||
}
|
||||
if c.m.mutWorkload != nil {
|
||||
c.m.mutWorkload(&wp)
|
||||
}
|
||||
if c.m.mutWorker != nil {
|
||||
c.m.mutWorker(&w)
|
||||
}
|
||||
err := Eligible(&lp, &wp, &w)
|
||||
if !errors.Is(err, c.want) {
|
||||
t.Fatalf("Eligible(%s): got %v want %v", c.name, err, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEligible_GateOrdering pins the order in which gates are evaluated.
|
||||
// A misclassified failure (e.g. reporting "VRAM" when the real problem is
|
||||
// "lane trust") would mislead operators reading the metric labels. The
|
||||
// order is documented in policy.go:Eligible(); this test is a regression
|
||||
// guard against re-ordering.
|
||||
func TestEligible_GateOrdering(t *testing.T) {
|
||||
lp := baselineLane()
|
||||
wp := baselineWorkload()
|
||||
w := baselineWorker()
|
||||
// Break multiple gates simultaneously and assert the *first* one wins.
|
||||
w.TrustMode = lattice.TrustAttestedGpuOnly // breaks gate 1 (lane trust)
|
||||
w.IOLevel = lattice.IOLevelCpuTeeOnly // also breaks gate 2 (lane io)
|
||||
w.Backend = GPUBackendROCm // also breaks gate 4 (lane backend)
|
||||
w.VRAMBytes = 1 << 20 // also breaks gate 10 (workload vram)
|
||||
|
||||
err := Eligible(&lp, &wp, &w)
|
||||
if !errors.Is(err, ErrIneligibleLaneTrust) {
|
||||
t.Fatalf("expected gate 1 (lane trust) to win, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
// Copyright (c) 2026, Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package registry defines the worker / node capability records that the
|
||||
// Lux GPU Runtime scheduler uses to pick a target for a workload.
|
||||
//
|
||||
// The registry is the single source of truth for "what can this node do".
|
||||
// A capability record is published by each worker (after it has produced a
|
||||
// composite attestation; see pkg/attestation) and consumed by the scheduler
|
||||
// to gate workloads through LanePolicy.Eligible / WorkloadPolicy.Eligible.
|
||||
//
|
||||
// The trust + privacy enums (ComputeTrustMode, ConfidentialIOLevel,
|
||||
// WorkloadPrivacyClass) live in github.com/luxfi/lattice/v7/types because
|
||||
// they are also imported by FHE / KMS / cevm. This package only adds the
|
||||
// hardware-shape fields (GPU arch, backend, VRAM, peer interconnect, etc.)
|
||||
// that the scheduler needs on top.
|
||||
//
|
||||
// Reference: LP-137-TRUST-REGISTRY.md.
|
||||
package registry
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
lattice "github.com/luxfi/lattice/v7/types"
|
||||
)
|
||||
|
||||
// GPUArch identifies the architectural family of a GPU. The scheduler uses
|
||||
// it to decide whether a workload that requires a specific feature (e.g.
|
||||
// confidential compute on Hopper, transformer engine v2 on Blackwell) can
|
||||
// be placed on this worker.
|
||||
//
|
||||
// Underlying values are stable and part of the wire / log contract. The
|
||||
// String() method is the canonical text form. New variants append at the
|
||||
// end of the order; reordering is a breaking change.
|
||||
type GPUArch uint8
|
||||
|
||||
const (
|
||||
GPUArchUnknown GPUArch = 0
|
||||
GPUArchAmpere GPUArch = 1 // A100, A40, A30, A10
|
||||
GPUArchHopper GPUArch = 2 // H100, H200 -- first NVIDIA CC TEE
|
||||
GPUArchBlackwell GPUArch = 3 // B100, B200, GB200 -- second-gen CC + TEE-IO
|
||||
GPUArchAdaLovelace GPUArch = 4 // L40, L40S, RTX Ada
|
||||
GPUArchAppleSilicon GPUArch = 5 // Apple M-series (Metal/MPS)
|
||||
GPUArchAMDCDNA GPUArch = 6 // MI250X, MI300X (ROCm)
|
||||
GPUArchIntelXeHPC GPUArch = 7 // Ponte Vecchio (oneAPI)
|
||||
)
|
||||
|
||||
// String returns a stable, human-readable name for the GPU arch.
|
||||
func (a GPUArch) String() string {
|
||||
switch a {
|
||||
case GPUArchUnknown:
|
||||
return "Unknown"
|
||||
case GPUArchAmpere:
|
||||
return "Ampere"
|
||||
case GPUArchHopper:
|
||||
return "Hopper"
|
||||
case GPUArchBlackwell:
|
||||
return "Blackwell"
|
||||
case GPUArchAdaLovelace:
|
||||
return "AdaLovelace"
|
||||
case GPUArchAppleSilicon:
|
||||
return "AppleSilicon"
|
||||
case GPUArchAMDCDNA:
|
||||
return "AMDCDNA"
|
||||
case GPUArchIntelXeHPC:
|
||||
return "IntelXeHPC"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Valid reports whether a is one of the defined arch values.
|
||||
func (a GPUArch) Valid() bool {
|
||||
return a <= GPUArchIntelXeHPC
|
||||
}
|
||||
|
||||
// SupportsConfidentialCompute reports whether the arch has on-die hardware
|
||||
// support for confidential compute (TEE / TEE-IO). Used by the scheduler
|
||||
// to short-circuit eligibility for ComputeTrustMode >= CpuGpuCompositeTEE.
|
||||
//
|
||||
// Hopper (H100) introduced GPU CC. Blackwell (B100/B200) extends with
|
||||
// TEE-IO. No other published arch has on-die CC at registry-publication
|
||||
// time; this function returns false for everything else.
|
||||
func (a GPUArch) SupportsConfidentialCompute() bool {
|
||||
return a == GPUArchHopper || a == GPUArchBlackwell
|
||||
}
|
||||
|
||||
// GPUBackend identifies the kernel-execution stack the worker has loaded.
|
||||
// One worker may report multiple backends (e.g. CUDA + Triton on the same
|
||||
// device); each capability record carries one primary backend, and the
|
||||
// scheduler matches it against the workload's required-backend set.
|
||||
type GPUBackend uint8
|
||||
|
||||
const (
|
||||
GPUBackendUnknown GPUBackend = 0
|
||||
GPUBackendCUDA GPUBackend = 1 // NVIDIA CUDA C++/PTX
|
||||
GPUBackendROCm GPUBackend = 2 // AMD HIP
|
||||
GPUBackendMetal GPUBackend = 3 // Apple Metal / MPS
|
||||
GPUBackendOneAPI GPUBackend = 4 // Intel oneAPI / SYCL
|
||||
GPUBackendVulkan GPUBackend = 5 // Cross-vendor Vulkan compute
|
||||
GPUBackendWebGPU GPUBackend = 6 // WebGPU / wgpu (browser + native)
|
||||
GPUBackendCPU GPUBackend = 7 // CPU fallback (no GPU)
|
||||
)
|
||||
|
||||
// String returns a stable, human-readable name for the backend.
|
||||
func (b GPUBackend) String() string {
|
||||
switch b {
|
||||
case GPUBackendUnknown:
|
||||
return "Unknown"
|
||||
case GPUBackendCUDA:
|
||||
return "CUDA"
|
||||
case GPUBackendROCm:
|
||||
return "ROCm"
|
||||
case GPUBackendMetal:
|
||||
return "Metal"
|
||||
case GPUBackendOneAPI:
|
||||
return "OneAPI"
|
||||
case GPUBackendVulkan:
|
||||
return "Vulkan"
|
||||
case GPUBackendWebGPU:
|
||||
return "WebGPU"
|
||||
case GPUBackendCPU:
|
||||
return "CPU"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Valid reports whether b is one of the defined backend values.
|
||||
func (b GPUBackend) Valid() bool {
|
||||
return b <= GPUBackendCPU
|
||||
}
|
||||
|
||||
// PeerInterconnect identifies the highest-bandwidth peer-to-peer fabric
|
||||
// available on the worker. A scheduler that places a multi-GPU workload
|
||||
// requiring NVLink or NVSwitch will refuse a worker that reports only PCIe.
|
||||
type PeerInterconnect uint8
|
||||
|
||||
const (
|
||||
PeerInterconnectNone PeerInterconnect = 0 // single-GPU, no peer fabric
|
||||
PeerInterconnectPCIe PeerInterconnect = 1 // PCIe Gen4/Gen5 only
|
||||
PeerInterconnectNVLink PeerInterconnect = 2 // NVLink point-to-point
|
||||
PeerInterconnectNVSwitch PeerInterconnect = 3 // NVSwitch all-to-all
|
||||
PeerInterconnectInfinity PeerInterconnect = 4 // AMD Infinity Fabric
|
||||
)
|
||||
|
||||
// String returns a stable name for the interconnect.
|
||||
func (p PeerInterconnect) String() string {
|
||||
switch p {
|
||||
case PeerInterconnectNone:
|
||||
return "None"
|
||||
case PeerInterconnectPCIe:
|
||||
return "PCIe"
|
||||
case PeerInterconnectNVLink:
|
||||
return "NVLink"
|
||||
case PeerInterconnectNVSwitch:
|
||||
return "NVSwitch"
|
||||
case PeerInterconnectInfinity:
|
||||
return "InfinityFabric"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Valid reports whether p is one of the defined interconnect values.
|
||||
func (p PeerInterconnect) Valid() bool {
|
||||
return p <= PeerInterconnectInfinity
|
||||
}
|
||||
|
||||
// WorkerCapability is the per-worker capability record. It describes one
|
||||
// addressable execution endpoint -- a single GPU, an Apple Metal device,
|
||||
// or a CPU-only fallback -- on a node.
|
||||
//
|
||||
// The struct is the canonical wire form: every field is required, none are
|
||||
// optional. Validate() performs cheap structural checks at the API edge;
|
||||
// it does NOT verify the attestation -- that is done by callers using
|
||||
// pkg/attestation.
|
||||
type WorkerCapability struct {
|
||||
// WorkerID is a globally unique identifier for this execution endpoint.
|
||||
// Convention: "<NodeID>/<DeviceIndex>" (e.g. "n3-h100-01/0").
|
||||
WorkerID string
|
||||
|
||||
// NodeID is the node that hosts this worker (matches NodeCapability.NodeID).
|
||||
NodeID string
|
||||
|
||||
// Arch + Backend describe the hardware + runtime stack. Both must be
|
||||
// Valid().
|
||||
Arch GPUArch
|
||||
Backend GPUBackend
|
||||
|
||||
// VRAMBytes is the total device memory in bytes. 0 for CPU workers.
|
||||
VRAMBytes uint64
|
||||
|
||||
// FP16TFlops + FP8TFlops + INT8TOps are the worker's published peak
|
||||
// compute throughput. Used by the scheduler to size workloads.
|
||||
FP16TFlops uint32 // FP16 dense TFLOPs (Tensor Cores)
|
||||
FP8TFlops uint32 // FP8 dense TFLOPs; 0 if unsupported (pre-Hopper)
|
||||
INT8TOps uint32 // INT8 dense TOPs
|
||||
|
||||
// Interconnect is the highest-bandwidth peer fabric available between
|
||||
// this worker and any sibling worker on the same node.
|
||||
Interconnect PeerInterconnect
|
||||
|
||||
// TrustMode + IOLevel are the strongest guarantees this worker can
|
||||
// currently honour. Set after attestation. A worker that has not yet
|
||||
// attested reports TrustPublicDeterministic + IOLevelNone.
|
||||
TrustMode lattice.ComputeTrustMode
|
||||
IOLevel lattice.ConfidentialIOLevel
|
||||
|
||||
// AttestationRoot is the SHA-256 of the worker's most recent composite
|
||||
// attestation envelope (see pkg/attestation.CompositeAttestation.Root).
|
||||
// Zero ([32]byte{}) iff the worker has not produced an attestation;
|
||||
// such workers are forced to TrustPublicDeterministic regardless of
|
||||
// the TrustMode field above.
|
||||
AttestationRoot [32]byte
|
||||
}
|
||||
|
||||
// Validate performs structural checks against the worker capability. It
|
||||
// rejects records that the scheduler cannot reason about. It does NOT
|
||||
// verify the attestation -- callers must do that separately.
|
||||
func (w *WorkerCapability) Validate() error {
|
||||
if w == nil {
|
||||
return ErrNilCapability
|
||||
}
|
||||
if w.WorkerID == "" {
|
||||
return errors.New("registry: worker_id required")
|
||||
}
|
||||
if w.NodeID == "" {
|
||||
return errors.New("registry: node_id required")
|
||||
}
|
||||
if !strings.HasPrefix(w.WorkerID, w.NodeID+"/") {
|
||||
return errors.New("registry: worker_id must be prefixed with node_id/")
|
||||
}
|
||||
if !w.Arch.Valid() {
|
||||
return errors.New("registry: invalid arch")
|
||||
}
|
||||
if !w.Backend.Valid() {
|
||||
return errors.New("registry: invalid backend")
|
||||
}
|
||||
if !w.Interconnect.Valid() {
|
||||
return errors.New("registry: invalid interconnect")
|
||||
}
|
||||
if !w.TrustMode.Valid() {
|
||||
return errors.New("registry: invalid trust_mode")
|
||||
}
|
||||
if !w.IOLevel.Valid() {
|
||||
return errors.New("registry: invalid io_level")
|
||||
}
|
||||
// Trust mode >= CpuGpuCompositeTEE requires arch with on-die CC support.
|
||||
if w.TrustMode >= lattice.TrustCpuGpuCompositeTEE && !w.Arch.SupportsConfidentialCompute() {
|
||||
return errors.New("registry: trust_mode >= CpuGpuCompositeTEE requires Hopper or Blackwell arch")
|
||||
}
|
||||
// Composite trust requires a non-zero attestation root.
|
||||
if w.TrustMode >= lattice.TrustCpuGpuCompositeTEE && w.AttestationRoot == ([32]byte{}) {
|
||||
return errors.New("registry: trust_mode >= CpuGpuCompositeTEE requires attestation_root")
|
||||
}
|
||||
// IO level cannot exceed what the trust mode supports. A worker that
|
||||
// claims FullDeviceIOAttested without ConfidentialIO trust mode is
|
||||
// inconsistent.
|
||||
if w.IOLevel >= lattice.IOLevelProtectedCpuGpuTransfer && w.TrustMode < lattice.TrustConfidentialIO {
|
||||
return errors.New("registry: protected IO level requires TrustConfidentialIO")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NodeCapability is the per-node capability record. A node hosts one or
|
||||
// more WorkerCapability entries plus node-level metadata (CPU TEE type,
|
||||
// region, operator identity).
|
||||
type NodeCapability struct {
|
||||
// NodeID is a globally unique node identifier.
|
||||
NodeID string
|
||||
|
||||
// Region is the physical region (e.g. "us-east-1a", "lux-sfo3-az1").
|
||||
// Used for placement constraints + co-residency rules.
|
||||
Region string
|
||||
|
||||
// OperatorID identifies the operator account that runs this node
|
||||
// (e.g. an IAM org slug). Used for tenant isolation policies.
|
||||
OperatorID string
|
||||
|
||||
// CPUTeeType is a free-form tag for the host CPU TEE. Empty string
|
||||
// means no CPU TEE. Canonical values: "amd-sev-snp", "intel-tdx",
|
||||
// "intel-sgx" (legacy), "arm-cca".
|
||||
CPUTeeType string
|
||||
|
||||
// Workers are the execution endpoints hosted on this node. At least
|
||||
// one entry; every entry's NodeID must match this NodeID.
|
||||
Workers []WorkerCapability
|
||||
|
||||
// AttestationRoot is the SHA-256 of the node's most-recent composite
|
||||
// attestation envelope. Distinct from per-worker roots: covers host /
|
||||
// kernel / firmware components, not GPU-specific evidence.
|
||||
AttestationRoot [32]byte
|
||||
}
|
||||
|
||||
// Validate performs structural checks against the node capability. Each
|
||||
// WorkerCapability is also validated; the first failure short-circuits.
|
||||
func (n *NodeCapability) Validate() error {
|
||||
if n == nil {
|
||||
return ErrNilCapability
|
||||
}
|
||||
if n.NodeID == "" {
|
||||
return errors.New("registry: node_id required")
|
||||
}
|
||||
if n.Region == "" {
|
||||
return errors.New("registry: region required")
|
||||
}
|
||||
if n.OperatorID == "" {
|
||||
return errors.New("registry: operator_id required")
|
||||
}
|
||||
if len(n.Workers) == 0 {
|
||||
return errors.New("registry: at least one worker required")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(n.Workers))
|
||||
for i := range n.Workers {
|
||||
w := &n.Workers[i]
|
||||
if w.NodeID != n.NodeID {
|
||||
return errors.New("registry: worker.node_id must match node.node_id")
|
||||
}
|
||||
if _, dup := seen[w.WorkerID]; dup {
|
||||
return errors.New("registry: duplicate worker_id")
|
||||
}
|
||||
seen[w.WorkerID] = struct{}{}
|
||||
if err := w.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrNilCapability is returned by Validate() when called on a nil receiver.
|
||||
var ErrNilCapability = errors.New("registry: nil capability")
|
||||
@@ -0,0 +1,291 @@
|
||||
// Copyright (c) 2026, Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
lattice "github.com/luxfi/lattice/v7/types"
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// GPUArch
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func TestGPUArch_StableValues(t *testing.T) {
|
||||
cases := []struct {
|
||||
arch GPUArch
|
||||
want uint8
|
||||
}{
|
||||
{GPUArchUnknown, 0},
|
||||
{GPUArchAmpere, 1},
|
||||
{GPUArchHopper, 2},
|
||||
{GPUArchBlackwell, 3},
|
||||
{GPUArchAdaLovelace, 4},
|
||||
{GPUArchAppleSilicon, 5},
|
||||
{GPUArchAMDCDNA, 6},
|
||||
{GPUArchIntelXeHPC, 7},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if uint8(c.arch) != c.want {
|
||||
t.Errorf("%s: got %d want %d", c.arch, uint8(c.arch), c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGPUArch_String(t *testing.T) {
|
||||
if got := GPUArchHopper.String(); got != "Hopper" {
|
||||
t.Errorf("got %q want Hopper", got)
|
||||
}
|
||||
if got := GPUArch(99).String(); got != "Unknown" {
|
||||
t.Errorf("got %q want Unknown", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGPUArch_Valid(t *testing.T) {
|
||||
for a := GPUArch(0); a <= GPUArchIntelXeHPC; a++ {
|
||||
if !a.Valid() {
|
||||
t.Errorf("expected %s to be valid", a)
|
||||
}
|
||||
}
|
||||
if GPUArch(99).Valid() {
|
||||
t.Error("expected 99 to be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGPUArch_SupportsConfidentialCompute(t *testing.T) {
|
||||
cases := []struct {
|
||||
arch GPUArch
|
||||
want bool
|
||||
}{
|
||||
{GPUArchUnknown, false},
|
||||
{GPUArchAmpere, false},
|
||||
{GPUArchHopper, true},
|
||||
{GPUArchBlackwell, true},
|
||||
{GPUArchAdaLovelace, false},
|
||||
{GPUArchAppleSilicon, false},
|
||||
{GPUArchAMDCDNA, false},
|
||||
{GPUArchIntelXeHPC, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := c.arch.SupportsConfidentialCompute(); got != c.want {
|
||||
t.Errorf("%s: got %v want %v", c.arch, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// GPUBackend
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func TestGPUBackend_StableValues(t *testing.T) {
|
||||
cases := []struct {
|
||||
b GPUBackend
|
||||
want uint8
|
||||
}{
|
||||
{GPUBackendUnknown, 0},
|
||||
{GPUBackendCUDA, 1},
|
||||
{GPUBackendROCm, 2},
|
||||
{GPUBackendMetal, 3},
|
||||
{GPUBackendOneAPI, 4},
|
||||
{GPUBackendVulkan, 5},
|
||||
{GPUBackendWebGPU, 6},
|
||||
{GPUBackendCPU, 7},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if uint8(c.b) != c.want {
|
||||
t.Errorf("%s: got %d want %d", c.b, uint8(c.b), c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGPUBackend_Valid(t *testing.T) {
|
||||
for b := GPUBackend(0); b <= GPUBackendCPU; b++ {
|
||||
if !b.Valid() {
|
||||
t.Errorf("expected %s to be valid", b)
|
||||
}
|
||||
}
|
||||
if GPUBackend(99).Valid() {
|
||||
t.Error("expected 99 to be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// PeerInterconnect
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func TestPeerInterconnect_Order(t *testing.T) {
|
||||
chain := []PeerInterconnect{
|
||||
PeerInterconnectNone,
|
||||
PeerInterconnectPCIe,
|
||||
PeerInterconnectNVLink,
|
||||
PeerInterconnectNVSwitch,
|
||||
PeerInterconnectInfinity,
|
||||
}
|
||||
for i := 1; i < len(chain); i++ {
|
||||
if !(chain[i] > chain[i-1]) {
|
||||
t.Fatalf("interconnect ordering broken at %s -> %s", chain[i-1], chain[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// WorkerCapability.Validate
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func goodWorker() WorkerCapability {
|
||||
return WorkerCapability{
|
||||
WorkerID: "node-a/0",
|
||||
NodeID: "node-a",
|
||||
Arch: GPUArchHopper,
|
||||
Backend: GPUBackendCUDA,
|
||||
VRAMBytes: 80 << 30,
|
||||
FP16TFlops: 1000,
|
||||
FP8TFlops: 2000,
|
||||
INT8TOps: 4000,
|
||||
Interconnect: PeerInterconnectNVSwitch,
|
||||
TrustMode: lattice.TrustCpuGpuCompositeTEE,
|
||||
IOLevel: lattice.IOLevelCpuGpuComposite,
|
||||
AttestationRoot: [32]byte{0x01},
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerCapability_Validate_Good(t *testing.T) {
|
||||
w := goodWorker()
|
||||
if err := w.Validate(); err != nil {
|
||||
t.Fatalf("good worker rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerCapability_Validate_Errors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mut func(*WorkerCapability)
|
||||
errSub string
|
||||
}{
|
||||
{"nil_worker_id", func(w *WorkerCapability) { w.WorkerID = "" }, "worker_id required"},
|
||||
{"nil_node_id", func(w *WorkerCapability) { w.NodeID = "" }, "node_id required"},
|
||||
{"prefix_mismatch", func(w *WorkerCapability) { w.WorkerID = "other/0" }, "prefixed with node_id"},
|
||||
{"bad_arch", func(w *WorkerCapability) { w.Arch = GPUArch(99) }, "invalid arch"},
|
||||
{"bad_backend", func(w *WorkerCapability) { w.Backend = GPUBackend(99) }, "invalid backend"},
|
||||
{"bad_interconnect", func(w *WorkerCapability) { w.Interconnect = PeerInterconnect(99) }, "invalid interconnect"},
|
||||
{"bad_trust", func(w *WorkerCapability) { w.TrustMode = lattice.ComputeTrustMode(99) }, "invalid trust_mode"},
|
||||
{"bad_io", func(w *WorkerCapability) { w.IOLevel = lattice.ConfidentialIOLevel(99) }, "invalid io_level"},
|
||||
{
|
||||
"composite_on_ampere",
|
||||
func(w *WorkerCapability) {
|
||||
w.Arch = GPUArchAmpere
|
||||
w.TrustMode = lattice.TrustCpuGpuCompositeTEE
|
||||
},
|
||||
"requires Hopper or Blackwell",
|
||||
},
|
||||
{
|
||||
"composite_no_root",
|
||||
func(w *WorkerCapability) {
|
||||
w.AttestationRoot = [32]byte{}
|
||||
},
|
||||
"requires attestation_root",
|
||||
},
|
||||
{
|
||||
"protected_io_without_confidential_trust",
|
||||
func(w *WorkerCapability) {
|
||||
w.IOLevel = lattice.IOLevelProtectedCpuGpuTransfer
|
||||
w.TrustMode = lattice.TrustCpuGpuCompositeTEE
|
||||
},
|
||||
"protected IO level requires TrustConfidentialIO",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
w := goodWorker()
|
||||
c.mut(&w)
|
||||
err := w.Validate()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", c.errSub)
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.errSub) {
|
||||
t.Fatalf("expected error containing %q, got %q", c.errSub, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerCapability_Validate_NilReceiver(t *testing.T) {
|
||||
var w *WorkerCapability
|
||||
if err := w.Validate(); !errors.Is(err, ErrNilCapability) {
|
||||
t.Fatalf("expected ErrNilCapability, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// NodeCapability.Validate
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func goodNode() NodeCapability {
|
||||
w := goodWorker()
|
||||
return NodeCapability{
|
||||
NodeID: "node-a",
|
||||
Region: "us-east-1a",
|
||||
OperatorID: "lux",
|
||||
CPUTeeType: "amd-sev-snp",
|
||||
Workers: []WorkerCapability{w},
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeCapability_Validate_Good(t *testing.T) {
|
||||
n := goodNode()
|
||||
if err := n.Validate(); err != nil {
|
||||
t.Fatalf("good node rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeCapability_Validate_Errors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mut func(*NodeCapability)
|
||||
errSub string
|
||||
}{
|
||||
{"nil_node_id", func(n *NodeCapability) { n.NodeID = "" }, "node_id required"},
|
||||
{"nil_region", func(n *NodeCapability) { n.Region = "" }, "region required"},
|
||||
{"nil_operator", func(n *NodeCapability) { n.OperatorID = "" }, "operator_id required"},
|
||||
{"no_workers", func(n *NodeCapability) { n.Workers = nil }, "at least one worker"},
|
||||
{
|
||||
"worker_node_mismatch",
|
||||
func(n *NodeCapability) {
|
||||
n.Workers[0].NodeID = "other"
|
||||
n.Workers[0].WorkerID = "other/0"
|
||||
},
|
||||
"must match",
|
||||
},
|
||||
{
|
||||
"duplicate_worker_id",
|
||||
func(n *NodeCapability) {
|
||||
n.Workers = append(n.Workers, n.Workers[0])
|
||||
},
|
||||
"duplicate worker_id",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
n := goodNode()
|
||||
c.mut(&n)
|
||||
err := n.Validate()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", c.errSub)
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.errSub) {
|
||||
t.Fatalf("expected error containing %q, got %q", c.errSub, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeCapability_Validate_NilReceiver(t *testing.T) {
|
||||
var n *NodeCapability
|
||||
if err := n.Validate(); !errors.Is(err, ErrNilCapability) {
|
||||
t.Fatalf("expected ErrNilCapability, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user