2
2
import math
3
3
import uuid
4
4
from datetime import datetime
5
- from typing import Annotated
5
+ from typing import Annotated , Any
6
6
7
+ from cryptography .exceptions import InvalidSignature
8
+ from cryptography .hazmat .primitives .hmac import HMAC , hashes
9
+ from eth_pydantic_types import Address , HexBytes
10
+ from eth_utils import to_bytes , to_int
7
11
from pydantic import BaseModel , Field , computed_field , field_validator
8
12
9
13
14
+ def normalize_bytes (val : bytes , length : int = 16 ) -> bytes :
15
+ return b"\x00 " * (length - len (val )) + val
16
+
17
+
10
18
class WorkspaceInfo (BaseModel ):
11
19
id : uuid .UUID
12
20
owner_id : uuid .UUID
@@ -18,17 +26,15 @@ class WorkspaceInfo(BaseModel):
18
26
class ClusterConfiguration (BaseModel ):
19
27
"""Configuration of the cluster (represented as 16 byte value)"""
20
28
21
- # NOTE: This configuration must be encode-able to a uint64 value for db storage
22
- # and on-chain processing through ApePay
23
-
24
- # NOTE: All defaults should be the minimal end of the scale,
25
- # so that `__or__` works right
29
+ # NOTE: This configuration must be encode-able to a uint64 value for db duration and on-chain
30
+ # processing through ApePay
31
+ # NOTE: All defaults should be the minimal end of the scale, so that `__or__` works right
26
32
27
33
# Version byte (Byte 0)
28
- # NOTE: Just in-case we change this after release
34
+ # NOTE: Update this to revise new models for every configuration change
29
35
version : int = 1
30
36
31
- # Bot Worker Configuration (Bytes 1-2)
37
+ # Bot Worker Configuration, priced per bot (Bytes 1-2)
32
38
cpu : Annotated [int , Field (ge = 0 , le = 6 )] = 0 # defaults to 0.25 vCPU
33
39
"""Allocated vCPUs per bot:
34
40
- 0.25 vCPU (0)
@@ -42,25 +48,27 @@ class ClusterConfiguration(BaseModel):
42
48
memory : Annotated [int , Field (ge = 0 , le = 120 )] = 0 # defaults to 512 MiB
43
49
"""Total memory per bot (in GB, 0 means '512 MiB')"""
44
50
45
- # NOTE: Configure # of workers based on cpu & memory settings
51
+ # NOTE: # of workers configured based on cpu & memory settings
46
52
47
- # Runner configuration (Bytes 3-5 )
53
+ # Runner configuration (Bytes 3-4 )
48
54
networks : Annotated [int , Field (ge = 1 , le = 20 )] = 1
49
55
"""Maximum number of concurrent network runners"""
50
56
51
57
bots : Annotated [int , Field (ge = 1 , le = 250 )] = 1
52
- """Maximum number of concurrent bots running"""
58
+ """Maximum number of concurrent running bots """
53
59
54
- triggers : Annotated [int , Field (ge = 50 , le = 1000 , multiple_of = 5 )] = 50
55
- """Maximum number of task triggers across all running bots"""
60
+ # NOTE: Byte 5 unused
56
61
57
- # Recorder configuration (Byte 6)
58
- storage : Annotated [int , Field (ge = 0 , le = 250 )] = 0 # 512 GB
59
- """Total task results and metrics parquet storage (in TB, 0 means '512 GB')"""
62
+ # Recorder configuration (Bytes 6-7)
63
+ bandwidth : Annotated [int , Field (ge = 0 , le = 250 )] = 0 # 512 kB/sec
64
+ """Rate at which data should be emitted by cluster (in MB/sec, 0 means '512 kB')"""
65
+ # NOTE: This rate is only estimated average, and will serve as a throttling threshold
60
66
61
- # Cluster general configuration (Byte 7)
62
- secrets : Annotated [int , Field (ge = 10 , le = 100 )] = 10
63
- """Total managed secrets"""
67
+ duration : Annotated [int , Field (ge = 1 , le = 120 )] = 1
68
+ """Time to keep data recording duration (in months)"""
69
+ # NOTE: The storage space alloted for your recordings will be `bandwidth x duration`.
70
+ # If the storage space is exceeded, it will be aggressively pruned to maintain that size.
71
+ # We will also prune duration past that point less aggressively, if there is unused space.
64
72
65
73
@field_validator ("cpu" , mode = "before" )
66
74
def parse_cpu_value (cls , value : str | int ) -> int :
@@ -75,43 +83,40 @@ def parse_memory_value(cls, value: str | int) -> int:
75
83
return value
76
84
77
85
mem , units = value .split (" " )
78
- if units .lower () == "mib" :
86
+ if units .lower () in ( "mib" , "mb" ) :
79
87
assert mem == "512"
80
88
return 0
81
89
82
90
assert units .lower () == "gb"
83
91
return int (mem )
84
92
85
- @field_validator ("storage " , mode = "before" )
86
- def parse_storage_value (cls , value : str | int ) -> int :
93
+ @field_validator ("bandwidth " , mode = "before" )
94
+ def parse_bandwidth_value (cls , value : str | int ) -> int :
87
95
if not isinstance (value , str ):
88
96
return value
89
97
90
- storage , units = value .split (" " )
91
- if units .lower () == "gb " :
92
- assert storage == "512"
98
+ bandwidth , units = value .split (" " )
99
+ if units .lower () == "b/sec " :
100
+ assert bandwidth == "512"
93
101
return 0
94
102
95
- assert units .lower () == "tb "
96
- return int (storage )
103
+ assert units .lower () == "kb/sec "
104
+ return int (bandwidth )
97
105
98
106
def settings_display_dict (self ) -> dict :
99
107
return dict (
100
108
version = self .version ,
109
+ runner = dict (
110
+ networks = self .networks ,
111
+ bots = self .bots ,
112
+ ),
101
113
bots = dict (
102
114
cpu = f"{ 256 * 2 ** self .cpu / 1024 } vCPU" ,
103
115
memory = f"{ self .memory } GB" if self .memory > 0 else "512 MiB" ,
104
116
),
105
- general = dict (
106
- bots = self .bots ,
107
- secrets = self .secrets ,
108
- ),
109
- runner = dict (
110
- networks = self .networks ,
111
- triggers = self .triggers ,
112
- ),
113
117
recorder = dict (
114
- storage = f"{ self .storage } TB" if self .storage > 0 else "512 GB" ,
118
+ bandwidth = f"{ self .bandwidth } MB/sec" if self .bandwidth > 0 else "512 kB/sec" ,
119
+ duration = f"{ self .duration } months" ,
115
120
),
116
121
)
117
122
@@ -121,41 +126,83 @@ def _decode_byte(value: int, byte: int) -> int:
121
126
return (value >> (8 * byte )) & (2 ** 8 - 1 ) # NOTE: max uint8
122
127
123
128
@classmethod
124
- def decode (cls , value : int ) -> "ClusterConfiguration" :
129
+ def decode (cls , value : Any ) -> "ClusterConfiguration" :
125
130
"""Decode the configuration from 8 byte integer value"""
126
131
if isinstance (value , ClusterConfiguration ):
127
132
return value # TODO: Something weird with SQLModel
128
133
134
+ elif isinstance (value , bytes ):
135
+ value = to_int (value )
136
+
137
+ elif not isinstance (value , int ):
138
+ raise ValueError (f"Cannot decode type: '{ type (value )} '" )
139
+
129
140
# NOTE: Do not change the order of these, these are not forwards compatible
130
- return cls (
131
- version = cls ._decode_byte (value , 0 ),
132
- cpu = cls ._decode_byte (value , 1 ),
133
- memory = cls ._decode_byte (value , 2 ),
134
- networks = cls ._decode_byte (value , 3 ),
135
- bots = cls ._decode_byte (value , 4 ),
136
- triggers = 5 * cls ._decode_byte (value , 5 ),
137
- storage = cls ._decode_byte (value , 6 ),
138
- secrets = cls ._decode_byte (value , 7 ),
139
- )
141
+ if (version := cls ._decode_byte (value , 0 )) == 1 :
142
+ return cls (
143
+ version = version ,
144
+ cpu = cls ._decode_byte (value , 1 ),
145
+ memory = cls ._decode_byte (value , 2 ),
146
+ networks = cls ._decode_byte (value , 3 ),
147
+ bots = cls ._decode_byte (value , 4 ),
148
+ bandwidth = cls ._decode_byte (value , 6 ),
149
+ duration = cls ._decode_byte (value , 7 ),
150
+ )
151
+
152
+ # NOTE: Update this to revise new models for every configuration change
153
+
154
+ raise ValueError (f"Unsupported version: '{ version } '" )
140
155
141
156
@staticmethod
142
157
def _encode_byte (value : int , byte : int ) -> int :
143
158
return value << (8 * byte )
144
159
145
160
def encode (self ) -> int :
146
161
"""Encode configuration as 8 byte integer value"""
147
- # NOTE: Do not change the order of these, these are not forwards compatible
162
+ # NOTE: Only need to encode the latest version, can change implementation below
148
163
return (
149
164
self ._encode_byte (self .version , 0 )
150
165
+ self ._encode_byte (self .cpu , 1 )
151
166
+ self ._encode_byte (self .memory , 2 )
152
167
+ self ._encode_byte (self .networks , 3 )
153
168
+ self ._encode_byte (self .bots , 4 )
154
- + self ._encode_byte (self .triggers // 5 , 5 )
155
- + self ._encode_byte (self .storage , 6 )
156
- + self ._encode_byte (self .secrets , 7 )
169
+ + self ._encode_byte (self .bandwidth , 6 )
170
+ + self ._encode_byte (self .duration , 7 )
157
171
)
158
172
173
+ def get_product_code (self , owner : Address , cluster_id : uuid .UUID ) -> HexBytes :
174
+ # returns bytes32 product code `(sig || config)`
175
+ config = normalize_bytes (to_bytes (self .encode ()))
176
+
177
+ # NOTE: MD5 is not recommended for general use, but is not considered insecure for HMAC use.
178
+ # However, our security property here is simple front-running protection to ensure
179
+ # only Workspace members can open a Stream to fund a Cluster (since `cluster_id` is a
180
+ # shared secret kept private between members of a Workspace when Cluster is created).
181
+ # Unless HMAC-MD5 can be shown insecure enough to recover the secret key in <5mins,
182
+ # this is probably good enough for now (and retains 16B size digest that fits with our
183
+ # encoded 16B configuration into a bytes32 val, to avoid memory expansion w/ DynArray)
184
+ h = HMAC (cluster_id .bytes , hashes .MD5 ())
185
+ h .update (normalize_bytes (to_bytes (hexstr = owner ), length = 20 ) + config )
186
+ sig = normalize_bytes (h .finalize ()) # 16 bytes
187
+
188
+ return HexBytes (config + sig )
189
+
190
+ def validate_product_code (
191
+ self , owner : Address , signature : bytes , cluster_id : uuid .UUID
192
+ ) -> bool :
193
+ # NOTE: Put `cluster_id` last so it's easy to use with `functools.partial`
194
+ config = normalize_bytes (to_bytes (self .encode ()))
195
+
196
+ h = HMAC (cluster_id .bytes , hashes .MD5 ())
197
+ h .update (normalize_bytes (to_bytes (hexstr = owner ), length = 20 ) + config )
198
+
199
+ try :
200
+ h .verify (signature )
201
+ return True
202
+
203
+ except InvalidSignature :
204
+ return False
205
+
159
206
160
207
class ClusterTier (enum .IntEnum ):
161
208
"""Suggestions for different tier configurations"""
@@ -165,18 +212,16 @@ class ClusterTier(enum.IntEnum):
165
212
memory = "512 MiB" ,
166
213
networks = 3 ,
167
214
bots = 5 ,
168
- triggers = 50 ,
169
- storage = "512 GB" ,
170
- secrets = 10 ,
215
+ bandwidth = "512 B/sec" , # 1.236 GB/mo
216
+ duration = 3 , # months
171
217
).encode ()
172
218
PROFESSIONAL = ClusterConfiguration (
173
219
cpu = "1 vCPU" ,
174
220
memory = "2 GB" ,
175
221
networks = 10 ,
176
222
bots = 20 ,
177
- triggers = 400 ,
178
- storage = "5 TB" ,
179
- secrets = 25 ,
223
+ bandwidth = "5 kB/sec" , # 12.36 GB/mo
224
+ duration = 12 , # 1 year = ~148GB
180
225
).encode ()
181
226
182
227
def configuration (self ) -> ClusterConfiguration :
0 commit comments