diff --git a/SPEC.md b/SPEC.md index a86f7e1..39b935b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -154,11 +154,25 @@ bytes and invalid signatures. Domain tag: `sohocloud/listing/v0` Order: `NodeID` (string), `Class` (string), `Printers` (repeated: `Kind` string, -`Model` string), `Capacity.VCPUs` (int64), `Capacity.MemMB` (int64), -`Capacity.DiskMB` (int64), `Capacity.PrintQPS` (int64), `OptIn.Compute` (bool), -`OptIn.Print` (bool), `IssuedAt` (time), `Seq` (uint64). +`Model` string), `GPUs` (repeated: `API` string, `Model` string, `VRAMMB` +int64), `Capacity.VCPUs` (int64), `Capacity.MemMB` (int64), +`Capacity.DiskMB` (int64), `Capacity.StorageCommitMB` (int64), +`Capacity.PrintQPS` (int64), `OptIn.Compute` (bool), `OptIn.Print` (bool), +`OptIn.Storage` (bool), `IssuedAt` (time), `Seq` (uint64). `Class` ∈ {`micro`, `standard`, `server`}. `Kind` ∈ {`traditional`, `threed`}. +`API` ∈ {`vulkan`, `nnapi`, `cuda`, `metal`}. + +`Capacity.DiskMB` is scratch space available to a running job; +`Capacity.StorageCommitMB` is long-lived storage the node commits to hold for +the network (shard hosting). The two are deliberately distinct so a node can +offer either without the other. How stored data is encrypted, sharded, and +audited is a frontend/agent concern outside this protocol — coordination only, +the wire never carries stored content. + +Advertising a GPU is the opt-in for GPU work: a listing with no `GPUs` +receives none, and a node withdraws a GPU by omitting it from its next +listing. `Seq` is strictly monotonic per node. A coordinator MUST reject a listing whose `Seq` does not strictly exceed the last one seen for that node (replay/rollback diff --git a/employment/employment.go b/employment/employment.go index 79f1edd..b187dfa 100644 --- a/employment/employment.go +++ b/employment/employment.go @@ -18,7 +18,7 @@ import ( // advisory routing hints, not enforcement — opt-out enforcement is local to the // node (see Decline / listing.WorkloadOptIn). type JobSpec struct { - Workload string // "compute" | "print" + Workload string // "compute" | "print" | "storage" Image string Args []string PrinterKind string // set for print workloads; empty otherwise diff --git a/listing/listing.go b/listing/listing.go index 4bf58da..c2e6e07 100644 --- a/listing/listing.go +++ b/listing/listing.go @@ -1,6 +1,6 @@ -// Package listing defines a node's signed advertisement of what compute and -// physical-print work it will accept. A listing is self-issued and node-signed: -// no coordinator speaks for a node's capabilities. +// Package listing defines a node's signed advertisement of what compute, +// storage, and physical-print work it will accept. A listing is self-issued +// and node-signed: no coordinator speaks for a node's capabilities. package listing import ( @@ -37,12 +37,41 @@ type PrinterCapability struct { Model string } -// Capacity is a point-in-time resource snapshot the node advertises. +// GPUAPI identifies the compute API through which a GPU is offered. The +// protocol enumerates APIs so a coordinator can avoid offering GPU work a +// node cannot run; it does NOT define scheduling policy (SPEC §5.2). +type GPUAPI string + +const ( + GPUVulkan GPUAPI = "vulkan" // cross-platform, incl. Android / Android TV + GPUNNAPI GPUAPI = "nnapi" // Android Neural Networks API + GPUCUDA GPUAPI = "cuda" // NVIDIA desktop/server + GPUMetal GPUAPI = "metal" // Apple silicon +) + +// GPUCapability describes one GPU the node offers. Advertising a GPU is the +// opt-in: a listing with no GPUs receives no GPU work, and a node withdraws +// a GPU by omitting it from its next listing. Like everything in a listing +// this is advisory — local enforcement still governs (SPEC §5.1). +type GPUCapability struct { + API GPUAPI + Model string + VRAMMB int +} + +// Capacity is a point-in-time resource snapshot the node advertises. DiskMB +// is scratch space available to a running job; StorageCommitMB is long-lived +// storage the node commits to hold for the network (shard hosting). The two +// are deliberately distinct so a node can offer either without the other. +// How stored data is encrypted, sharded, and audited is a frontend/agent +// concern outside this protocol: coordination only — the wire never carries +// stored content. type Capacity struct { - VCPUs int - MemMB int - DiskMB int - PrintQPS int // print jobs the node will queue; 0 if none + VCPUs int + MemMB int + DiskMB int + StorageCommitMB int + PrintQPS int // print jobs the node will queue; 0 if none } // WorkloadOptIn is ADVISORY. The coordinator is NOT a security boundary for @@ -53,6 +82,7 @@ type Capacity struct { type WorkloadOptIn struct { Compute bool Print bool + Storage bool } // CapabilityListing is a node's signed capability advertisement. @@ -60,6 +90,7 @@ type CapabilityListing struct { NodeID identity.NodeID Class ComputeClass Printers []PrinterCapability + GPUs []GPUCapability Capacity Capacity OptIn WorkloadOptIn IssuedAt time.Time @@ -80,12 +111,20 @@ func (l CapabilityListing) CanonicalBytes() []byte { b.String(string(p.Kind)) b.String(p.Model) } + b.Count(len(l.GPUs)) + for _, g := range l.GPUs { + b.String(string(g.API)) + b.String(g.Model) + b.Int64(int64(g.VRAMMB)) + } b.Int64(int64(l.Capacity.VCPUs)) b.Int64(int64(l.Capacity.MemMB)) b.Int64(int64(l.Capacity.DiskMB)) + b.Int64(int64(l.Capacity.StorageCommitMB)) b.Int64(int64(l.Capacity.PrintQPS)) b.Bool(l.OptIn.Compute) b.Bool(l.OptIn.Print) + b.Bool(l.OptIn.Storage) b.Time(l.IssuedAt) b.Uint64(l.Seq) return b.Sum() diff --git a/listing/listing_test.go b/listing/listing_test.go index 535f522..97cfcfb 100644 --- a/listing/listing_test.go +++ b/listing/listing_test.go @@ -15,8 +15,9 @@ func TestListingRoundTrip(t *testing.T) { NodeID: "node-1", Class: ClassStandard, Printers: []PrinterCapability{{Kind: Printer3D, Model: "Prusa MK4"}}, - Capacity: Capacity{VCPUs: 4, MemMB: 8192, DiskMB: 100000}, - OptIn: WorkloadOptIn{Compute: true, Print: true}, + GPUs: []GPUCapability{{API: GPUVulkan, Model: "Adreno 750", VRAMMB: 8192}}, + Capacity: Capacity{VCPUs: 4, MemMB: 8192, DiskMB: 100000, StorageCommitMB: 500000}, + OptIn: WorkloadOptIn{Compute: true, Print: true, Storage: true}, IssuedAt: time.Unix(1_700_000_000, 0), Seq: 7, } @@ -26,6 +27,32 @@ func TestListingRoundTrip(t *testing.T) { } } +func TestGPUTamperDetected(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(nil) + l := CapabilityListing{ + NodeID: "node-1", + Class: ClassMicro, + GPUs: []GPUCapability{{API: GPUNNAPI, Model: "Tensor G4", VRAMMB: 4096}}, + IssuedAt: time.Unix(1, 0), + Seq: 1, + } + l.Sign(priv) + l.GPUs[0].VRAMMB = 24576 // inflate the advertised GPU after signing + if l.Verify(pub) { + t.Fatal("tampered GPU capability verified") + } +} + +func TestStorageCommitTamperDetected(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(nil) + l := CapabilityListing{NodeID: "node-1", Class: ClassMicro, Seq: 1, IssuedAt: time.Unix(1, 0)} + l.Sign(priv) + l.Capacity.StorageCommitMB = 1_000_000 // claim storage after signing + if l.Verify(pub) { + t.Fatal("tampered storage commitment verified") + } +} + func TestListingTamperDetected(t *testing.T) { pub, priv, _ := ed25519.GenerateKey(nil) l := CapabilityListing{NodeID: "node-1", Class: ClassMicro, Seq: 1, IssuedAt: time.Unix(1, 0)} diff --git a/testdata/reproduce_spec.py b/testdata/reproduce_spec.py index dc3a16c..0d366b1 100644 --- a/testdata/reproduce_spec.py +++ b/testdata/reproduce_spec.py @@ -100,13 +100,21 @@ def canon_capability_listing(f) -> bytes: for p in printers: out += enc_string(p["Kind"]) out += enc_string(p["Model"]) + gpus = f.get("GPUs", []) + out += uvarint(len(gpus)) # repeated: count then elements inline + for g in gpus: + out += enc_string(g["API"]) + out += enc_string(g["Model"]) + out += enc_int64(g["VRAMMB"]) cap = f["Capacity"] out += enc_int64(cap["VCPUs"]) out += enc_int64(cap["MemMB"]) out += enc_int64(cap["DiskMB"]) + out += enc_int64(cap["StorageCommitMB"]) out += enc_int64(cap["PrintQPS"]) out += enc_bool(f["OptIn"]["Compute"]) out += enc_bool(f["OptIn"]["Print"]) + out += enc_bool(f["OptIn"]["Storage"]) out += enc_time(f["_IssuedAtNanos"]) out += enc_uint64(f["Seq"]) return bytes(out) diff --git a/testdata/vectors.json b/testdata/vectors.json index 961e142..ec365b1 100644 --- a/testdata/vectors.json +++ b/testdata/vectors.json @@ -149,14 +149,28 @@ "DiskMB": 2000000, "MemMB": 65536, "PrintQPS": 3, + "StorageCommitMB": 500000, "VCPUs": 16 }, "Class": "server", + "GPUs": [ + { + "API": "cuda", + "Model": "RTX 4090", + "VRAMMB": 24576 + }, + { + "API": "vulkan", + "Model": "Adreno 750", + "VRAMMB": 8192 + } + ], "IssuedAt": "unixnano 1700000000000000123", "NodeID": "node-alpha", "OptIn": { "Compute": true, - "Print": false + "Print": false, + "Storage": true }, "Printers": [ { @@ -170,8 +184,8 @@ ], "Seq": 42 }, - "canonical_bytes_hex": "14736f686f636c6f75642f6c697374696e672f76300a6e6f64652d616c70686106736572766572020b747261646974696f6e616c0b4850204c617365724a657406746872656564095072757361204d4b340000000000000010000000000001000000000000001e84800000000000000003010017979cfe362a007b000000000000002a", - "signature_hex": "880fb9b2cb3ecc07ad4eaebf931e2ccc6583d06c8be1b8eb10b823feafb24533afbc68d8778f9e434bc4b5e11291a08673cf61efddeedb57ef3c67d1e8409c03" + "canonical_bytes_hex": "14736f686f636c6f75642f6c697374696e672f76300a6e6f64652d616c70686106736572766572020b747261646974696f6e616c0b4850204c617365724a657406746872656564095072757361204d4b3402046375646108525458203430393000000000000060000676756c6b616e0a416472656e6f2037353000000000000020000000000000000010000000000001000000000000001e8480000000000007a120000000000000000301000117979cfe362a007b000000000000002a", + "signature_hex": "996279bd351b85d29164a572fdb9b7e2a43c740c4f1fcfe3588de98da8e751ca66c1ffc8d71500930265cdc16fc2c1aecd7076fcefae2a9d5d5e62274c718207" }, { "name": "Heartbeat", diff --git a/vectors/vectors_test.go b/vectors/vectors_test.go index a6f817e..4c61239 100644 --- a/vectors/vectors_test.go +++ b/vectors/vectors_test.go @@ -218,7 +218,8 @@ func genMessages(t *testing.T) []messageCase { t.Helper() var out []messageCase - // 1. CapabilityListing — node-signed. Repeated Printers with >=2 elements. + // 1. CapabilityListing — node-signed. Repeated Printers and GPUs with >=2 + // elements each so the count-prefixed repeat encoding is exercised. { seed := seedFill(0x11) priv := ed25519.NewKeyFromSeed(seed) @@ -230,8 +231,12 @@ func genMessages(t *testing.T) []messageCase { {Kind: listing.PrinterTraditional, Model: "HP LaserJet"}, {Kind: listing.Printer3D, Model: "Prusa MK4"}, }, - Capacity: listing.Capacity{VCPUs: 16, MemMB: 65536, DiskMB: 2_000_000, PrintQPS: 3}, - OptIn: listing.WorkloadOptIn{Compute: true, Print: false}, + GPUs: []listing.GPUCapability{ + {API: listing.GPUCUDA, Model: "RTX 4090", VRAMMB: 24576}, + {API: listing.GPUVulkan, Model: "Adreno 750", VRAMMB: 8192}, + }, + Capacity: listing.Capacity{VCPUs: 16, MemMB: 65536, DiskMB: 2_000_000, StorageCommitMB: 500_000, PrintQPS: 3}, + OptIn: listing.WorkloadOptIn{Compute: true, Print: false, Storage: true}, IssuedAt: tAt(fixedNanoA), Seq: 42, } @@ -249,11 +254,16 @@ func genMessages(t *testing.T) []messageCase { {"Kind": string(l.Printers[0].Kind), "Model": l.Printers[0].Model}, {"Kind": string(l.Printers[1].Kind), "Model": l.Printers[1].Model}, }, + "GPUs": []map[string]any{ + {"API": string(l.GPUs[0].API), "Model": l.GPUs[0].Model, "VRAMMB": l.GPUs[0].VRAMMB}, + {"API": string(l.GPUs[1].API), "Model": l.GPUs[1].Model, "VRAMMB": l.GPUs[1].VRAMMB}, + }, "Capacity": map[string]int{ "VCPUs": l.Capacity.VCPUs, "MemMB": l.Capacity.MemMB, - "DiskMB": l.Capacity.DiskMB, "PrintQPS": l.Capacity.PrintQPS, + "DiskMB": l.Capacity.DiskMB, "StorageCommitMB": l.Capacity.StorageCommitMB, + "PrintQPS": l.Capacity.PrintQPS, }, - "OptIn": map[string]bool{"Compute": l.OptIn.Compute, "Print": l.OptIn.Print}, + "OptIn": map[string]bool{"Compute": l.OptIn.Compute, "Print": l.OptIn.Print, "Storage": l.OptIn.Storage}, "IssuedAt": "unixnano " + i64toa(fixedNanoA), "Seq": l.Seq, }),