Added create container grpc method

Signed-off-by: Ilyas Niyazov <i.niyazov@yadro.com>
This commit is contained in:
Ilyas Niyazov 2025-03-10 13:46:17 +03:00
parent 9a1b5d778b
commit f8465e5b99
34 changed files with 532 additions and 53 deletions

0
frostfs_sdk/__init__.py Normal file
View file

View file

@ -0,0 +1,30 @@
# Create channel and Stubs
import grpc
from frostfs_sdk.client.models.client_environment import ClientEnvironment
from frostfs_sdk.client.models.client_settings import ClientSettings
from frostfs_sdk.client.models.ecdsa import ECDSA
from frostfs_sdk.client.services.container import ContainerClient
class FrostfsClient:
def __init__(self, client_settings: ClientSettings):
self.channel = grpc.insecure_channel(f"{client_settings.host}:{client_settings.port}")
self.ecdsa: ECDSA = ECDSA(wif=client_settings.wif)
client_environment = ClientEnvironment(self.ecdsa, self.channel)
self.container = ContainerClient(client_environment)
def close(self):
self.channel.close()
"""
import frostfs_sdk
WIF = "L5XNVUzPnma6m4mPrWEN6CcTscJERcfX3yvb1cdffdxe1iriAshU"
address = "10.78.128.25:8080"
client = frostfs_sdk.FrostfsClient(ClientSettings(WIF, address))
params = frostfs_sdk.models.PrmsCreateContainer(name="1234")
client.container.create(params)
"""

View file

@ -0,0 +1,8 @@
import grpc
from frostfs_sdk.client.models.ecdsa import ECDSA
class ClientEnvironment:
def __init__(self, ecdsa: ECDSA, channel: grpc.Channel):
self.ecdsa = ecdsa
self.channel = channel

View file

@ -0,0 +1,19 @@
class ClientSettings:
def __init__(self, wif: str = None, address: str = None):
"""
Initializes client settings with validation.
Args:
wif: Wallet import format string
address: FrostFS node host address
"""
self.wif = wif
self.address = address
# Perform validation after initialization
self.validate()
def validate(self):
"""Performs runtime validation of the settings"""
if not (self.address and self.wif):
raise ValueError("The value must be specified ADDRESS and WIF")

View file

@ -0,0 +1,8 @@
from frostfs_sdk.cryptography.key_extension import KeyExtension
class ECDSA:
def __init__(self, wif: str):
self.wif = wif
self.private_key: bytes = KeyExtension().get_private_key_from_wif(wif)
self.public_key: bytes = KeyExtension().get_public_key(self.private_key)

View file

@ -0,0 +1,18 @@
from dataclasses import dataclass, field
from typing import Optional, Dict
from frostfs_sdk.models.dto.container import Container
from frostfs_sdk.models.dto.session_token import SessionToken
from frostfs_sdk.client.parameters.wait import PrmWait
@dataclass(frozen=True)
class PrmContainerCreate:
container: Container
wait_params: Optional[PrmWait] = None
session_token: Optional[SessionToken] = None
x_headers: Dict[str, str] = field(default_factory=dict)
def __post_init__(self):
if self.wait_params is None:
object.__setattr__(self, 'wait_params', PrmWait())

View file

@ -0,0 +1,21 @@
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Optional
@dataclass(frozen=True)
class PrmWait:
DEFAULT_TIMEOUT: timedelta = field(default=timedelta(seconds=120), init=False)
DEFAULT_POLL_INTERVAL: timedelta = field(default=timedelta(seconds=5), init=False)
timeout: timedelta = DEFAULT_TIMEOUT
poll_interval: timedelta = DEFAULT_POLL_INTERVAL
def __post_init__(self):
if self.timeout is None:
object.__setattr__(self, 'timeout', self.DEFAULT_TIMEOUT)
if self.poll_interval is None:
object.__setattr__(self, 'poll_interval', self.DEFAULT_POLL_INTERVAL)
def get_deadline(self) -> datetime:
return datetime.now() + self.timeout

View file

@ -0,0 +1,32 @@
# implementation Conainer methods
from frostfs_sdk.client.models.client_environment import ClientEnvironment
from frostfs_sdk.cryptography.signer import Signer
from frostfs_sdk.models.dto.container import ContainerId
import protos.models.container.service_pb2_grpc as service_pb2_grpc_container
import protos.models.container.service_pb2 as service_pb2_container
from frostfs_sdk.client.parameters.container_create import PrmContainerCreate
from frostfs_sdk.models.mappers.container_mapper import ContainerMapper
class ContainerClient:
def __init__(self, client_environment: ClientEnvironment):
self.container_stub = service_pb2_grpc_container.ContainerServiceStub(client_environment.channel)
self.ecdsa = client_environment.ecdsa
def create(self, prm_container_create: PrmContainerCreate) -> ContainerId:
request = self.create_put_request(prm_container_create)
response: service_pb2_container.PutResponse = self.container_stub.Put(request)
return ContainerId(value=response.body.container_id)
def create_put_request(self, prm: PrmContainerCreate):
grpc_container=ContainerMapper().to_grpc_message(prm.container)
body = service_pb2_container.PutRequest.Body(
container=grpc_container,
signature=Signer.sign_rfc6979(self.ecdsa.private_key, grpc_container)
)
request = service_pb2_container.PutRequest(body=body)
signed_request = Signer.sign(self.ecdsa.private_key, request)
return signed_request

View file

@ -0,0 +1 @@
# Cryptography directory package

View file

@ -0,0 +1,44 @@
import base58
import ecdsa
class KeyExtension:
def get_private_key_from_wif(self, wif: str) -> bytes:
"""
Converts a WIF private key to a byte array.
:param wif: WIF private key in string format.
:return: Private key in byte format (32 bytes).
:raises ValueError: If the WIF key is incorrect.
"""
assert not self.is_empty(wif)
decoded = base58.b58decode_check(wif)
if len(decoded) != 34 or decoded[0] != 0x80 or decoded[-1] != 0x01:
raise ValueError("Incorrect WIF private key")
private_key = decoded[1:-1]
return private_key
def get_public_key(self, private_key: bytes) -> bytes:
"""
Extract public key from Private key
:param private_key: Private key in byte format (32 bytes).
:return: compressed public key in byte format (33 bytes).
:raises ValueError: If the private_key key is empty or null.
"""
assert not self.is_empty(private_key)
if len(private_key) != 32:
raise ValueError(f"Incorrect len of private key, Expected: 32, Actual: {len(private_key)}")
public_key = ecdsa.SigningKey.from_string(private_key, curve=ecdsa.NIST256p).get_verifying_key()
compressed_public_key = public_key.to_string("compressed")
return compressed_public_key
@staticmethod
def is_empty(sequence_symbols: bytes | str):
if len(sequence_symbols) == 0 or sequence_symbols is None:
raise ValueError(f"Empty sequence symbols of key: {sequence_symbols}")
return False

View file

@ -0,0 +1,28 @@
import ecdsa
from hashlib import sha256, sha512
class Signer:
@staticmethod
def sign_rfc6979(private_key: bytes, message: bytes) -> bytes:
if len(private_key) == 0 or private_key is None:
raise ValueError(f"Incorrect private_key: {private_key}")
sk = ecdsa.SigningKey.from_string(private_key, curve=ecdsa.NIST256p, hashfunc=sha256)
signature = sk.sign_deterministic(message)
return signature
@staticmethod
def sign(private_key: bytes, message: bytes) -> bytes:
if len(private_key) == 0 or private_key is None:
raise ValueError(f"Incorrect private key: {private_key}")
sk = ecdsa.SigningKey.from_string(private_key, curve=ecdsa.NIST256p, hashfunc=sha512)
signature = sk.sign(message)
# the first byte indicates the node version marker
signature_with_marker = bytes([0x04]) + signature
return signature_with_marker

View file

@ -0,0 +1,50 @@
import ecdsa
from hashlib import sha256, sha512
class Verifier:
def verify_rfc6979(self, public_key: bytes, message: bytes, signature: bytes) -> bool:
"""
Verify a signature using the public key.
:param public_key: Public key in byte format.
:param message: Signature verification message in byte format.
:param signature: Signature in byte format.
:return: True if the signature is correct, otherwise False.
:raises: ValueError: If the public_key key is incorrect.
"""
if len(public_key) == 0 or public_key is None:
raise ValueError(f"Incorrect public key: {public_key}")
if message is None or signature is None:
return False
vk = ecdsa.VerifyingKey.from_string(public_key, curve=ecdsa.NIST256p, hashfunc=sha256)
try:
return vk.verify(signature, message)
except ecdsa.BadSignatureError:
return False
def verify(self, public_key: bytes, message: bytes, signature: bytes) -> bool:
"""
Verify a signature using the public key.
:param public_key: Public key in byte format.
:param message: Signature verification message in byte format.
:param signature: Signature in byte format.
:return: True if the signature is correct, otherwise False.
:raises: ValueError: If the public_key key is incorrect.
"""
if len(public_key) == 0 or public_key is None:
raise ValueError(f"Incorrect public key: {public_key}")
if message is None or signature is None:
return False
vk = ecdsa.VerifyingKey.from_string(public_key, curve=ecdsa.NIST256p, hashfunc=sha512)
try:
return vk.verify(signature, message)
except ecdsa.BadSignatureError:
return False

View file

@ -0,0 +1,25 @@
from dataclasses import dataclass, field
from typing import Dict, Optional
import uuid
from frostfs_sdk.models.enums.basic_acl import BasicAcl
from frostfs_sdk.models.dto.placement_policy import PlacementPolicy
@dataclass
class Container:
basicAcl: BasicAcl
placementPolicy: PlacementPolicy
nonce: uuid.UUID = field(default_factory=uuid.uuid4)
version: Optional[str] = None
attributes: Dict[str, str] = field(default_factory=dict)
def __init__(self, basicAcl: BasicAcl, placementPolicy: PlacementPolicy):
self.basicAcl = basicAcl
self.placementPolicy = placementPolicy
@dataclass
class ContainerId:
value: str

View file

@ -0,0 +1,14 @@
from dataclasses import dataclass
from frostfs_sdk.models.enums.filter_operation import FilterOperation
@dataclass(frozen=True)
class Filter:
"""
Data Transfer Object for Filter configuration
"""
name: str
key: str
operation: FilterOperation
value: str

View file

@ -0,0 +1,9 @@
from dataclasses import dataclass
from typing import List
from frostfs_sdk.models.dto.replica import Replica
@dataclass
class PlacementPolicy:
replicas: List[Replica]
unique: bool

View file

@ -0,0 +1,12 @@
from dataclasses import dataclass, field
EMPTY_STRING = ""
@dataclass
class Replica:
count: int
selector: str = field(default=EMPTY_STRING)
def __post_init__(self):
self.selector = self.selector if self.selector else EMPTY_STRING

View file

@ -0,0 +1,15 @@
from dataclasses import dataclass
from frostfs_sdk.models.enums.selector_clause import SelectorClause
@dataclass(frozen=True)
class Selector:
"""
Data Transfer Object for Selector configuration
"""
name: str
count: int
clause: SelectorClause
attribute: str
filter: str

View file

@ -0,0 +1,5 @@
from dataclasses import dataclass
@dataclass(frozen=True)
class SessionToken:
token: bytes

View file

@ -0,0 +1,7 @@
from enum import Enum
class BasicAcl(Enum):
PRIVATE = 0x1C8C8CCC
PUBLIC_RO = 0x1FBF8CFF
PUBLIC_RW = 0x1FBFBFFF
PUBLIC_APPEND = 0x1FBF9FFF

View file

@ -0,0 +1,42 @@
class FilterOperation:
"""
Enum for filter operations with integer value mapping
"""
OPERATION_UNSPECIFIED = 0
EQ = 1
NE = 2
GT = 3
GE = 4
LT = 5
LE = 6
OR = 7
AND = 8
NOT = 9
LIKE = 10
_value_map = {
0: OPERATION_UNSPECIFIED,
1: EQ,
2: NE,
3: GT,
4: GE,
5: LT,
6: LE,
7: OR,
8: AND,
9: NOT,
10: LIKE
}
@classmethod
def get(cls, value: int) -> 'FilterOperation':
"""
Get enum instance by integer value
Args:
value: Integer value of the operation
Returns:
Corresponding FilterOperation instance
"""
return cls._value_map.get(value)

View file

@ -0,0 +1,26 @@
class SelectorClause:
"""
Enum for selector clauses with integer value mapping
"""
CLAUSE_UNSPECIFIED = 0
SAME = 1
DISTINCT = 2
_value_map = {
0: CLAUSE_UNSPECIFIED,
1: SAME,
2: DISTINCT
}
@classmethod
def get(cls, value: int) -> 'SelectorClause':
"""
Get enum instance by integer value
Args:
value: Integer value of the clause
Returns:
Corresponding SelectorClause instance
"""
return cls._value_map.get(value)

View file

@ -0,0 +1,65 @@
from typing import ByteString, Optional
from frostfs_sdk.models.mappers.placement_policy_mapper import PlacementPolicyMapper
import protos.models.container.types_pb2 as types_pb2_container
from frostfs_sdk.models.dto.container import Container
class ContainerMapper:
@staticmethod
def to_grpc_message(container: Container) -> Optional[types_pb2_container.Container]:
"""
Converts Container DTO to gRPC message
Args:
container: Container DTO object
Returns:
gRPC Container message builder
"""
if not container:
return None
attributes = [
types_pb2_container.Container.Attribute(key=k, value=v)
for k, v in container.attributes.items()
]
grpc_container = types_pb2_container.Container(
# nonce=ByteString.copy(container.nonce),
placement_policy=PlacementPolicyMapper.to_grpc_message(container.placementPolicy),
attributes=attributes
)
# if container.owner_id:
# grpc_container.owner_id = OwnerIdMapper.to_grpc_message(container.owner_id)
# if container.version:
# grpc_container.version = VersionMapper.to_grpc_message(container.version)
return grpc_container
@staticmethod
def to_model(container_grpc: types_pb2_container.Container) -> Optional[Container]:
"""
Converts gRPC message to Container DTO
Args:
container_grpc: gRPC Container message
Returns:
Container DTO object
"""
if not container_grpc or container_grpc.ByteSize() == 0:
return None
attributes = {attr.key: attr.value for attr in container_grpc.attributes}
return Container(
# nonce=UuidUtils.as_uuid(container_grpc.nonce.to_bytes()),
placement_policy=PlacementPolicyMapper.to_model(container_grpc.placement_policy),
# version=VersionMapper.to_model(container_grpc.version),
# owner_id=OwnerIdMapper.to_model(container_grpc.owner_id),
attributes=attributes
)

View file

@ -0,0 +1,60 @@
from typing import List, Optional
from frostfs_sdk.models.enums.filter_operation import FilterOperation
from frostfs_sdk.models.dto.filter import Filter
import protos.models.netmap.types_pb2 as types_pb2_netmap
class FilterMapper:
@staticmethod
def to_grpc_messages(filters: List[Filter]) -> List[types_pb2_netmap.Filter]:
"""
Converts list of Filter DTOs to gRPC messages with nested conversion
"""
if not filters:
return []
return [FilterMapper.to_grpc_message(f) for f in filters]
@staticmethod
def to_grpc_message(filter_dto: Filter) -> types_pb2_netmap.Filter:
"""
Converts Filter DTO to gRPC message with nested filters
"""
operation = types_pb2_netmap.Filter.Operation.Value(filter_dto.operation.value)
return types_pb2_netmap.Filter(
name=filter_dto.name,
key=filter_dto.key,
op=operation,
value=filter_dto.value,
filters=FilterMapper.to_grpc_messages(filter_dto.filters)
)
@staticmethod
def to_models(filters_grpc: List[types_pb2_netmap.Filter]) -> Optional[List[Filter]]:
"""
Converts gRPC messages to Filter DTOs with nested conversion
"""
if not filters_grpc:
return None
return [FilterMapper.to_model(f) for f in filters_grpc]
@staticmethod
def to_model(filter_grpc: types_pb2_netmap.Filter) -> Optional[Filter]:
"""
Converts gRPC message to Filter DTO with nested filters
"""
if not filter_grpc or filter_grpc.ByteSize() == 0:
return None
operation = FilterOperation(filter_grpc.op)
return Filter(
name=filter_grpc.name,
key=filter_grpc.key,
operation=operation,
value=filter_grpc.value,
filters=FilterMapper.to_models(filter_grpc.filters)
)

View file

@ -0,0 +1,50 @@
from typing import Optional
from frostfs_sdk.models.mappers.filter_mapper import FilterMapper
import protos.models.netmap.types_pb2 as types_pb2_netmap
from frostfs_sdk.models.dto.placement_policy import PlacementPolicy
class PlacementPolicyMapper:
@staticmethod
def to_grpc_message(policy: PlacementPolicy) -> Optional[types_pb2_netmap.PlacementPolicy]:
"""
Converts PlacementPolicy DTO to gRPC message
Args:
policy: PlacementPolicy DTO object
Returns:
gRPC PlacementPolicy message
"""
if not policy:
return None
return types_pb2_netmap.PlacementPolicy(
unique=policy.unique,
container_backup_factor=policy.backup_factor,
filters=FilterMapper.to_grpc_messages(policy.filters),
# selectors=SelectorMapper.to_grpc_messages(policy.selectors),
# replicas=ReplicaMapper.to_grpc_messages(policy.replicas)
)
@staticmethod
def to_model(policy_grpc: types_pb2_netmap.PlacementPolicy) -> Optional[PlacementPolicy]:
"""
Converts gRPC message to PlacementPolicy DTO
Args:
policy_grpc: gRPC PlacementPolicy message
Returns:
PlacementPolicy DTO object
"""
if not policy_grpc or policy_grpc.ByteSize() == 0:
return None
return PlacementPolicy(
# replicas=ReplicaMapper.to_models(policy_grpc.replicas),
unique=policy_grpc.unique,
backup_factor=policy_grpc.container_backup_factor,
filters=FilterMapper.to_models(policy_grpc.filters),
# selectors=SelectorMapper.to_models(policy_grpc.selectors)
)