Skip to content

Commit d10f01f

Browse files
authored
refactor(cluster): update ClusterConfiguration for final release (#115)
* refactor(cluster): re-arrange config for final release Updated comments, and also method for front-running protection * test: add test for checking hmac algo
1 parent eae33b9 commit d10f01f

File tree

2 files changed

+115
-57
lines changed

2 files changed

+115
-57
lines changed

silverback/cluster/types.py

Lines changed: 102 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,19 @@
22
import math
33
import uuid
44
from datetime import datetime
5-
from typing import Annotated
5+
from typing import Annotated, Any
66

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
711
from pydantic import BaseModel, Field, computed_field, field_validator
812

913

14+
def normalize_bytes(val: bytes, length: int = 16) -> bytes:
15+
return b"\x00" * (length - len(val)) + val
16+
17+
1018
class WorkspaceInfo(BaseModel):
1119
id: uuid.UUID
1220
owner_id: uuid.UUID
@@ -18,17 +26,15 @@ class WorkspaceInfo(BaseModel):
1826
class ClusterConfiguration(BaseModel):
1927
"""Configuration of the cluster (represented as 16 byte value)"""
2028

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
2632

2733
# 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
2935
version: int = 1
3036

31-
# Bot Worker Configuration (Bytes 1-2)
37+
# Bot Worker Configuration, priced per bot (Bytes 1-2)
3238
cpu: Annotated[int, Field(ge=0, le=6)] = 0 # defaults to 0.25 vCPU
3339
"""Allocated vCPUs per bot:
3440
- 0.25 vCPU (0)
@@ -42,25 +48,27 @@ class ClusterConfiguration(BaseModel):
4248
memory: Annotated[int, Field(ge=0, le=120)] = 0 # defaults to 512 MiB
4349
"""Total memory per bot (in GB, 0 means '512 MiB')"""
4450

45-
# NOTE: Configure # of workers based on cpu & memory settings
51+
# NOTE: # of workers configured based on cpu & memory settings
4652

47-
# Runner configuration (Bytes 3-5)
53+
# Runner configuration (Bytes 3-4)
4854
networks: Annotated[int, Field(ge=1, le=20)] = 1
4955
"""Maximum number of concurrent network runners"""
5056

5157
bots: Annotated[int, Field(ge=1, le=250)] = 1
52-
"""Maximum number of concurrent bots running"""
58+
"""Maximum number of concurrent running bots"""
5359

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
5661

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
6066

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.
6472

6573
@field_validator("cpu", mode="before")
6674
def parse_cpu_value(cls, value: str | int) -> int:
@@ -75,43 +83,40 @@ def parse_memory_value(cls, value: str | int) -> int:
7583
return value
7684

7785
mem, units = value.split(" ")
78-
if units.lower() == "mib":
86+
if units.lower() in ("mib", "mb"):
7987
assert mem == "512"
8088
return 0
8189

8290
assert units.lower() == "gb"
8391
return int(mem)
8492

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:
8795
if not isinstance(value, str):
8896
return value
8997

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"
93101
return 0
94102

95-
assert units.lower() == "tb"
96-
return int(storage)
103+
assert units.lower() == "kb/sec"
104+
return int(bandwidth)
97105

98106
def settings_display_dict(self) -> dict:
99107
return dict(
100108
version=self.version,
109+
runner=dict(
110+
networks=self.networks,
111+
bots=self.bots,
112+
),
101113
bots=dict(
102114
cpu=f"{256 * 2**self.cpu / 1024} vCPU",
103115
memory=f"{self.memory} GB" if self.memory > 0 else "512 MiB",
104116
),
105-
general=dict(
106-
bots=self.bots,
107-
secrets=self.secrets,
108-
),
109-
runner=dict(
110-
networks=self.networks,
111-
triggers=self.triggers,
112-
),
113117
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",
115120
),
116121
)
117122

@@ -121,41 +126,83 @@ def _decode_byte(value: int, byte: int) -> int:
121126
return (value >> (8 * byte)) & (2**8 - 1) # NOTE: max uint8
122127

123128
@classmethod
124-
def decode(cls, value: int) -> "ClusterConfiguration":
129+
def decode(cls, value: Any) -> "ClusterConfiguration":
125130
"""Decode the configuration from 8 byte integer value"""
126131
if isinstance(value, ClusterConfiguration):
127132
return value # TODO: Something weird with SQLModel
128133

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+
129140
# 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}'")
140155

141156
@staticmethod
142157
def _encode_byte(value: int, byte: int) -> int:
143158
return value << (8 * byte)
144159

145160
def encode(self) -> int:
146161
"""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
148163
return (
149164
self._encode_byte(self.version, 0)
150165
+ self._encode_byte(self.cpu, 1)
151166
+ self._encode_byte(self.memory, 2)
152167
+ self._encode_byte(self.networks, 3)
153168
+ 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)
157171
)
158172

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+
159206

160207
class ClusterTier(enum.IntEnum):
161208
"""Suggestions for different tier configurations"""
@@ -165,18 +212,16 @@ class ClusterTier(enum.IntEnum):
165212
memory="512 MiB",
166213
networks=3,
167214
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
171217
).encode()
172218
PROFESSIONAL = ClusterConfiguration(
173219
cpu="1 vCPU",
174220
memory="2 GB",
175221
networks=10,
176222
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
180225
).encode()
181226

182227
def configuration(self) -> ClusterConfiguration:

tests/test_cluster.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import uuid
2+
3+
from silverback.cluster.types import ClusterConfiguration
4+
5+
6+
def test_hmac_signature():
7+
config = ClusterConfiguration()
8+
cluster_id = uuid.uuid4()
9+
owner = "0x4838B106FCe9647Bdf1E7877BF73cE8B0BAD5f97"
10+
product_code = config.get_product_code(owner, cluster_id)
11+
# NOTE: Ensure we can properly decode the encoded product code into a configuration
12+
assert config == ClusterConfiguration.decode(product_code[:16])
13+
assert config.validate_product_code(owner, product_code[16:], cluster_id)

0 commit comments

Comments
 (0)