Rename neofs to frostfs

Signed-off-by: Yulia Kovshova <y.kovshova@yadro.com>
This commit is contained in:
Юлия Ковшова 2023-01-10 16:02:24 +03:00 committed by Stanislav Bogatyrev
parent 5a2c7ac98d
commit 6d3b6f0f2f
83 changed files with 330 additions and 338 deletions

View file

@ -0,0 +1 @@
__version__ = "1.1.1"

View file

@ -0,0 +1,2 @@
from frostfs_testlib.blockchain.multisig import Multisig
from frostfs_testlib.blockchain.rpc_client import RPCClient

View file

@ -0,0 +1,51 @@
from frostfs_testlib.cli import NeoGo
class Multisig:
def __init__(self, neogo: NeoGo, invoke_tx_file: str, block_period: int):
self.neogo = neogo
self.invoke_tx_file = invoke_tx_file
self.block_period = block_period
def create_and_send(
self,
contract_hash: str,
contract_args: str,
multisig_hash: str,
wallets: list[str],
passwords: list[str],
address: str,
endpoint: str,
) -> None:
if not len(wallets):
raise AttributeError("Got empty wallets list")
self.neogo.contract.invokefunction(
address=address,
rpc_endpoint=endpoint,
wallet=wallets[0],
wallet_password=passwords[0],
out=None if len(wallets) == 1 else self.invoke_tx_file,
scripthash=contract_hash,
arguments=contract_args,
multisig_hash=multisig_hash,
)
if len(wallets) > 1:
# sign with rest of wallets except the last one
for wallet in wallets[1:-1]:
self.neogo.wallet.sign(
wallet=wallet,
input_file=self.invoke_tx_file,
out=self.invoke_tx_file,
address=address,
)
# sign tx with last wallet and push it to blockchain
self.neogo.wallet.sign(
wallet=wallets[-1],
input_file=self.invoke_tx_file,
out=self.invoke_tx_file,
address=address,
rpc_endpoint=endpoint,
)

View file

@ -0,0 +1,156 @@
import json
from time import sleep
from typing import Optional
from cli import NeoGo
from shell import Shell
from utils.converters import process_b64_bytearray
from frostfs_testlib.blockchain import Multisig
class RoleDesignation:
def __init__(
self,
shell: Shell,
neo_go_exec_path: str,
block_period: int,
designate_contract: str,
):
self.neogo = NeoGo(shell, neo_go_exec_path)
self.block_period = block_period
self.designate_contract = designate_contract
def set_notary_nodes(
self,
addr: str,
pubkeys: list[str],
script_hash: str,
wallet: str,
passwd: str,
endpoint: str,
) -> str:
keys = [f"bytes:{k}" for k in pubkeys]
keys_str = " ".join(keys)
out = self.neogo.contract.invokefunction(
address=addr,
scripthash=self.designate_contract,
wallet=wallet,
wallet_password=passwd,
rpc_endpoint=endpoint,
arguments=f"designateAsRole int:32 [ {keys_str} ] -- {script_hash}",
force=True,
)
sleep(self.block_period)
return out.stdout.split(" ")[-1]
def set_inner_ring(
self,
addr: str,
pubkeys: list[str],
script_hash: str,
wallet: str,
passwd: str,
endpoint: str,
) -> str:
keys = [f"bytes:{k}" for k in pubkeys]
keys_str = " ".join(keys)
out = self.neogo.contract.invokefunction(
address=addr,
scripthash=self.designate_contract,
wallet=wallet,
wallet_password=passwd,
rpc_endpoint=endpoint,
arguments=f"designateAsRole int:16 [ {keys_str} ] -- {script_hash}",
force=True,
)
sleep(self.block_period)
return out.stdout.split(" ")[-1]
def set_oracles(
self,
addr: str,
pubkeys: list[str],
script_hash: str,
wallet: str,
passwd: str,
endpoint: str,
) -> str:
keys = [f"bytes:{k}" for k in pubkeys]
keys_str = " ".join(keys)
out = self.neogo.contract.invokefunction(
address=addr,
scripthash=self.designate_contract,
wallet=wallet,
wallet_password=passwd,
rpc_endpoint=endpoint,
arguments=f"designateAsRole int:8 [ {keys_str} ] -- {script_hash}",
force=True,
)
sleep(self.block_period)
return out.stdout.split(" ")[-1]
def set_notary_nodes_multisig_tx(
self,
pubkeys: list[str],
script_hash: str,
wallets: list[str],
passwords: list[str],
address: str,
endpoint: str,
invoke_tx_file: str,
) -> None:
keys = [f"bytes:{k}" for k in pubkeys]
keys_str = " ".join(keys)
multisig = Multisig(
self.neogo, invoke_tx_file=invoke_tx_file, block_period=self.block_period
)
multisig.create_and_send(
self.designate_contract,
f"designateAsRole int:32 [ {keys_str} ]",
script_hash,
wallets,
passwords,
address,
endpoint,
)
sleep(self.block_period)
def set_inner_ring_multisig_tx(
self,
pubkeys: list[str],
script_hash: str,
wallets: list[str],
passwords: list[str],
address: str,
endpoint: str,
invoke_tx_file: str,
) -> None:
keys = [f"bytes:{k}" for k in pubkeys]
keys_str = " ".join(keys)
multisig = Multisig(
self.neogo, invoke_tx_file=invoke_tx_file, block_period=self.block_period
)
multisig.create_and_send(
self.designate_contract,
f"designateAsRole int:16 [ {keys_str} ]",
script_hash,
wallets,
passwords,
address,
endpoint,
)
sleep(self.block_period)
def check_candidates(self, contract_hash: str, endpoint: str) -> Optional[list[str]]:
out = self.neogo.contract.testinvokefunction(
scripthash=contract_hash,
method="innerRingCandidates",
rpc_endpoint=endpoint,
)
output_dict = json.loads(out.stdout.replace("\n", ""))
candidates = output_dict["stack"][0]["value"]
if len(candidates) == 0:
return None
# TODO: return a list of keys
return [process_b64_bytearray(candidate["value"][0]["value"]) for candidate in candidates]

View file

@ -0,0 +1,80 @@
import json
import logging
from typing import Any, Dict, Optional
import requests
logger = logging.getLogger("frostfs.testlib.blockchain")
class NeoRPCException(Exception):
pass
class RPCClient:
def __init__(self, endpoint, timeout: int = 10):
self.endpoint = endpoint
self.timeout = timeout
def get_raw_transaction(self, tx_hash):
return self._call_endpoint("getrawtransaction", params=[tx_hash])
def send_raw_transaction(self, raw_tx: str):
return self._call_endpoint("sendrawtransaction", params=[raw_tx])
def get_storage(self, sc_hash: str, storage_key: str):
return self._call_endpoint("getstorage", params=[sc_hash, storage_key])
def invoke_function(
self,
sc_hash: str,
function: str,
params: Optional[list] = None,
signers: Optional[list] = None,
) -> Dict[str, Any]:
return self._call_endpoint(
"invokefunction", params=[sc_hash, function, params or [], signers or []]
)
def get_transaction_height(self, txid: str):
return self._call_endpoint("gettransactionheight", params=[txid])
def get_nep17_transfers(self, address, timestamps=None):
params = [address]
if timestamps:
params.append(timestamps)
return self._call_endpoint("getnep17transfers", params)
def get_nep17_balances(self, address):
return self._call_endpoint("getnep17balances", [address, 0])
def get_application_log(self, tx_hash):
return self._call_endpoint("getapplicationlog", params=[tx_hash])
def get_contract_state(self, contract_id):
"""
`contract_id` might be contract name, script hash or number
"""
return self._call_endpoint("getcontractstate", params=[contract_id])
def _call_endpoint(self, method, params=None) -> Dict[str, Any]:
payload = _build_payload(method, params)
logger.info(payload)
try:
response = requests.post(self.endpoint, data=payload, timeout=self.timeout)
response.raise_for_status()
if response.status_code == 200:
if "result" in response.json():
return response.json()["result"]
return response.json()
except Exception as exc:
raise NeoRPCException(
f"Could not call method {method} "
f"with endpoint: {self.endpoint}: {exc}"
f"\nRequest sent: {payload}"
) from exc
def _build_payload(method, params: Optional[list] = None):
payload = json.dumps({"jsonrpc": "2.0", "method": method, "params": params or [], "id": 1})
return payload.replace("'", '"')

View file

@ -0,0 +1,4 @@
from frostfs_testlib.cli.frostfs_adm import FrostfsAdm
from frostfs_testlib.cli.frostfs_authmate import FrostfsAuthmate
from frostfs_testlib.cli.frostfs_cli import FrostfsCli
from frostfs_testlib.cli.neogo import NeoGo, NetworkType

View file

@ -0,0 +1,74 @@
from typing import Optional
from frostfs_testlib.shell import CommandOptions, CommandResult, InteractiveInput, Shell
class CliCommand:
WALLET_SOURCE_ERROR_MSG = "Provide either wallet or wallet_config to specify wallet location"
WALLET_PASSWD_ERROR_MSG = "Provide either wallet_password or wallet_config to specify password"
cli_exec_path: Optional[str] = None
__base_params: Optional[str] = None
map_params = {
"json_mode": "json",
"await_mode": "await",
"hash_type": "hash",
"doc_type": "type",
"to_address": "to",
"from_address": "from",
"to_file": "to",
"from_file": "from",
}
def __init__(self, shell: Shell, cli_exec_path: str, **base_params):
self.shell = shell
self.cli_exec_path = cli_exec_path
self.__base_params = " ".join(
[f"--{param} {value}" for param, value in base_params.items() if value]
)
def _format_command(self, command: str, **params) -> str:
param_str = []
for param, value in params.items():
if param == "post_data":
param_str.append(value)
continue
if param in self.map_params.keys():
param = self.map_params[param]
param = param.replace("_", "-")
if not value:
continue
if isinstance(value, bool):
param_str.append(f"--{param}")
elif isinstance(value, int):
param_str.append(f"--{param} {value}")
elif isinstance(value, list):
for value_item in value:
val_str = str(value_item).replace("'", "\\'")
param_str.append(f"--{param} '{val_str}'")
elif isinstance(value, dict):
param_str.append(
f'--{param} \'{",".join(f"{key}={val}" for key, val in value.items())}\''
)
else:
if "'" in str(value):
value_str = str(value).replace('"', '\\"')
param_str.append(f'--{param} "{value_str}"')
else:
param_str.append(f"--{param} '{value}'")
param_str = " ".join(param_str)
return f"{self.cli_exec_path} {self.__base_params} {command or ''} {param_str}"
def _execute(self, command: Optional[str], **params) -> CommandResult:
return self.shell.exec(self._format_command(command, **params))
def _execute_with_password(self, command: Optional[str], password, **params) -> CommandResult:
return self.shell.exec(
self._format_command(command, **params),
options=CommandOptions(
interactive_inputs=[InteractiveInput(prompt_pattern="assword", input=password)]
),
)

View file

@ -0,0 +1 @@
from frostfs_testlib.cli.frostfs_adm.adm import FrostfsAdm

View file

@ -0,0 +1,22 @@
from typing import Optional
from frostfs_testlib.cli.frostfs_adm.config import FrostfsAdmConfig
from frostfs_testlib.cli.frostfs_adm.morph import FrostfsAdmMorph
from frostfs_testlib.cli.frostfs_adm.storage_config import FrostfsAdmStorageConfig
from frostfs_testlib.cli.frostfs_adm.subnet import FrostfsAdmMorphSubnet
from frostfs_testlib.cli.frostfs_adm.version import FrostfsAdmVersion
from frostfs_testlib.shell import Shell
class FrostfsAdm:
morph: Optional[FrostfsAdmMorph] = None
subnet: Optional[FrostfsAdmMorphSubnet] = None
storage_config: Optional[FrostfsAdmStorageConfig] = None
version: Optional[FrostfsAdmVersion] = None
def __init__(self, shell: Shell, frostfs_adm_exec_path: str, config_file: Optional[str] = None):
self.config = FrostfsAdmConfig(shell, frostfs_adm_exec_path, config=config_file)
self.morph = FrostfsAdmMorph(shell, frostfs_adm_exec_path, config=config_file)
self.subnet = FrostfsAdmMorphSubnet(shell, frostfs_adm_exec_path, config=config_file)
self.storage_config = FrostfsAdmStorageConfig(shell, frostfs_adm_exec_path, config=config_file)
self.version = FrostfsAdmVersion(shell, frostfs_adm_exec_path, config=config_file)

View file

@ -0,0 +1,22 @@
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.shell import CommandResult
class FrostfsAdmConfig(CliCommand):
def init(self, path: str = "~/.frostfs/adm/config.yml") -> CommandResult:
"""Initialize basic frostfs-adm configuration file.
Args:
path: Path to config (default ~/.frostfs/adm/config.yml).
Returns:
Command's result.
"""
return self._execute(
"config init",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)

View file

@ -0,0 +1,356 @@
from typing import Optional
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.shell import CommandResult
class FrostfsAdmMorph(CliCommand):
def deposit_notary(
self,
rpc_endpoint: str,
account: str,
gas: str,
storage_wallet: Optional[str] = None,
till: Optional[str] = None,
) -> CommandResult:
"""Deposit GAS for notary service.
Args:
account: Wallet account address.
gas: Amount of GAS to deposit.
rpc_endpoint: N3 RPC node endpoint.
storage_wallet: Path to storage node wallet.
till: Notary deposit duration in blocks.
Returns:
Command's result.
"""
return self._execute(
"morph deposit-notary",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def dump_balances(
self,
rpc_endpoint: str,
alphabet: Optional[str] = None,
proxy: Optional[str] = None,
script_hash: Optional[str] = None,
storage: Optional[str] = None,
) -> CommandResult:
"""Dump GAS balances.
Args:
alphabet: Dump balances of alphabet contracts.
proxy: Dump balances of the proxy contract.
rpc_endpoint: N3 RPC node endpoint.
script_hash: Use script-hash format for addresses.
storage: Dump balances of storage nodes from the current netmap.
Returns:
Command's result.
"""
return self._execute(
"morph dump-balances",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def dump_config(self, rpc_endpoint: str) -> CommandResult:
"""Section for morph network configuration commands.
Args:
rpc_endpoint: N3 RPC node endpoint
Returns:
Command's result.
"""
return self._execute(
"morph dump-config",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def dump_containers(
self,
rpc_endpoint: str,
cid: Optional[str] = None,
container_contract: Optional[str] = None,
dump: str = "./testlib_dump_container",
) -> CommandResult:
"""Dump FrostFS containers to file.
Args:
cid: Containers to dump.
container_contract: Container contract hash (for networks without NNS).
dump: File where to save dumped containers (default: ./testlib_dump_container).
rpc_endpoint: N3 RPC node endpoint.
Returns:
Command's result.
"""
return self._execute(
"morph dump-containers",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def dump_hashes(self, rpc_endpoint: str) -> CommandResult:
"""Dump deployed contract hashes.
Args:
rpc_endpoint: N3 RPC node endpoint.
Returns:
Command's result.
"""
return self._execute(
"morph dump-hashes",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def force_new_epoch(
self, rpc_endpoint: Optional[str] = None, alphabet: Optional[str] = None
) -> CommandResult:
"""Create new FrostFS epoch event in the side chain.
Args:
alphabet: Path to alphabet wallets dir.
rpc_endpoint: N3 RPC node endpoint.
Returns:
Command's result.
"""
return self._execute(
"morph force-new-epoch",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def generate_alphabet(
self,
rpc_endpoint: str,
alphabet_wallets: str,
size: int = 7,
) -> CommandResult:
"""Generate alphabet wallets for consensus nodes of the morph network.
Args:
alphabet_wallets: Path to alphabet wallets dir.
size: Amount of alphabet wallets to generate (default 7).
rpc_endpoint: N3 RPC node endpoint.
Returns:
Command's result.
"""
return self._execute(
"morph generate-alphabet",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def generate_storage_wallet(
self,
rpc_endpoint: str,
alphabet_wallets: str,
storage_wallet: str,
initial_gas: Optional[str] = None,
) -> CommandResult:
"""Generate storage node wallet for the morph network.
Args:
alphabet_wallets: Path to alphabet wallets dir.
initial_gas: Initial amount of GAS to transfer.
rpc_endpoint: N3 RPC node endpoint.
storage_wallet: Path to new storage node wallet.
Returns:
Command's result.
"""
return self._execute(
"morph generate-storage-wallet",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def init(
self,
rpc_endpoint: str,
alphabet_wallets: str,
contracts: str,
protocol: str,
container_alias_fee: int = 500,
container_fee: int = 1000,
epoch_duration: int = 240,
homomorphic_disabled: bool = False,
local_dump: Optional[str] = None,
max_object_size: int = 67108864,
) -> CommandResult:
"""Section for morph network configuration commands.
Args:
alphabet_wallets: Path to alphabet wallets dir.
container_alias_fee: Container alias fee (default 500).
container_fee: Container registration fee (default 1000).
contracts: Path to archive with compiled FrostFS contracts
(default fetched from latest github release).
epoch_duration: Amount of side chain blocks in one FrostFS epoch (default 240).
homomorphic_disabled: Disable object homomorphic hashing.
local_dump: Path to the blocks dump file.
max_object_size: Max single object size in bytes (default 67108864).
protocol: Path to the consensus node configuration.
rpc_endpoint: N3 RPC node endpoint.
Returns:
Command's result.
"""
return self._execute(
"morph init",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def refill_gas(
self,
rpc_endpoint: str,
alphabet_wallets: str,
storage_wallet: str,
gas: Optional[str] = None,
) -> CommandResult:
"""Refill GAS of storage node's wallet in the morph network
Args:
alphabet_wallets: Path to alphabet wallets dir.
gas: Additional amount of GAS to transfer.
rpc_endpoint: N3 RPC node endpoint.
storage_wallet: Path to new storage node wallet.
Returns:
Command's result.
"""
return self._execute(
"morph refill-gas",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def restore_containers(
self,
rpc_endpoint: str,
alphabet_wallets: str,
cid: str,
dump: str,
) -> CommandResult:
"""Restore FrostFS containers from file.
Args:
alphabet_wallets: Path to alphabet wallets dir.
cid: Containers to restore.
dump: File to restore containers from.
rpc_endpoint: N3 RPC node endpoint.
Returns:
Command's result.
"""
return self._execute(
"morph restore-containers",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def set_policy(
self,
rpc_endpoint: str,
alphabet_wallets: str,
exec_fee_factor: Optional[int] = None,
storage_price: Optional[int] = None,
fee_per_byte: Optional[int] = None,
) -> CommandResult:
"""Set global policy values.
Args:
alphabet_wallets: Path to alphabet wallets dir.
exec_fee_factor: ExecFeeFactor=<n1>.
storage_price: StoragePrice=<n2>.
fee_per_byte: FeePerByte=<n3>.
rpc_endpoint: N3 RPC node endpoint.
Returns:
Command's result.
"""
non_param_attribute = ""
if exec_fee_factor:
non_param_attribute += f"ExecFeeFactor={exec_fee_factor} "
if storage_price:
non_param_attribute += f"StoragePrice={storage_price} "
if fee_per_byte:
non_param_attribute += f"FeePerByte={fee_per_byte} "
return self._execute(
f"morph restore-containers {non_param_attribute}",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self", "exec_fee_factor", "storage_price", "fee_per_byte"]
},
)
def update_contracts(
self,
rpc_endpoint: str,
alphabet_wallets: str,
contracts: Optional[str] = None,
) -> CommandResult:
"""Update FrostFS contracts.
Args:
alphabet_wallets: Path to alphabet wallets dir.
contracts: Path to archive with compiled FrostFS contracts
(default fetched from latest github release).
rpc_endpoint: N3 RPC node endpoint.
Returns:
Command's result.
"""
return self._execute(
"morph update-contracts",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)

View file

@ -0,0 +1,23 @@
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.shell import CommandResult
class FrostfsAdmStorageConfig(CliCommand):
def set(self, account: str, wallet: str) -> CommandResult:
"""Initialize basic frostfs-adm configuration file.
Args:
account: Wallet account.
wallet: Path to wallet.
Returns:
Command's result.
"""
return self._execute(
"storage-config",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)

View file

@ -0,0 +1,239 @@
from typing import Optional
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.shell import CommandResult
class FrostfsAdmMorphSubnet(CliCommand):
def create(
self, rpc_endpoint: str, address: str, wallet: str, notary: bool = False
) -> CommandResult:
"""Create FrostFS subnet.
Args:
address: Address in the wallet, optional.
notary: Flag to create subnet in notary environment.
rpc_endpoint: N3 RPC node endpoint.
wallet: Path to file with wallet.
Returns:
Command's result.
"""
return self._execute(
"morph subnet create",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def get(self, rpc_endpoint: str, subnet: str) -> CommandResult:
"""Read information about the FrostFS subnet.
Args:
rpc_endpoint: N3 RPC node endpoint.
subnet: ID of the subnet to read.
Returns:
Command's result.
"""
return self._execute(
"morph subnet get",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def remove(
self, rpc_endpoint: str, wallet: str, subnet: str, address: Optional[str] = None
) -> CommandResult:
"""Remove FrostFS subnet.
Args:
address: Address in the wallet, optional.
rpc_endpoint: N3 RPC node endpoint.
subnet: ID of the subnet to read.
wallet: Path to file with wallet.
Returns:
Command's result.
"""
return self._execute(
"morph subnet remove",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def admin_add(
self,
rpc_endpoint: str,
wallet: str,
admin: str,
subnet: str,
client: Optional[str] = None,
group: Optional[str] = None,
address: Optional[str] = None,
) -> CommandResult:
"""Add admin to the FrostFS subnet.
Args:
address: Address in the wallet, optional.
admin: Hex-encoded public key of the admin.
client: Add client admin instead of node one.
group: Client group ID in text format (needed with --client only).
rpc_endpoint: N3 RPC node endpoint.
subnet: ID of the subnet to read.
wallet: Path to file with wallet.
Returns:
Command's result.
"""
return self._execute(
"morph subnet admin add",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def admin_remove(
self,
rpc_endpoint: str,
wallet: str,
admin: str,
subnet: str,
client: Optional[str] = None,
address: Optional[str] = None,
) -> CommandResult:
"""Remove admin of the FrostFS subnet.
Args:
address: Address in the wallet, optional.
admin: Hex-encoded public key of the admin.
client: Remove client admin instead of node one.
rpc_endpoint: N3 RPC node endpoint.
subnet: ID of the subnet to read.
wallet: Path to file with wallet.
Returns:
Command's result.
"""
return self._execute(
"morph subnet admin remove",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def client_add(
self,
rpc_endpoint: str,
wallet: str,
subnet: str,
client: Optional[str] = None,
group: Optional[str] = None,
address: Optional[str] = None,
) -> CommandResult:
"""Add client to the FrostFS subnet.
Args:
address: Address in the wallet, optional.
client: Add client admin instead of node one.
group: Client group ID in text format (needed with --client only).
rpc_endpoint: N3 RPC node endpoint.
subnet: ID of the subnet to read.
wallet: Path to file with wallet.
Returns:
Command's result.
"""
return self._execute(
"morph subnet client add",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def client_remove(
self,
rpc_endpoint: str,
wallet: str,
client: str,
group: str,
subnet: str,
address: Optional[str] = None,
) -> CommandResult:
"""Remove client of the FrostFS subnet.
Args:
address: Address in the wallet, optional.
client: Remove client admin instead of node one.
group: ID of the client group to work with.
rpc_endpoint: N3 RPC node endpoint.
subnet: ID of the subnet to read.
wallet: Path to file with wallet.
Returns:
Command's result.
"""
return self._execute(
"morph subnet client remove",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def node_add(self, rpc_endpoint: str, wallet: str, node: str, subnet: str) -> CommandResult:
"""Add node to the FrostFS subnet.
Args:
node: Hex-encoded public key of the node.
rpc_endpoint: N3 RPC node endpoint.
subnet: ID of the subnet to read.
wallet: Path to file with wallet.
Returns:
Command's result.
"""
return self._execute(
"morph subnet node add",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def node_remove(self, rpc_endpoint: str, wallet: str, node: str, subnet: str) -> CommandResult:
"""Remove node from the FrostFS subnet.
Args:
node: Hex-encoded public key of the node.
rpc_endpoint: N3 RPC node endpoint.
subnet: ID of the subnet to read.
wallet: Path to file with wallet.
Returns:
Command's result.
"""
return self._execute(
"morph subnet node remove",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)

View file

@ -0,0 +1,12 @@
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.shell import CommandResult
class FrostfsAdmVersion(CliCommand):
def get(self) -> CommandResult:
"""Application version
Returns:
Command's result.
"""
return self._execute("", version=True)

View file

@ -0,0 +1 @@
from frostfs_testlib.cli.frostfs_authmate.authmate import FrostfsAuthmate

View file

@ -0,0 +1,14 @@
from typing import Optional
from frostfs_testlib.cli.frostfs_authmate.secret import FrostfsAuthmateSecret
from frostfs_testlib.cli.frostfs_authmate.version import FrostfsAuthmateVersion
from frostfs_testlib.shell import Shell
class FrostfsAuthmate:
secret: Optional[FrostfsAuthmateSecret] = None
version: Optional[FrostfsAuthmateVersion] = None
def __init__(self, shell: Shell, frostfs_authmate_exec_path: str):
self.secret = FrostfsAuthmateSecret(shell, frostfs_authmate_exec_path)
self.version = FrostfsAuthmateVersion(shell, frostfs_authmate_exec_path)

View file

@ -0,0 +1,91 @@
from typing import Optional, Union
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.shell import CommandResult
class FrostfsAuthmateSecret(CliCommand):
def obtain(
self,
wallet: str,
wallet_password: str,
peer: str,
gate_wallet: str,
access_key_id: str,
address: Optional[str] = None,
gate_address: Optional[str] = None,
) -> CommandResult:
"""Obtain a secret from FrostFS network.
Args:
wallet: Path to the wallet.
wallet_password: Wallet password.
address: Address of wallet account.
peer: Address of frostfs peer to connect to.
gate_wallet: Path to the wallet.
gate_address: Address of wallet account.
access_key_id: Access key id for s3.
Returns:
Command's result.
"""
return self._execute_with_password(
"obtain-secret",
wallet_password,
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def issue(
self,
wallet: str,
wallet_password: str,
peer: str,
bearer_rules: str,
gate_public_key: Union[str, list[str]],
address: Optional[str] = None,
container_id: Optional[str] = None,
container_friendly_name: Optional[str] = None,
container_placement_policy: Optional[str] = None,
session_tokens: Optional[str] = None,
lifetime: Optional[str] = None,
container_policy: Optional[str] = None,
aws_cli_credentials: Optional[str] = None,
) -> CommandResult:
"""Obtain a secret from FrostFS network
Args:
wallet: Path to the wallet.
wallet_password: Wallet password.
address: Address of wallet account.
peer: Address of a frostfs peer to connect to.
bearer_rules: Rules for bearer token as plain json string.
gate_public_key: Public 256r1 key of a gate (send list[str] of keys to use multiple gates).
container_id: Auth container id to put the secret into.
container_friendly_name: Friendly name of auth container to put the secret into.
container_placement_policy: Placement policy of auth container to put the secret into
(default: "REP 2 IN X CBF 3 SELECT 2 FROM * AS X").
session_tokens: Create session tokens with rules, if the rules are set as 'none', no
session tokens will be created.
lifetime: Lifetime of tokens. For example 50h30m (note: max time unit is an hour so to
set a day you should use 24h). It will be ceil rounded to the nearest amount of
epoch. (default: 720h0m0s).
container_policy: Mapping AWS storage class to FrostFS storage policy as plain json string
or path to json file.
aws_cli_credentials: Path to the aws cli credential file.
Returns:
Command's result.
"""
return self._execute_with_password(
"issue-secret",
wallet_password,
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)

View file

@ -0,0 +1,12 @@
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.shell import CommandResult
class FrostfsAuthmateVersion(CliCommand):
def get(self) -> CommandResult:
"""Application version
Returns:
Command's result.
"""
return self._execute("", version=True)

View file

@ -0,0 +1 @@
from frostfs_testlib.cli.frostfs_cli.cli import FrostfsCli

View file

@ -0,0 +1,30 @@
from typing import Optional
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.shell import CommandResult
class FrostfsCliAccounting(CliCommand):
def balance(
self,
wallet: Optional[str] = None,
rpc_endpoint: Optional[str] = None,
address: Optional[str] = None,
owner: Optional[str] = None,
) -> CommandResult:
"""Get internal balance of FrostFS account
Args:
address: Address of wallet account.
owner: Owner of balance account (omit to use owner from private key).
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
wallet: WIF (NEP-2) string or path to the wallet or binary key.
Returns:
Command's result.
"""
return self._execute(
"accounting balance",
**{param: value for param, value in locals().items() if param not in ["self"]},
)

View file

@ -0,0 +1,52 @@
from typing import Optional
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.shell import CommandResult
class FrostfsCliACL(CliCommand):
def extended_create(
self, cid: str, out: str, file: Optional[str] = None, rule: Optional[list] = None
) -> CommandResult:
"""Create extended ACL from the text representation.
Rule consist of these blocks: <action> <operation> [<filter1> ...] [<target1> ...]
Action is 'allow' or 'deny'.
Operation is an object service verb: 'get', 'head', 'put', 'search', 'delete', 'getrange',
or 'getrangehash'.
Filter consists of <typ>:<key><match><value>
Typ is 'obj' for object applied filter or 'req' for request applied filter.
Key is a valid unicode string corresponding to object or request header key.
Well-known system object headers start with '$Object:' prefix.
User defined headers start without prefix.
Read more about filter keys at:
http://github.com/TrueCloudLab/frostfs-api/blob/master/proto-docs/acl.md#message-eaclrecordfilter
Match is '=' for matching and '!=' for non-matching filter.
Value is a valid unicode string corresponding to object or request header value.
Target is
'user' for container owner,
'system' for Storage nodes in container and Inner Ring nodes,
'others' for all other request senders,
'pubkey:<key1>,<key2>,...' for exact request sender, where <key> is a hex-encoded 33-byte
public key.
When both '--rule' and '--file' arguments are used, '--rule' records will be placed higher
in resulting extended ACL table.
Args:
cid: Container ID.
file: Read list of extended ACL table records from from text file.
out: Save JSON formatted extended ACL table in file.
rule: Extended ACL table record to apply.
Returns:
Command's result.
"""
return self._execute(
"acl extended create",
**{param: value for param, value in locals().items() if param not in ["self"]},
)

View file

@ -0,0 +1,38 @@
from typing import Optional
from frostfs_testlib.cli.frostfs_cli.accounting import FrostfsCliAccounting
from frostfs_testlib.cli.frostfs_cli.acl import FrostfsCliACL
from frostfs_testlib.cli.frostfs_cli.container import FrostfsCliContainer
from frostfs_testlib.cli.frostfs_cli.netmap import FrostfsCliNetmap
from frostfs_testlib.cli.frostfs_cli.object import FrostfsCliObject
from frostfs_testlib.cli.frostfs_cli.session import FrostfsCliSession
from frostfs_testlib.cli.frostfs_cli.shards import FrostfsCliShards
from frostfs_testlib.cli.frostfs_cli.storagegroup import FrostfsCliStorageGroup
from frostfs_testlib.cli.frostfs_cli.util import FrostfsCliUtil
from frostfs_testlib.cli.frostfs_cli.version import FrostfsCliVersion
from frostfs_testlib.shell import Shell
class FrostfsCli:
accounting: Optional[FrostfsCliAccounting] = None
acl: Optional[FrostfsCliACL] = None
container: Optional[FrostfsCliContainer] = None
netmap: Optional[FrostfsCliNetmap] = None
object: Optional[FrostfsCliObject] = None
session: Optional[FrostfsCliSession] = None
shards: Optional[FrostfsCliShards] = None
storagegroup: Optional[FrostfsCliStorageGroup] = None
util: Optional[FrostfsCliUtil] = None
version: Optional[FrostfsCliVersion] = None
def __init__(self, shell: Shell, frostfs_cli_exec_path: str, config_file: Optional[str] = None):
self.accounting = FrostfsCliAccounting(shell, frostfs_cli_exec_path, config=config_file)
self.acl = FrostfsCliACL(shell, frostfs_cli_exec_path, config=config_file)
self.container = FrostfsCliContainer(shell, frostfs_cli_exec_path, config=config_file)
self.netmap = FrostfsCliNetmap(shell, frostfs_cli_exec_path, config=config_file)
self.object = FrostfsCliObject(shell, frostfs_cli_exec_path, config=config_file)
self.session = FrostfsCliSession(shell, frostfs_cli_exec_path, config=config_file)
self.shards = FrostfsCliShards(shell, frostfs_cli_exec_path, config=config_file)
self.storagegroup = FrostfsCliStorageGroup(shell, frostfs_cli_exec_path, config=config_file)
self.util = FrostfsCliUtil(shell, frostfs_cli_exec_path, config=config_file)
self.version = FrostfsCliVersion(shell, frostfs_cli_exec_path, config=config_file)

View file

@ -0,0 +1,264 @@
from typing import Optional
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.shell import CommandResult
class FrostfsCliContainer(CliCommand):
def create(
self,
rpc_endpoint: str,
wallet: str,
address: Optional[str] = None,
attributes: Optional[dict] = None,
basic_acl: Optional[str] = None,
await_mode: bool = False,
disable_timestamp: bool = False,
name: Optional[str] = None,
nonce: Optional[str] = None,
policy: Optional[str] = None,
session: Optional[str] = None,
subnet: Optional[str] = None,
ttl: Optional[int] = None,
xhdr: Optional[dict] = None,
timeout: Optional[str] = None,
) -> CommandResult:
"""
Create a new container and register it in the FrostFS.
It will be stored in the sidechain when the Inner Ring accepts it.
Args:
address: Address of wallet account.
attributes: Comma separated pairs of container attributes in form of
Key1=Value1,Key2=Value2.
await_mode: Block execution until container is persisted.
basic_acl: Hex encoded basic ACL value or keywords like 'public-read-write',
'private', 'eacl-public-read' (default "private").
disable_timestamp: Disable timestamp container attribute.
name: Container name attribute.
nonce: UUIDv4 nonce value for container.
policy: QL-encoded or JSON-encoded placement policy or path to file with it.
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
session: Path to a JSON-encoded container session token.
subnet: String representation of container subnetwork.
ttl: TTL value in request meta header (default 2).
wallet: WIF (NEP-2) string or path to the wallet or binary key.
xhdr: Dict with request X-Headers.
timeout: Timeout for the operation (default 15s).
Returns:
Command's result.
"""
return self._execute(
"container create",
**{param: value for param, value in locals().items() if param not in ["self"]},
)
def delete(
self,
rpc_endpoint: str,
wallet: str,
cid: str,
address: Optional[str] = None,
await_mode: bool = False,
session: Optional[str] = None,
ttl: Optional[int] = None,
xhdr: Optional[dict] = None,
force: bool = False,
timeout: Optional[str] = None,
) -> CommandResult:
"""
Delete an existing container.
Only the owner of the container has permission to remove the container.
Args:
address: Address of wallet account.
await_mode: Block execution until container is removed.
cid: Container ID.
force: Do not check whether container contains locks and remove immediately.
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
session: Path to a JSON-encoded container session token.
ttl: TTL value in request meta header (default 2).
wallet: WIF (NEP-2) string or path to the wallet or binary key.
xhdr: Dict with request X-Headers.
timeout: Timeout for the operation (default 15s).
Returns:
Command's result.
"""
return self._execute(
"container delete",
**{param: value for param, value in locals().items() if param not in ["self"]},
)
def get(
self,
rpc_endpoint: str,
wallet: str,
cid: str,
address: Optional[str] = None,
await_mode: bool = False,
to: Optional[str] = None,
json_mode: bool = False,
ttl: Optional[int] = None,
xhdr: Optional[dict] = None,
timeout: Optional[str] = None,
) -> CommandResult:
"""
Get container field info.
Args:
address: Address of wallet account.
await_mode: Block execution until container is removed.
cid: Container ID.
json_mode: Print or dump container in JSON format.
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
to: Path to dump encoded container.
ttl: TTL value in request meta header (default 2).
wallet: WIF (NEP-2) string or path to the wallet or binary key.
xhdr: Dict with request X-Headers.
timeout: Timeout for the operation (default 15s).
Returns:
Command's result.
"""
return self._execute(
"container get",
**{param: value for param, value in locals().items() if param not in ["self"]},
)
def get_eacl(
self,
rpc_endpoint: str,
wallet: str,
cid: str,
address: Optional[str] = None,
await_mode: bool = False,
to: Optional[str] = None,
session: Optional[str] = None,
ttl: Optional[int] = None,
xhdr: Optional[dict] = None,
timeout: Optional[str] = None,
) -> CommandResult:
"""
Get extended ACL table of container.
Args:
address: Address of wallet account.
await_mode: Block execution until container is removed.
cid: Container ID.
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
to: Path to dump encoded container.
session: Path to a JSON-encoded container session token.
ttl: TTL value in request meta header (default 2).
wallet: WIF (NEP-2) string or path to the wallet or binary key.
xhdr: Dict with request X-Headers.
timeout: Timeout for the operation (default 15s).
Returns:
Command's result.
"""
return self._execute(
"container get-eacl",
**{param: value for param, value in locals().items() if param not in ["self"]},
)
def list(
self,
rpc_endpoint: str,
wallet: str,
address: Optional[str] = None,
owner: Optional[str] = None,
ttl: Optional[int] = None,
xhdr: Optional[dict] = None,
timeout: Optional[str] = None,
**params,
) -> CommandResult:
"""
List all created containers.
Args:
address: Address of wallet account.
owner: Owner of containers (omit to use owner from private key).
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
ttl: TTL value in request meta header (default 2).
wallet: WIF (NEP-2) string or path to the wallet or binary key.
xhdr: Dict with request X-Headers.
timeout: Timeout for the operation (default 15s).
Returns:
Command's result.
"""
return self._execute(
"container list",
**{param: value for param, value in locals().items() if param not in ["self"]},
)
def list_objects(
self,
rpc_endpoint: str,
wallet: str,
cid: str,
address: Optional[str] = None,
ttl: Optional[int] = None,
xhdr: Optional[dict] = None,
timeout: Optional[str] = None,
) -> CommandResult:
"""
List existing objects in container.
Args:
address: Address of wallet account.
cid: Container ID.
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
ttl: TTL value in request meta header (default 2).
wallet: WIF (NEP-2) string or path to the wallet or binary key.
xhdr: Dict with request X-Headers.
timeout: Timeout for the operation (default 15s).
Returns:
Command's result.
"""
return self._execute(
"container list-objects",
**{param: value for param, value in locals().items() if param not in ["self"]},
)
def set_eacl(
self,
rpc_endpoint: str,
wallet: str,
cid: str,
address: Optional[str] = None,
await_mode: bool = False,
table: Optional[str] = None,
session: Optional[str] = None,
ttl: Optional[int] = None,
xhdr: Optional[dict] = None,
timeout: Optional[str] = None,
) -> CommandResult:
"""
Set a new extended ACL table for the container.
Container ID in the EACL table will be substituted with the ID from the CLI.
Args:
address: Address of wallet account.
await_mode: Block execution until container is removed.
cid: Container ID.
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
session: Path to a JSON-encoded container session token.
table: Path to file with JSON or binary encoded EACL table.
ttl: TTL value in request meta header (default 2).
wallet: WIF (NEP-2) string or path to the wallet or binary key.
xhdr: Dict with request X-Headers.
timeout: Timeout for the operation (default 15s).
Returns:
Command's result.
"""
return self._execute(
"container set-eacl",
**{param: value for param, value in locals().items() if param not in ["self"]},
)

View file

@ -0,0 +1,120 @@
from typing import Optional
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.shell import CommandResult
class FrostfsCliNetmap(CliCommand):
def epoch(
self,
rpc_endpoint: str,
wallet: str,
address: Optional[str] = None,
generate_key: bool = False,
ttl: Optional[int] = None,
xhdr: Optional[dict] = None,
) -> CommandResult:
"""
Get current epoch number.
Args:
address: Address of wallet account.
generate_key: Generate new private key.
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
ttl: TTL value in request meta header (default 2).
wallet: Path to the wallet or binary key.
xhdr: Dict with request X-Headers.
Returns:
Command's result.
"""
return self._execute(
"netmap epoch",
**{param: value for param, value in locals().items() if param not in ["self"]},
)
def netinfo(
self,
rpc_endpoint: str,
wallet: str,
address: Optional[str] = None,
generate_key: bool = False,
ttl: Optional[int] = None,
xhdr: Optional[dict] = None,
) -> CommandResult:
"""
Get information about FrostFS network.
Args:
address: Address of wallet account
generate_key: Generate new private key
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>')
ttl: TTL value in request meta header (default 2)
wallet: Path to the wallet or binary key
xhdr: Request X-Headers in form of Key=Value
Returns:
Command's result.
"""
return self._execute(
"netmap netinfo",
**{param: value for param, value in locals().items() if param not in ["self"]},
)
def nodeinfo(
self,
rpc_endpoint: str,
wallet: str,
address: Optional[str] = None,
generate_key: bool = False,
json: bool = False,
ttl: Optional[int] = None,
xhdr: Optional[dict] = None,
) -> CommandResult:
"""
Get target node info.
Args:
address: Address of wallet account.
generate_key: Generate new private key.
json: Print node info in JSON format.
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
ttl: TTL value in request meta header (default 2).
wallet: Path to the wallet or binary key.
xhdr: Dict with request X-Headers.
Returns:
Command's result.
"""
return self._execute(
"netmap nodeinfo",
**{param: value for param, value in locals().items() if param not in ["self"]},
)
def snapshot(
self,
rpc_endpoint: str,
wallet: str,
address: Optional[str] = None,
generate_key: bool = False,
ttl: Optional[int] = None,
xhdr: Optional[dict] = None,
) -> CommandResult:
"""
Request current local snapshot of the network map.
Args:
address: Address of wallet account.
generate_key: Generate new private key.
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
ttl: TTL value in request meta header (default 2).
wallet: Path to the wallet or binary key.
xhdr: Dict with request X-Headers.
Returns:
Command's result.
"""
return self._execute(
"netmap snapshot",
**{param: value for param, value in locals().items() if param not in ["self"]},
)

View file

@ -0,0 +1,351 @@
from typing import Optional
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.shell import CommandResult
class FrostfsCliObject(CliCommand):
def delete(
self,
rpc_endpoint: str,
wallet: str,
cid: str,
oid: str,
address: Optional[str] = None,
bearer: Optional[str] = None,
session: Optional[str] = None,
ttl: Optional[int] = None,
xhdr: Optional[dict] = None,
timeout: Optional[str] = None,
) -> CommandResult:
"""
Delete object from FrostFS.
Args:
address: Address of wallet account.
bearer: File with signed JSON or binary encoded bearer token.
cid: Container ID.
oid: Object ID.
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
session: Filepath to a JSON- or binary-encoded token of the object DELETE session.
ttl: TTL value in request meta header (default 2).
wallet: WIF (NEP-2) string or path to the wallet or binary key.
xhdr: Dict with request X-Headers.
timeout: Timeout for the operation (default 15s).
Returns:
Command's result.
"""
return self._execute(
"object delete",
**{param: value for param, value in locals().items() if param not in ["self"]},
)
def get(
self,
rpc_endpoint: str,
wallet: str,
cid: str,
oid: str,
address: Optional[str] = None,
bearer: Optional[str] = None,
file: Optional[str] = None,
header: Optional[str] = None,
no_progress: bool = False,
raw: bool = False,
session: Optional[str] = None,
ttl: Optional[int] = None,
xhdr: Optional[dict] = None,
timeout: Optional[str] = None,
) -> CommandResult:
"""
Get object from FrostFS.
Args:
address: Address of wallet account.
bearer: File with signed JSON or binary encoded bearer token.
cid: Container ID.
file: File to write object payload to. Default: stdout.
header: File to write header to. Default: stdout.
no_progress: Do not show progress bar.
oid: Object ID.
raw: Set raw request option.
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
session: Filepath to a JSON- or binary-encoded token of the object GET session.
ttl: TTL value in request meta header (default 2).
wallet: WIF (NEP-2) string or path to the wallet or binary key.
xhdr: Dict with request X-Headers.
timeout: Timeout for the operation (default 15s).
Returns:
Command's result.
"""
return self._execute(
"object get",
**{param: value for param, value in locals().items() if param not in ["self"]},
)
def hash(
self,
rpc_endpoint: str,
wallet: str,
cid: str,
oid: str,
address: Optional[str] = None,
bearer: Optional[str] = None,
range: Optional[str] = None,
salt: Optional[str] = None,
ttl: Optional[int] = None,
session: Optional[str] = None,
hash_type: Optional[str] = None,
xhdr: Optional[dict] = None,
timeout: Optional[str] = None,
) -> CommandResult:
"""
Get object hash.
Args:
address: Address of wallet account.
bearer: File with signed JSON or binary encoded bearer token.
cid: Container ID.
oid: Object ID.
range: Range to take hash from in the form offset1:length1,...
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
salt: Salt in hex format.
ttl: TTL value in request meta header (default 2).
session: Filepath to a JSON- or binary-encoded token of the object RANGEHASH session.
hash_type: Hash type. Either 'sha256' or 'tz' (default "sha256").
wallet: WIF (NEP-2) string or path to the wallet or binary key.
xhdr: Dict with request X-Headers.
timeout: Timeout for the operation (default 15s).
Returns:
Command's result.
"""
return self._execute(
"object hash",
**{
param: value for param, value in locals().items() if param not in ["self", "params"]
},
)
def head(
self,
rpc_endpoint: str,
wallet: str,
cid: str,
oid: str,
address: Optional[str] = None,
bearer: Optional[str] = None,
file: Optional[str] = None,
json_mode: bool = False,
main_only: bool = False,
proto: bool = False,
raw: bool = False,
session: Optional[str] = None,
ttl: Optional[int] = None,
xhdr: Optional[dict] = None,
timeout: Optional[str] = None,
) -> CommandResult:
"""
Get object header.
Args:
address: Address of wallet account.
bearer: File with signed JSON or binary encoded bearer token.
cid: Container ID.
file: File to write object payload to. Default: stdout.
json_mode: Marshal output in JSON.
main_only: Return only main fields.
oid: Object ID.
proto: Marshal output in Protobuf.
raw: Set raw request option.
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
session: Filepath to a JSON- or binary-encoded token of the object HEAD session.
ttl: TTL value in request meta header (default 2).
wallet: WIF (NEP-2) string or path to the wallet or binary key.
xhdr: Dict with request X-Headers.
timeout: Timeout for the operation (default 15s).
Returns:
Command's result.
"""
return self._execute(
"object head",
**{param: value for param, value in locals().items() if param not in ["self"]},
)
def lock(
self,
rpc_endpoint: str,
wallet: str,
cid: str,
oid: str,
lifetime: Optional[int] = None,
expire_at: Optional[int] = None,
address: Optional[str] = None,
bearer: Optional[str] = None,
session: Optional[str] = None,
ttl: Optional[int] = None,
xhdr: Optional[dict] = None,
timeout: Optional[str] = None,
) -> CommandResult:
"""
Lock object in container.
Args:
address: Address of wallet account.
bearer: File with signed JSON or binary encoded bearer token.
cid: Container ID.
oid: Object ID.
lifetime: Lock lifetime.
expire_at: Lock expiration epoch.
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
session: Filepath to a JSON- or binary-encoded token of the object PUT session.
ttl: TTL value in request meta header (default 2).
wallet: WIF (NEP-2) string or path to the wallet or binary key.
xhdr: Dict with request X-Headers.
timeout: Timeout for the operation (default 15s).
Returns:
Command's result.
"""
return self._execute(
"object lock",
**{param: value for param, value in locals().items() if param not in ["self"]},
)
def put(
self,
rpc_endpoint: str,
wallet: str,
cid: str,
file: str,
address: Optional[str] = None,
attributes: Optional[dict] = None,
bearer: Optional[str] = None,
disable_filename: bool = False,
disable_timestamp: bool = False,
expire_at: Optional[int] = None,
no_progress: bool = False,
notify: Optional[str] = None,
session: Optional[str] = None,
ttl: Optional[int] = None,
xhdr: Optional[dict] = None,
timeout: Optional[str] = None,
) -> CommandResult:
"""
Put object to FrostFS.
Args:
address: Address of wallet account.
attributes: User attributes in form of Key1=Value1,Key2=Value2.
bearer: File with signed JSON or binary encoded bearer token.
cid: Container ID.
disable_filename: Do not set well-known filename attribute.
disable_timestamp: Do not set well-known timestamp attribute.
expire_at: Last epoch in the life of the object.
file: File with object payload.
no_progress: Do not show progress bar.
notify: Object notification in the form of *epoch*:*topic*; '-'
topic means using default.
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
session: Filepath to a JSON- or binary-encoded token of the object PUT session.
ttl: TTL value in request meta header (default 2).
wallet: WIF (NEP-2) string or path to the wallet or binary key.
xhdr: Dict with request X-Headers.
timeout: Timeout for the operation (default 15s).
Returns:
Command's result.
"""
return self._execute(
"object put",
**{param: value for param, value in locals().items() if param not in ["self"]},
)
def range(
self,
rpc_endpoint: str,
wallet: str,
cid: str,
oid: str,
range: str,
address: Optional[str] = None,
bearer: Optional[str] = None,
file: Optional[str] = None,
json_mode: bool = False,
raw: bool = False,
session: Optional[str] = None,
ttl: Optional[int] = None,
xhdr: Optional[dict] = None,
timeout: Optional[str] = None,
) -> CommandResult:
"""
Get payload range data of an object.
Args:
address: Address of wallet account.
bearer: File with signed JSON or binary encoded bearer token.
cid: Container ID.
file: File to write object payload to. Default: stdout.
json_mode: Marshal output in JSON.
oid: Object ID.
range: Range to take data from in the form offset:length.
raw: Set raw request option.
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
session: Filepath to a JSON- or binary-encoded token of the object RANGE session.
ttl: TTL value in request meta header (default 2).
wallet: WIF (NEP-2) string or path to the wallet or binary key.
xhdr: Dict with request X-Headers.
timeout: Timeout for the operation (default 15s).
Returns:
Command's result.
"""
return self._execute(
"object range",
**{param: value for param, value in locals().items() if param not in ["self"]},
)
def search(
self,
rpc_endpoint: str,
wallet: str,
cid: str,
address: Optional[str] = None,
bearer: Optional[str] = None,
filters: Optional[list] = None,
oid: Optional[str] = None,
phy: bool = False,
root: bool = False,
session: Optional[str] = None,
ttl: Optional[int] = None,
xhdr: Optional[dict] = None,
timeout: Optional[str] = None,
) -> CommandResult:
"""
Search object.
Args:
address: Address of wallet account.
bearer: File with signed JSON or binary encoded bearer token.
cid: Container ID.
filters: Repeated filter expressions or files with protobuf JSON.
oid: Object ID.
phy: Search physically stored objects.
root: Search for user objects.
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
session: Filepath to a JSON- or binary-encoded token of the object SEARCH session.
ttl: TTL value in request meta header (default 2).
wallet: WIF (NEP-2) string or path to the wallet or binary key.
xhdr: Dict with request X-Headers.
timeout: Timeout for the operation (default 15s).
Returns:
Command's result.
"""
return self._execute(
"object search",
**{param: value for param, value in locals().items() if param not in ["self"]},
)

View file

@ -0,0 +1,41 @@
from typing import Optional
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.shell import CommandResult
class FrostfsCliSession(CliCommand):
def create(
self,
rpc_endpoint: str,
wallet: str,
wallet_password: str,
out: str,
lifetime: Optional[int] = None,
address: Optional[str] = None,
json: Optional[bool] = False,
) -> CommandResult:
"""
Create session token.
Args:
address: Address of wallet account.
out: File to write session token to.
lifetime: Number of epochs for token to stay valid.
json: Output token in JSON.
wallet: WIF (NEP-2) string or path to the wallet or binary key.
wallet_password: Wallet password.
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
Returns:
Command's result.
"""
return self._execute_with_password(
"session create",
wallet_password,
**{
param: value
for param, value in locals().items()
if param not in ["self", "wallet_password"]
},
)

View file

@ -0,0 +1,138 @@
from typing import Optional
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.shell import CommandResult
class FrostfsCliShards(CliCommand):
def flush_cache(
self,
endpoint: str,
wallet: str,
wallet_password: str,
id: Optional[list[str]],
address: Optional[str] = None,
all: bool = False,
) -> CommandResult:
"""
Flush objects from the write-cache to the main storage.
Args:
address: Address of wallet account.
id: List of shard IDs in base58 encoding.
all: Process all shards.
endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
wallet: WIF (NEP-2) string or path to the wallet or binary key.
wallet_password: Wallet password.
Returns:
Command's result.
"""
return self._execute_with_password(
f"control shards flush-cache",
wallet_password,
**{param: value for param, value in locals().items() if param not in ["self"]},
)
def set_mode(
self,
endpoint: str,
wallet: str,
wallet_password: str,
mode: str,
id: Optional[list[str]],
address: Optional[str] = None,
all: bool = False,
clear_errors: bool = False,
) -> CommandResult:
"""
Set work mode of the shard.
Args:
address: Address of wallet account.
id: List of shard IDs in base58 encoding.
mode: New shard mode ('degraded-read-only', 'read-only', 'read-write').
all: Process all shards.
clear_errors: Set shard error count to 0.
endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
wallet: WIF (NEP-2) string or path to the wallet or binary key.
wallet_password: Wallet password.
Returns:
Command's result.
"""
return self._execute_with_password(
f"control shards set-mode",
wallet_password,
**{
param: value
for param, value in locals().items()
if param not in ["self", "wallet_password"]
},
)
def dump(
self,
endpoint: str,
wallet: str,
wallet_password: str,
id: str,
path: str,
address: Optional[str] = None,
no_errors: bool = False,
) -> CommandResult:
"""
Dump objects from shard to a file.
Args:
address: Address of wallet account.
no_errors: Skip invalid/unreadable objects.
id: Shard ID in base58 encoding.
path: File to write objects to.
endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
wallet: WIF (NEP-2) string or path to the wallet or binary key.
wallet_password: Wallet password.
Returns:
Command's result.
"""
return self._execute_with_password(
f"control shards dump",
wallet_password,
**{
param: value
for param, value in locals().items()
if param not in ["self", "wallet_password"]
},
)
def list(
self,
endpoint: str,
wallet: str,
wallet_password: str,
address: Optional[str] = None,
json_mode: bool = False,
) -> CommandResult:
"""
List shards of the storage node.
Args:
address: Address of wallet account.
json_mode: Print shard info as a JSON array.
endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
wallet: WIF (NEP-2) string or path to the wallet or binary key.
wallet_password: Wallet password.
Returns:
Command's result.
"""
return self._execute_with_password(
f"control shards list",
wallet_password,
**{
param: value
for param, value in locals().items()
if param not in ["self", "wallet_password"]
},
)

View file

@ -0,0 +1,147 @@
from typing import Optional
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.shell import CommandResult
class FrostfsCliStorageGroup(CliCommand):
def put(
self,
rpc_endpoint: str,
wallet: str,
cid: str,
members: list[str],
ttl: Optional[int] = None,
bearer: Optional[str] = None,
lifetime: Optional[int] = None,
address: Optional[str] = None,
xhdr: Optional[dict] = None,
) -> CommandResult:
"""
Put storage group to FrostFS.
Args:
address: Address of wallet account.
bearer: File with signed JSON or binary encoded bearer token.
cid: Container ID.
members: ID list of storage group members.
lifetime: Storage group lifetime in epochs.
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
ttl: TTL value in request meta header.
wallet: WIF (NEP-2) string or path to the wallet or binary key.
xhdr: Dict with request X-Headers.
Returns:
Command's result.
"""
members = ",".join(members)
return self._execute(
"storagegroup put",
**{param: value for param, value in locals().items() if param not in ["self"]},
)
def get(
self,
rpc_endpoint: str,
wallet: str,
cid: str,
id: str,
raw: Optional[bool] = False,
ttl: Optional[int] = None,
bearer: Optional[str] = None,
lifetime: Optional[int] = None,
address: Optional[str] = None,
xhdr: Optional[dict] = None,
) -> CommandResult:
"""
Get storage group from FrostFS.
Args:
address: Address of wallet account.
bearer: File with signed JSON or binary encoded bearer token.
cid: Container ID.
id: Storage group identifier.
raw: Set raw request option.
lifetime: Storage group lifetime in epochs.
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
ttl: TTL value in request meta header.
wallet: WIF (NEP-2) string or path to the wallet or binary key.
xhdr: Dict with request X-Headers.
Returns:
Command's result.
"""
return self._execute(
"storagegroup get",
**{param: value for param, value in locals().items() if param not in ["self"]},
)
def list(
self,
rpc_endpoint: str,
wallet: str,
cid: str,
raw: Optional[bool] = False,
ttl: Optional[int] = None,
bearer: Optional[str] = None,
lifetime: Optional[int] = None,
address: Optional[str] = None,
xhdr: Optional[dict] = None,
) -> CommandResult:
"""
List storage groups in FrostFS container.
Args:
address: Address of wallet account.
bearer: File with signed JSON or binary encoded bearer token.
cid: Container ID.
raw: Set raw request option.
lifetime: Storage group lifetime in epochs.
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
ttl: TTL value in request meta header.
wallet: WIF (NEP-2) string or path to the wallet or binary key.
xhdr: Dict with request X-Headers.
Returns:
Command's result.
"""
return self._execute(
"storagegroup list",
**{param: value for param, value in locals().items() if param not in ["self"]},
)
def delete(
self,
rpc_endpoint: str,
wallet: str,
cid: str,
id: str,
raw: Optional[bool] = False,
ttl: Optional[int] = None,
bearer: Optional[str] = None,
lifetime: Optional[int] = None,
address: Optional[str] = None,
xhdr: Optional[dict] = None,
) -> CommandResult:
"""
Delete storage group from FrostFS.
Args:
address: Address of wallet account.
bearer: File with signed JSON or binary encoded bearer token.
cid: Container ID.
id: Storage group identifier.
raw: Set raw request option.
lifetime: Storage group lifetime in epochs.
rpc_endpoint: Remote node address (as 'multiaddr' or '<host>:<port>').
ttl: TTL value in request meta header.
wallet: WIF (NEP-2) string or path to the wallet or binary key.
xhdr: Dict with request X-Headers.
Returns:
Command's result.
"""
return self._execute(
"storagegroup delete",
**{param: value for param, value in locals().items() if param not in ["self"]},
)

View file

@ -0,0 +1,56 @@
from typing import Optional
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.shell import CommandResult
class FrostfsCliUtil(CliCommand):
def sign_bearer_token(
self,
wallet: str,
from_file: str,
to_file: str,
address: Optional[str] = None,
json: Optional[bool] = False,
) -> CommandResult:
"""
Sign bearer token to use it in requests.
Args:
address: Address of wallet account.
from_file: File with JSON or binary encoded bearer token to sign.
to_file: File to dump signed bearer token (default: binary encoded).
json: Dump bearer token in JSON encoding.
wallet: WIF (NEP-2) string or path to the wallet or binary key.
Returns:
Command's result.
"""
return self._execute(
"util sign bearer-token",
**{param: value for param, value in locals().items() if param not in ["self"]},
)
def sign_session_token(
self,
wallet: str,
from_file: str,
to_file: str,
address: Optional[str] = None,
) -> CommandResult:
"""
Sign session token to use it in requests.
Args:
address: Address of wallet account.
from_file: File with JSON encoded session token to sign.
to_file: File to dump signed bearer token (default: binary encoded).
wallet: WIF (NEP-2) string or path to the wallet or binary key.
Returns:
Command's result.
"""
return self._execute(
"util sign session-token",
**{param: value for param, value in locals().items() if param not in ["self"]},
)

View file

@ -0,0 +1,13 @@
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.shell import CommandResult
class FrostfsCliVersion(CliCommand):
def get(self) -> CommandResult:
"""
Application version and FrostFS API compatibility.
Returns:
Command's result.
"""
return self._execute("", version=True)

View file

@ -0,0 +1,2 @@
from frostfs_testlib.cli.neogo.go import NeoGo
from frostfs_testlib.cli.neogo.network_type import NetworkType

View file

@ -0,0 +1,134 @@
from typing import Optional
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.shell import CommandResult
class NeoGoCandidate(CliCommand):
def register(
self,
address: str,
rpc_endpoint: str,
wallet: Optional[str] = None,
wallet_config: Optional[str] = None,
wallet_password: Optional[str] = None,
gas: Optional[float] = None,
timeout: int = 10,
) -> CommandResult:
"""Register as a new candidate.
Args:
address: Address to register.
wallet: Target location of the wallet file ('-' to read from stdin);
conflicts with --wallet-config flag.
wallet_config: Target location of the wallet config file; conflicts with --wallet flag.
wallet_password: Wallet password.
gas: Network fee to add to the transaction (prioritizing it).
rpc_endpoint: RPC node address.
timeout: Timeout for the operation (default: 10s).
Returns:
Command's result.
"""
assert bool(wallet) ^ bool(wallet_config), self.WALLET_SOURCE_ERROR_MSG
exec_param = {
param: param_value
for param, param_value in locals().items()
if param not in ["self", "wallet_password"]
}
exec_param["timeout"] = f"{timeout}s"
if wallet_password is not None:
return self._execute_with_password(
"wallet candidate register", wallet_password, **exec_param
)
if wallet_config:
return self._execute("wallet candidate register", **exec_param)
raise Exception(self.WALLET_PASSWD_ERROR_MSG)
def unregister(
self,
address: str,
rpc_endpoint: str,
wallet: Optional[str] = None,
wallet_config: Optional[str] = None,
wallet_password: Optional[str] = None,
gas: Optional[float] = None,
timeout: int = 10,
) -> CommandResult:
"""Unregister self as a candidate.
Args:
address: Address to unregister.
wallet: Target location of the wallet file ('-' to read from stdin);
conflicts with --wallet-config flag.
wallet_config: Target location of the wallet config file; conflicts with --wallet flag.
wallet_password: Wallet password.
gas: Network fee to add to the transaction (prioritizing it).
rpc_endpoint: RPC node address.
timeout: Timeout for the operation (default: 10s).
Returns:
Command's result.
"""
assert bool(wallet) ^ bool(wallet_config), self.WALLET_SOURCE_ERROR_MSG
exec_param = {
param: param_value
for param, param_value in locals().items()
if param not in ["self", "wallet_password"]
}
exec_param["timeout"] = f"{timeout}s"
if wallet_password is not None:
return self._execute_with_password(
"wallet candidate unregister", wallet_password, **exec_param
)
if wallet_config:
return self._execute("wallet candidate unregister", **exec_param)
raise Exception(self.WALLET_PASSWD_ERROR_MSG)
def vote(
self,
address: str,
candidate: str,
rpc_endpoint: str,
wallet: Optional[str] = None,
wallet_config: Optional[str] = None,
wallet_password: Optional[str] = None,
gas: Optional[float] = None,
timeout: int = 10,
) -> CommandResult:
"""Votes for a validator.
Voting happens by calling "vote" method of a NEO native contract. Do not provide
candidate argument to perform unvoting.
Args:
address: Address to vote from
candidate: Public key of candidate to vote for.
wallet: Target location of the wallet file ('-' to read from stdin);
conflicts with --wallet-config flag.
wallet_config: Target location of the wallet config file; conflicts with --wallet flag.
wallet_password: Wallet password.
gas: Network fee to add to the transaction (prioritizing it).
rpc_endpoint: RPC node address.
timeout: Timeout for the operation (default: 10s).
Returns:
Command's result.
"""
assert bool(wallet) ^ bool(wallet_config), self.WALLET_SOURCE_ERROR_MSG
exec_param = {
param: param_value
for param, param_value in locals().items()
if param not in ["self", "wallet_password"]
}
exec_param["timeout"] = f"{timeout}s"
if wallet_password is not None:
return self._execute_with_password(
"wallet candidate vote", wallet_password, **exec_param
)
if wallet_config:
return self._execute("wallet candidate vote", **exec_param)
raise Exception(self.WALLET_PASSWD_ERROR_MSG)

View file

@ -0,0 +1,398 @@
from typing import Optional
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.shell import CommandResult
class NeoGoContract(CliCommand):
def compile(
self,
input_file: str,
out: str,
manifest: str,
config: str,
no_standards: bool = False,
no_events: bool = False,
no_permissions: bool = False,
bindings: Optional[str] = None,
) -> CommandResult:
"""Compile a smart contract to a .nef file.
Args:
input_file: Input file for the smart contract to be compiled.
out: Output of the compiled contract.
manifest: Emit contract manifest (*.manifest.json) file into separate file using
configuration input file (*.yml).
config: Configuration input file (*.yml).
no_standards: Do not check compliance with supported standards.
no_events: Do not check emitted events with the manifest.
no_permissions: Do not check if invoked contracts are allowed in manifest.
bindings: Output file for smart-contract bindings configuration.
Returns:
Command's result.
"""
return self._execute(
"contract compile",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def deploy(
self,
address: str,
input_file: str,
manifest: str,
rpc_endpoint: str,
sysgas: Optional[float] = None,
wallet: Optional[str] = None,
wallet_config: Optional[str] = None,
wallet_password: Optional[str] = None,
gas: Optional[float] = None,
out: Optional[str] = None,
force: bool = False,
timeout: int = 10,
) -> CommandResult:
"""Deploy a smart contract (.nef with description)
Args:
wallet: Wallet to use to get the key for transaction signing;
conflicts with wallet_config.
wallet_config: Path to wallet config to use to get the key for transaction signing;
conflicts with wallet.
wallet_password: Wallet password.
address: Address to use as transaction signee (and gas source).
gas: Network fee to add to the transaction (prioritizing it).
sysgas: System fee to add to transaction (compensating for execution).
out: File to put JSON transaction to.
force: Do not ask for a confirmation.
rpc_endpoint: RPC node address.
timeout: Timeout for the operation (default: 10s).
input_file: Input file for the smart contract (*.nef).
manifest: Emit contract manifest (*.manifest.json) file into separate file using
configuration input file (*.yml).
Returns:
Command's result.
"""
assert bool(wallet) ^ bool(wallet_config), self.WALLET_SOURCE_ERROR_MSG
exec_param = {
param: param_value
for param, param_value in locals().items()
if param not in ["self", "wallet_password"]
}
exec_param["timeout"] = f"{timeout}s"
if wallet_password is not None:
return self._execute_with_password(
"contract deploy",
wallet_password,
**exec_param,
)
if wallet_config:
return self._execute(
"contract deploy",
**exec_param,
)
raise Exception(self.WALLET_PASSWD_ERROR_MSG)
def generate_wrapper(
self,
out: str,
hash: str,
config: Optional[str] = None,
manifest: Optional[str] = None,
) -> CommandResult:
"""Generate wrapper to use in other contracts.
Args:
config: Configuration file to use.
manifest: Read contract manifest (*.manifest.json) file.
out: Output of the compiled contract.
hash: Smart-contract hash.
Returns:
Command's result.
"""
return self._execute(
"contract generate-wrapper",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def invokefunction(
self,
scripthash: str,
address: Optional[str] = None,
wallet: Optional[str] = None,
method: Optional[str] = None,
arguments: Optional[str] = None,
multisig_hash: Optional[str] = None,
wallet_config: Optional[str] = None,
wallet_password: Optional[str] = None,
gas: Optional[float] = None,
sysgas: Optional[float] = None,
out: Optional[str] = None,
force: bool = False,
rpc_endpoint: Optional[str] = None,
timeout: int = 10,
) -> CommandResult:
"""Executes given (as a script hash) deployed script.
Script is executed with the given method, arguments and signers. Sender is included in
the list of signers by default with None witness scope. If you'd like to change default
sender's scope, specify it via signers parameter. See testinvokefunction documentation
for the details about parameters. It differs from testinvokefunction in that this command
sends an invocation transaction to the network.
Args:
scripthash: Function hash.
method: Call method.
arguments: Method arguments.
multisig_hash: Multisig hash.
wallet: Wallet to use to get the key for transaction signing;
conflicts with wallet_config.
wallet_config: Path to wallet config to use to get the key for transaction signing;
conflicts with wallet.
wallet_password: Wallet password.
address: Address to use as transaction signee (and gas source).
gas: Network fee to add to the transaction (prioritizing it).
sysgas: System fee to add to transaction (compensating for execution).
out: File to put JSON transaction to.
force: Force-push the transaction in case of bad VM state after test script invocation.
rpc_endpoint: RPC node address.
timeout: Timeout for the operation (default: 10s).
Returns:
Command's result.
"""
assert bool(wallet) ^ bool(wallet_config), self.WALLET_SOURCE_ERROR_MSG
multisig_hash = f"-- {multisig_hash}" or ""
post_data = f"{scripthash} {method or ''} {arguments or ''} {multisig_hash}"
exec_param = {
param: param_value
for param, param_value in locals().items()
if param
not in [
"self",
"scripthash",
"method",
"arguments",
"multisig_hash",
"wallet_password",
]
}
exec_param["timeout"] = f"{timeout}s"
exec_param["post_data"] = post_data
if wallet_password is not None:
return self._execute_with_password(
"contract invokefunction", wallet_password, **exec_param
)
if wallet_config:
return self._execute("contract invokefunction", **exec_param)
raise Exception(self.WALLET_PASSWD_ERROR_MSG)
def testinvokefunction(
self,
scripthash: str,
wallet: Optional[str] = None,
wallet_password: Optional[str] = None,
method: Optional[str] = None,
arguments: Optional[str] = None,
multisig_hash: Optional[str] = None,
rpc_endpoint: Optional[str] = None,
timeout: int = 10,
) -> CommandResult:
"""Executes given (as a script hash) deployed script.
Script is executed with the given method, arguments and signers (sender is not included
by default). If no method is given "" is passed to the script, if no arguments are given,
an empty array is passed, if no signers are given no array is passed. If signers are
specified, the first one of them is treated as a sender. All of the given arguments are
encapsulated into array before invoking the script. The script thus should follow the
regular convention of smart contract arguments (method string and an array of other
arguments).
See more information and samples in `neo-go contract testinvokefunction --help`.
Args:
scripthash: Function hash.
wallet: Wallet to use for testinvoke.
wallet_password: Wallet password.
method: Call method.
arguments: Method arguments.
multisig_hash: Multisig hash.
rpc_endpoint: RPC node address.
timeout: Timeout for the operation (default: 10s).
Returns:
Command's result.
"""
multisig_hash = f"-- {multisig_hash}" if multisig_hash else ""
post_data = f"{scripthash} {method or ''} {arguments or ''} {multisig_hash}"
exec_param = {
param: param_value
for param, param_value in locals().items()
if param
not in [
"self",
"scripthash",
"method",
"arguments",
"multisig_hash",
"wallet_password",
]
}
exec_param["timeout"] = f"{timeout}s"
exec_param["post_data"] = post_data
if wallet_password is not None:
return self._execute_with_password(
"contract testinvokefunction", wallet_password, **exec_param
)
return self._execute("contract testinvokefunction", **exec_param)
def testinvokescript(
self,
input_file: str,
rpc_endpoint: Optional[str] = None,
timeout: int = 10,
) -> CommandResult:
"""Executes given compiled AVM instructions in NEF format.
Instructions are executed with the given set of signers not including sender by default.
See testinvokefunction documentation for the details about parameters.
Args:
input_file: Input location of the .nef file that needs to be invoked.
rpc_endpoint: RPC node address.
timeout: Timeout for the operation (default: 10s).
Returns:
Command's result.
"""
exec_param = {
param: param_value for param, param_value in locals().items() if param not in ["self"]
}
exec_param["timeout"] = f"{timeout}s"
return self._execute(
"contract testinvokescript",
**exec_param,
)
def init(self, name: str, skip_details: bool = False) -> CommandResult:
"""Initialize a new smart-contract in a directory with boiler plate code.
Args:
name: Name of the smart-contract to be initialized.
skip_details: Skip filling in the projects and contract details.
Returns:
Command's result.
"""
return self._execute(
"contract init",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def inspect(
self,
input_file: Optional[str] = None,
compile: Optional[str] = None,
) -> CommandResult:
"""Creates a user readable dump of the program instructions.
Args:
input_file: Input file of the program (either .go or .nef).
compile: Compile input file (it should be go code then).
Returns:
Command's result.
"""
return self._execute(
"contract inspect",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def calc_hash(
self,
input_file: str,
manifest: str,
sender: Optional[str] = None,
) -> CommandResult:
"""Calculates hash of a contract after deployment.
Args:
input_file: Path to NEF file.
sender: Sender script hash or address.
manifest: Path to manifest file.
Returns:
Command's result.
"""
return self._execute(
"contract calc-hash",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def add_group(
self,
manifest: str,
address: str,
wallet: Optional[str] = None,
wallet_config: Optional[str] = None,
wallet_password: Optional[str] = None,
sender: Optional[str] = None,
nef: Optional[str] = None,
) -> CommandResult:
"""Adds group to the manifest.
Args:
wallet: Wallet to use to get the key for transaction signing;
conflicts with wallet_config.
wallet_config: Path to wallet config to use to get the key for transaction signing;
conflicts with wallet.
wallet_password: Wallet password.
sender: Deploy transaction sender.
address: Account to sign group with.
nef: Path to the NEF file.
manifest: Path to the manifest.
Returns:
Command's result.
"""
assert bool(wallet) ^ bool(wallet_config), self.WALLET_SOURCE_ERROR_MSG
exec_param = {
param: param_value
for param, param_value in locals().items()
if param not in ["self", "wallet_password"]
}
if wallet_password is not None:
return self._execute_with_password(
"contract manifest add-group", wallet_password, **exec_param
)
if wallet_config:
return self._execute("contract manifest add-group", **exec_param)
raise Exception(self.WALLET_PASSWD_ERROR_MSG)

View file

@ -0,0 +1,69 @@
from typing import Optional
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.cli.neogo.network_type import NetworkType
from frostfs_testlib.shell import CommandResult
class NeoGoDb(CliCommand):
def dump(
self,
config_path: str,
out: str,
network: NetworkType = NetworkType.PRIVATE,
count: int = 0,
start: int = 0,
) -> CommandResult:
"""Dump blocks (starting with block #1) to the file.
Args:
config_path: Path to config.
network: Select network type (default: private).
count: Number of blocks to be processed (default or 0: all chain) (default: 0).
start: Block number to start from (default: 0) (default: 0).
out: Output file (stdout if not given).
Returns:
Command's result.
"""
return self._execute(
"db dump",
**{network.value: True},
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def restore(
self,
config_path: str,
input_file: str,
network: NetworkType = NetworkType.PRIVATE,
count: int = 0,
dump: Optional[str] = None,
incremental: bool = False,
) -> CommandResult:
"""Dump blocks (starting with block #1) to the file.
Args:
config_path: Path to config.
network: Select network type (default: private).
count: Number of blocks to be processed (default or 0: all chain) (default: 0).
input_file: Input file (stdin if not given).
dump: Directory for storing JSON dumps.
incremental: Use if dump is incremental.
Returns:
Command's result.
"""
return self._execute(
"db restore",
**{network.value: True},
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)

View file

@ -0,0 +1,37 @@
from typing import Optional
from frostfs_testlib.cli.neogo.candidate import NeoGoCandidate
from frostfs_testlib.cli.neogo.contract import NeoGoContract
from frostfs_testlib.cli.neogo.db import NeoGoDb
from frostfs_testlib.cli.neogo.nep17 import NeoGoNep17
from frostfs_testlib.cli.neogo.node import NeoGoNode
from frostfs_testlib.cli.neogo.query import NeoGoQuery
from frostfs_testlib.cli.neogo.version import NeoGoVersion
from frostfs_testlib.cli.neogo.wallet import NeoGoWallet
from frostfs_testlib.shell import Shell
class NeoGo:
candidate: Optional[NeoGoCandidate] = None
contract: Optional[NeoGoContract] = None
db: Optional[NeoGoDb] = None
nep17: Optional[NeoGoNep17] = None
node: Optional[NeoGoNode] = None
query: Optional[NeoGoQuery] = None
version: Optional[NeoGoVersion] = None
wallet: Optional[NeoGoWallet] = None
def __init__(
self,
shell: Shell,
neo_go_exec_path: str,
config_path: Optional[str] = None,
):
self.candidate = NeoGoCandidate(shell, neo_go_exec_path, config_path=config_path)
self.contract = NeoGoContract(shell, neo_go_exec_path, config_path=config_path)
self.db = NeoGoDb(shell, neo_go_exec_path, config_path=config_path)
self.nep17 = NeoGoNep17(shell, neo_go_exec_path, config_path=config_path)
self.node = NeoGoNode(shell, neo_go_exec_path, config_path=config_path)
self.query = NeoGoQuery(shell, neo_go_exec_path, config_path=config_path)
self.version = NeoGoVersion(shell, neo_go_exec_path, config_path=config_path)
self.wallet = NeoGoWallet(shell, neo_go_exec_path, config_path=config_path)

View file

@ -0,0 +1,240 @@
from typing import Optional
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.shell import CommandResult
class NeoGoNep17(CliCommand):
def balance(
self,
address: str,
token: str,
rpc_endpoint: str,
wallet: Optional[str] = None,
wallet_config: Optional[str] = None,
timeout: int = 10,
) -> CommandResult:
"""Get address balance.
Args:
address: Address to use.
wallet: Target location of the wallet file ('-' to read from stdin);
conflicts with --wallet-config flag.
wallet_config: Target location of the wallet config file; conflicts with --wallet flag.
token: Token to use (hash or name (for NEO/GAS or imported tokens)).
rpc_endpoint: RPC node address.
timeout: Timeout for the operation (default: 10s).
Returns:
Command's result.
"""
assert bool(wallet) ^ bool(wallet_config), self.WALLET_SOURCE_ERROR_MSG
exec_param = {
param: param_value for param, param_value in locals().items() if param not in ["self"]
}
exec_param["timeout"] = f"{timeout}s"
return self._execute(
"wallet nep17 balance",
**exec_param,
)
def import_token(
self,
address: str,
wallet: Optional[str] = None,
wallet_config: Optional[str] = None,
token: Optional[str] = None,
rpc_endpoint: Optional[str] = None,
timeout: int = 10,
) -> CommandResult:
"""Import NEP-17 token to a wallet.
Args:
address: Token contract address or hash in LE.
wallet: Target location of the wallet file ('-' to read from stdin);
conflicts with --wallet-config flag.
wallet_config: Target location of the wallet config file; conflicts with --wallet flag.
token: Token to use (hash or name (for NEO/GAS or imported tokens)).
rpc_endpoint: RPC node address.
timeout: Timeout for the operation (default: 10s).
Returns:
Command's result.
"""
assert bool(wallet) ^ bool(wallet_config), self.WALLET_SOURCE_ERROR_MSG
exec_param = {
param: param_value for param, param_value in locals().items() if param not in ["self"]
}
exec_param["timeout"] = f"{timeout}s"
return self._execute(
"wallet nep17 import",
**exec_param,
)
def info(
self,
token: str,
wallet: Optional[str] = None,
wallet_config: Optional[str] = None,
) -> CommandResult:
"""Print imported NEP-17 token info.
Args:
wallet: Target location of the wallet file ('-' to read from stdin);
conflicts with --wallet-config flag.
wallet_config: Target location of the wallet config file; conflicts with --wallet flag.
token: Token to use (hash or name (for NEO/GAS or imported tokens)).
Returns:
Command's result.
"""
assert bool(wallet) ^ bool(wallet_config), self.WALLET_SOURCE_ERROR_MSG
return self._execute(
"wallet nep17 info",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def remove(
self,
token: str,
wallet: Optional[str] = None,
wallet_config: Optional[str] = None,
force: bool = False,
) -> CommandResult:
"""Remove NEP-17 token from the wallet.
Args:
wallet: Target location of the wallet file ('-' to read from stdin);
conflicts with --wallet-config flag.
wallet_config: Target location of the wallet config file; conflicts with --wallet flag.
token: Token to use (hash or name (for NEO/GAS or imported tokens)).
force: Do not ask for a confirmation.
Returns:
Command's result.
"""
return self._execute(
"wallet nep17 remove",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def transfer(
self,
token: str,
to_address: str,
rpc_endpoint: str,
sysgas: Optional[float] = None,
wallet: Optional[str] = None,
wallet_config: Optional[str] = None,
wallet_password: Optional[str] = None,
out: Optional[str] = None,
from_address: Optional[str] = None,
force: bool = False,
gas: Optional[float] = None,
amount: float = 0,
timeout: int = 10,
) -> CommandResult:
"""Transfers specified NEP-17 token amount.
Transfer is executed with optional 'data' parameter and cosigners list attached to the
transfer. See 'contract testinvokefunction' documentation for the details about 'data'
parameter and cosigners syntax. If no 'data' is given then default nil value will be used.
If no cosigners are given then the sender with CalledByEntry scope will be used as the only
signer.
Args:
wallet: Target location of the wallet file ('-' to read from stdin);
conflicts with --wallet-config flag.
wallet_config: Target location of the wallet config file; conflicts with --wallet flag.
wallet_password: Wallet password.
out: File to put JSON transaction to.
from_address: Address to send an asset from.
to_address: Address to send an asset to.
token: Token to use (hash or name (for NEO/GAS or imported tokens)).
force: Do not ask for a confirmation.
gas: Network fee to add to the transaction (prioritizing it).
sysgas: System fee to add to transaction (compensating for execution).
force: Do not ask for a confirmation.
amount: Amount of asset to send.
rpc_endpoint: RPC node address.
timeout: Timeout for the operation (default: 10s).
Returns:
Command's result.
"""
assert bool(wallet) ^ bool(wallet_config), self.WALLET_SOURCE_ERROR_MSG
exec_param = {
param: param_value
for param, param_value in locals().items()
if param not in ["self", "wallet_password"]
}
exec_param["timeout"] = f"{timeout}s"
if wallet_password is not None:
return self._execute_with_password(
"wallet nep17 transfer",
wallet_password,
**exec_param,
)
if wallet_config:
return self._execute(
"wallet nep17 transfer",
**exec_param,
)
raise Exception(self.WALLET_PASSWD_ERROR_MSG)
def multitransfer(
self,
token: str,
to_address: list[str],
sysgas: float,
rpc_endpoint: str,
wallet: Optional[str] = None,
wallet_config: Optional[str] = None,
out: Optional[str] = None,
from_address: Optional[str] = None,
force: bool = False,
gas: Optional[float] = None,
amount: float = 0,
timeout: int = 10,
) -> CommandResult:
"""Transfer NEP-17 tokens to multiple recipients.
Args:
wallet: Target location of the wallet file ('-' to read from stdin);
conflicts with --wallet-config flag.
wallet_config: Target location of the wallet config file; conflicts with --wallet flag.
out: File to put JSON transaction to.
from_address: Address to send an asset from.
to_address: Address to send an asset to.
token: Token to use (hash or name (for NEO/GAS or imported tokens)).
force: Do not ask for a confirmation.
gas: Network fee to add to the transaction (prioritizing it).
sysgas: System fee to add to transaction (compensating for execution).
force: Do not ask for a confirmation.
amount: Amount of asset to send.
rpc_endpoint: RPC node address.
timeout: Timeout for the operation (default: 10s).
Returns:
Command's result.
"""
assert bool(wallet) ^ bool(wallet_config), self.WALLET_SOURCE_ERROR_MSG
exec_param = {
param: param_value for param, param_value in locals().items() if param not in ["self"]
}
exec_param["timeout"] = f"{timeout}s"
return self._execute(
"wallet nep17 multitransfer",
**exec_param,
)

View file

@ -0,0 +1,7 @@
from enum import Enum
class NetworkType(Enum):
PRIVATE = "privnet"
MAIN = "mainnet"
TEST = "testnet"

View file

@ -0,0 +1,16 @@
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.cli.neogo.network_type import NetworkType
from frostfs_testlib.shell import CommandResult
class NeoGoNode(CliCommand):
def start(self, network: NetworkType = NetworkType.PRIVATE) -> CommandResult:
"""Start a NEO node.
Args:
network: Select network type (default: private).
Returns:
Command's result.
"""
return self._execute("start", **{network.value: True})

View file

@ -0,0 +1,100 @@
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.shell import CommandResult
class NeoGoQuery(CliCommand):
def candidates(self, rpc_endpoint: str, timeout: str = "10s") -> CommandResult:
"""Get candidates and votes.
Args:
rpc_endpoint: RPC node address.
timeout: Timeout for the operation (default: 10s).
Returns:
Command's result.
"""
return self._execute(
"query candidates",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def committee(self, rpc_endpoint: str, timeout: str = "10s") -> CommandResult:
"""Get committee list.
Args:
rpc_endpoint: RPC node address.
timeout: Timeout for the operation (default: 10s).
Returns:
Command's result.
"""
return self._execute(
"query committee",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def height(self, rpc_endpoint: str, timeout: str = "10s") -> CommandResult:
"""Get node height.
Args:
rpc_endpoint: RPC node address.
timeout: Timeout for the operation (default: 10s).
Returns:
Command's result.
"""
return self._execute(
"query height",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def tx(self, tx_hash: str, rpc_endpoint: str, timeout: str = "10s") -> CommandResult:
"""Query transaction status.
Args:
tx_hash: Hash of transaction.
rpc_endpoint: RPC node address.
timeout: Timeout for the operation (default: 10s).
Returns:
Command's result.
"""
return self._execute(
f"query tx {tx_hash}",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self", "hash"]
},
)
def voter(self, rpc_endpoint: str, timeout: str = "10s") -> CommandResult:
"""Print NEO holder account state.
Args:
rpc_endpoint: RPC node address.
timeout: Timeout for the operation (default: 10s).
Returns:
Command's result.
"""
return self._execute(
"query voter",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)

View file

@ -0,0 +1,12 @@
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.shell import CommandResult
class NeoGoVersion(CliCommand):
def get(self) -> CommandResult:
"""Application version.
Returns:
Command's result.
"""
return self._execute("", version=True)

View file

@ -0,0 +1,381 @@
from typing import Optional
from frostfs_testlib.cli.cli_command import CliCommand
from frostfs_testlib.shell import CommandResult
class NeoGoWallet(CliCommand):
def claim(
self,
address: str,
rpc_endpoint: str,
wallet: Optional[str] = None,
wallet_config: Optional[str] = None,
timeout: int = 10,
) -> CommandResult:
"""Claim GAS.
Args:
address: Address to claim GAS for.
wallet: Target location of the wallet file ('-' to read from stdin);
conflicts with --wallet-config flag.
wallet_config: Target location of the wallet config file; conflicts with --wallet flag.
rpc_endpoint: RPC node address.
timeout: Timeout for the operation (default: 10s).
Returns:
Command's result.
"""
assert bool(wallet) ^ bool(wallet_config), self.WALLET_SOURCE_ERROR_MSG
exec_param = {
param: param_value for param, param_value in locals().items() if param not in ["self"]
}
exec_param["timeout"] = f"{timeout}s"
return self._execute(
"wallet claim",
**exec_param,
)
def init(
self,
wallet: Optional[str] = None,
wallet_config: Optional[str] = None,
account: bool = False,
) -> CommandResult:
"""Create a new wallet.
Args:
wallet: Target location of the wallet file ('-' to read from stdin);
conflicts with --wallet-config flag.
wallet_config: Target location of the wallet config file; conflicts with --wallet flag.
account: Create a new account.
Returns:
Command's result.
"""
assert bool(wallet) ^ bool(wallet_config), self.WALLET_SOURCE_ERROR_MSG
return self._execute(
"wallet init",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def convert(
self,
out: str,
wallet: Optional[str] = None,
wallet_config: Optional[str] = None,
) -> CommandResult:
"""Convert addresses from existing NEO2 NEP6-wallet to NEO3 format.
Args:
wallet: Target location of the wallet file ('-' to read from stdin);
conflicts with --wallet-config flag.
wallet_config: Target location of the wallet config file; conflicts with --wallet flag.
out: Where to write converted wallet.
Returns:
Command's result.
"""
assert bool(wallet) ^ bool(wallet_config), self.WALLET_SOURCE_ERROR_MSG
return self._execute(
"wallet convert",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def create(
self,
wallet: Optional[str] = None,
wallet_config: Optional[str] = None,
) -> CommandResult:
"""Add an account to the existing wallet.
Args:
wallet: Target location of the wallet file ('-' to read from stdin);
conflicts with --wallet-config flag.
wallet_config: Target location of the wallet config file; conflicts with --wallet flag.
Returns:
Command's result.
"""
assert bool(wallet) ^ bool(wallet_config), self.WALLET_SOURCE_ERROR_MSG
return self._execute(
"wallet create",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def dump(
self,
wallet: Optional[str] = None,
wallet_config: Optional[str] = None,
decrypt: bool = False,
) -> CommandResult:
"""Check and dump an existing NEO wallet.
Args:
wallet: Target location of the wallet file ('-' to read from stdin);
conflicts with --wallet-config flag.
wallet_config: Target location of the wallet config file; conflicts with --wallet flag.
decrypt: Decrypt encrypted keys.
Returns:
Command's result.
"""
assert bool(wallet) ^ bool(wallet_config), self.WALLET_SOURCE_ERROR_MSG
return self._execute(
"wallet dump",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def dump_keys(
self,
address: Optional[str] = None,
wallet: Optional[str] = None,
wallet_config: Optional[str] = None,
) -> CommandResult:
"""Check and dump an existing NEO wallet.
Args:
wallet: Target location of the wallet file ('-' to read from stdin);
conflicts with --wallet-config flag.
wallet_config: Target location of the wallet config file; conflicts with --wallet flag.
address: Address to print public keys for.
Returns:
Command's result.
"""
assert bool(wallet) ^ bool(wallet_config), self.WALLET_SOURCE_ERROR_MSG
return self._execute(
"wallet dump-keys",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def export(
self,
wallet: Optional[str] = None,
wallet_config: Optional[str] = None,
decrypt: bool = False,
) -> CommandResult:
"""Export keys for address.
Args:
wallet: Target location of the wallet file ('-' to read from stdin);
conflicts with --wallet-config flag.
wallet_config: Target location of the wallet config file; conflicts with --wallet flag.
decrypt: Decrypt encrypted keys.
Returns:
Command's result.
"""
assert bool(wallet) ^ bool(wallet_config), self.WALLET_SOURCE_ERROR_MSG
return self._execute(
"wallet export",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def import_wif(
self,
wif: str,
name: str,
contract: str,
wallet: Optional[str] = None,
wallet_config: Optional[str] = None,
) -> CommandResult:
"""Import WIF of a standard signature contract.
Args:
wallet: Target location of the wallet file ('-' to read from stdin);
conflicts with --wallet-config flag.
wallet_config: Target location of the wallet config file; conflicts with --wallet flag.
wif: WIF to import.
name: Optional account name.
contract: Verification script for custom contracts.
Returns:
Command's result.
"""
assert bool(wallet) ^ bool(wallet_config), self.WALLET_SOURCE_ERROR_MSG
return self._execute(
"wallet import",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def import_multisig(
self,
wif: str,
name: Optional[str] = None,
min_number: int = 0,
wallet: Optional[str] = None,
wallet_config: Optional[str] = None,
) -> CommandResult:
"""Import multisig contract.
Args:
wallet: Target location of the wallet file ('-' to read from stdin);
conflicts with --wallet-config flag.
wallet_config: Target location of the wallet config file; conflicts with --wallet flag.
wif: WIF to import.
name: Optional account name.
min_number: Minimal number of signatures (default: 0).
Returns:
Command's result.
"""
assert bool(wallet) ^ bool(wallet_config), self.WALLET_SOURCE_ERROR_MSG
return self._execute(
"wallet import-multisig",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def import_deployed(
self,
wif: str,
rpc_endpoint: str,
name: Optional[str] = None,
wallet: Optional[str] = None,
wallet_config: Optional[str] = None,
contract: Optional[str] = None,
timeout: int = 10,
) -> CommandResult:
"""Import deployed contract.
Args:
wallet: Target location of the wallet file ('-' to read from stdin);
conflicts with --wallet-config flag.
wallet_config: Target location of the wallet config file; conflicts with --wallet flag.
wif: WIF to import.
name: Optional account name.
contract: Contract hash or address.
rpc_endpoint: RPC node address.
timeout: Timeout for the operation (default: 10s).
Returns:
Command's result.
"""
assert bool(wallet) ^ bool(wallet_config), self.WALLET_SOURCE_ERROR_MSG
exec_param = {
param: param_value for param, param_value in locals().items() if param not in ["self"]
}
exec_param["timeout"] = f"{timeout}s"
return self._execute(
"wallet import-deployed",
**exec_param,
)
def remove(
self,
address: str,
wallet: Optional[str] = None,
wallet_config: Optional[str] = None,
force: bool = False,
) -> CommandResult:
"""Remove an account from the wallet.
Args:
wallet: Target location of the wallet file ('-' to read from stdin);
conflicts with --wallet-config flag.
wallet_config: Target location of the wallet config file; conflicts with --wallet flag.
address: Account address or hash in LE form to be removed.
force: Do not ask for a confirmation.
Returns:
Command's result.
"""
assert bool(wallet) ^ bool(wallet_config), self.WALLET_SOURCE_ERROR_MSG
return self._execute(
"wallet remove",
**{
param: param_value
for param, param_value in locals().items()
if param not in ["self"]
},
)
def sign(
self,
input_file: str,
address: str,
rpc_endpoint: Optional[str] = None,
wallet: Optional[str] = None,
wallet_config: Optional[str] = None,
wallet_password: Optional[str] = None,
out: Optional[str] = None,
timeout: int = 10,
) -> CommandResult:
"""Cosign transaction with multisig/contract/additional account.
Signs the given (in the input file) context (which must be a transaction signing context)
for the given address using the given wallet. This command can output the resulting JSON
(with additional signature added) right to the console (if no output file and no RPC
endpoint specified) or into a file (which can be the same as input one). If an RPC endpoint
is given it'll also try to construct a complete transaction and send it via RPC (printing
its hash if everything is OK).
Args:
wallet: Target location of the wallet file ('-' to read from stdin);
conflicts with --wallet-config flag.
wallet_config: Target location of the wallet config file; conflicts with --wallet flag.
wallet_password: Wallet password.
out: File to put JSON transaction to.
input_file: File with JSON transaction.
address: Address to use.
rpc_endpoint: RPC node address.
timeout: Timeout for the operation (default: 10s).
Returns:
Command's result.
"""
assert bool(wallet) ^ bool(wallet_config), self.WALLET_SOURCE_ERROR_MSG
exec_param = {
param: param_value
for param, param_value in locals().items()
if param not in ["self", "wallet_password"]
}
exec_param["timeout"] = f"{timeout}s"
if wallet_password is not None:
return self._execute_with_password("wallet sign", wallet_password, **exec_param)
if wallet_config:
return self._execute("wallet sign", **exec_param)
raise Exception(self.WALLET_PASSWD_ERROR_MSG)

View file

@ -0,0 +1,3 @@
from frostfs_testlib.hosting.config import CLIConfig, HostConfig, ServiceConfig
from frostfs_testlib.hosting.hosting import Hosting
from frostfs_testlib.hosting.interfaces import Host

View file

@ -0,0 +1,70 @@
from dataclasses import dataclass, field, fields
from typing import Any
@dataclass
class ParsedAttributes:
"""Base class for data structures representing parsed attributes from configs."""
@classmethod
def parse(cls, attributes: dict[str, Any]):
# Pick attributes supported by the class
field_names = set(field.name for field in fields(cls))
supported_attributes = {
key: value for key, value in attributes.items() if key in field_names
}
return cls(**supported_attributes)
@dataclass
class CLIConfig:
"""Describes CLI tool on some host.
Attributes:
name: Name of the tool.
exec_path: Path to executable file of the tool.
attributes: Dict with extra information about the tool.
"""
name: str
exec_path: str
attributes: dict[str, str] = field(default_factory=dict)
@dataclass
class ServiceConfig:
"""Describes neoFS service on some host.
Attributes:
name: Name of the service that uniquely identifies it across all hosts.
attributes: Dict with extra information about the service. For example, we can store
name of docker container (or name of systemd service), endpoints, path to wallet,
path to configuration file, etc.
"""
name: str
attributes: dict[str, str] = field(default_factory=dict)
@dataclass
class HostConfig:
"""Describes machine that hosts neoFS services.
Attributes:
plugin_name: Name of plugin that should be used to manage the host.
address: Address of the machine (IP or DNS name).
services: List of services hosted on the machine.
clis: List of CLI tools available on the machine.
attributes: Dict with extra information about the host. For example, we can store
connection parameters in this dict.
"""
plugin_name: str
address: str
services: list[ServiceConfig] = field(default_factory=list)
clis: list[CLIConfig] = field(default_factory=list)
attributes: dict[str, str] = field(default_factory=dict)
def __post_init__(self) -> None:
self.services = [ServiceConfig(**service) for service in self.services or []]
self.clis = [CLIConfig(**cli) for cli in self.clis or []]

View file

@ -0,0 +1,238 @@
import json
import logging
import os
import re
import time
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Optional
import docker
from requests import HTTPError
from frostfs_testlib.hosting.config import ParsedAttributes
from frostfs_testlib.hosting.interfaces import Host
from frostfs_testlib.shell import LocalShell, Shell, SSHShell
from frostfs_testlib.shell.command_inspectors import SudoInspector
logger = logging.getLogger("frostfs.testlib.hosting")
@dataclass
class HostAttributes(ParsedAttributes):
"""Represents attributes of host where Docker with neoFS runs.
Attributes:
sudo_shell: Specifies whether shell commands should be auto-prefixed with sudo.
docker_endpoint: Protocol, address and port of docker where neoFS runs. Recommended format
is tcp socket (https://docs.docker.com/engine/reference/commandline/dockerd/#daemon-socket-option),
for example: tcp://{address}:2375 (where 2375 is default docker port).
ssh_login: Login for SSH connection to the machine where docker runs.
ssh_password: Password for SSH connection.
ssh_private_key_path: Path to private key for SSH connection.
ssh_private_key_passphrase: Passphrase for the private key.
"""
sudo_shell: bool = False
docker_endpoint: Optional[str] = None
ssh_login: Optional[str] = None
ssh_password: Optional[str] = None
ssh_private_key_path: Optional[str] = None
ssh_private_key_passphrase: Optional[str] = None
@dataclass
class ServiceAttributes(ParsedAttributes):
"""Represents attributes of service running as Docker container.
Attributes:
container_name: Name of Docker container where the service runs.
volume_name: Name of volume where storage node service stores the data.
start_timeout: Timeout (in seconds) for service to start.
stop_timeout: Timeout (in seconds) for service to stop.
"""
container_name: str
volume_name: Optional[str] = None
start_timeout: int = 90
stop_timeout: int = 90
class DockerHost(Host):
"""Manages services hosted in Docker containers running on a local or remote machine."""
def get_shell(self) -> Shell:
host_attributes = HostAttributes.parse(self._config.attributes)
command_inspectors = []
if host_attributes.sudo_shell:
command_inspectors.append(SudoInspector())
if not host_attributes.ssh_login:
# If there is no SSH connection to the host, use local shell
return LocalShell(command_inspectors)
# If there is SSH connection to the host, use SSH shell
return SSHShell(
host=self._config.address,
login=host_attributes.ssh_login,
password=host_attributes.ssh_password,
private_key_path=host_attributes.ssh_private_key_path,
private_key_passphrase=host_attributes.ssh_private_key_passphrase,
command_inspectors=command_inspectors,
)
def start_host(self) -> None:
# We emulate starting machine by starting all services
# As an alternative we can probably try to stop docker service...
for service_config in self._config.services:
self.start_service(service_config.name)
def stop_host(self) -> None:
# We emulate stopping machine by stopping all services
# As an alternative we can probably try to stop docker service...
for service_config in self._config.services:
self.stop_service(service_config.name)
def start_service(self, service_name: str) -> None:
service_attributes = self._get_service_attributes(service_name)
client = self._get_docker_client()
client.start(service_attributes.container_name)
self._wait_for_container_to_be_in_state(
container_name=service_attributes.container_name,
expected_state="running",
timeout=service_attributes.start_timeout,
)
def stop_service(self, service_name: str) -> None:
service_attributes = self._get_service_attributes(service_name)
client = self._get_docker_client()
client.stop(service_attributes.container_name)
self._wait_for_container_to_be_in_state(
container_name=service_attributes.container_name,
expected_state="exited",
timeout=service_attributes.stop_timeout,
)
def restart_service(self, service_name: str) -> None:
service_attributes = self._get_service_attributes(service_name)
client = self._get_docker_client()
client.restart(service_attributes.container_name)
self._wait_for_container_to_be_in_state(
container_name=service_attributes.container_name,
expected_state="running",
timeout=service_attributes.start_timeout,
)
def delete_storage_node_data(self, service_name: str, cache_only: bool = False) -> None:
service_attributes = self._get_service_attributes(service_name)
client = self._get_docker_client()
volume_info = client.inspect_volume(service_attributes.volume_name)
volume_path = volume_info["Mountpoint"]
shell = self.get_shell()
meta_clean_cmd = f"rm -rf {volume_path}/meta*/*"
data_clean_cmd = f"; rm -rf {volume_path}/data*/*" if not cache_only else ""
cmd = f"{meta_clean_cmd}{data_clean_cmd}"
shell.exec(cmd)
def dump_logs(
self,
directory_path: str,
since: Optional[datetime] = None,
until: Optional[datetime] = None,
filter_regex: Optional[str] = None,
) -> None:
client = self._get_docker_client()
for service_config in self._config.services:
container_name = self._get_service_attributes(service_config.name).container_name
try:
logs = client.logs(container_name, since=since, until=until)
except HTTPError as exc:
logger.info(f"Got exception while dumping logs of '{container_name}': {exc}")
continue
if filter_regex:
logs = (
"\n".join(match[0] for match in re.findall(filter_regex, logs, re.IGNORECASE))
or f"No matches found in logs based on given filter '{filter_regex}'"
)
# Save logs to the directory
file_path = os.path.join(
directory_path,
f"{self._config.address}-{container_name}-log.txt",
)
with open(file_path, "wb") as file:
file.write(logs)
def is_message_in_logs(
self,
message_regex: str,
since: Optional[datetime] = None,
until: Optional[datetime] = None,
) -> bool:
client = self._get_docker_client()
for service_config in self._config.services:
container_name = self._get_service_attributes(service_config.name).container_name
try:
logs = client.logs(container_name, since=since, until=until)
except HTTPError as exc:
logger.info(f"Got exception while dumping logs of '{container_name}': {exc}")
continue
if message_regex:
matches = re.findall(message_regex, logs, re.IGNORECASE)
if matches:
return True
return False
def _get_service_attributes(self, service_name) -> ServiceAttributes:
service_config = self.get_service_config(service_name)
return ServiceAttributes.parse(service_config.attributes)
def _get_docker_client(self) -> docker.APIClient:
docker_endpoint = HostAttributes.parse(self._config.attributes).docker_endpoint
if not docker_endpoint:
# Use default docker client that talks to unix socket
return docker.APIClient()
# Otherwise use docker client that talks to specified endpoint
return docker.APIClient(base_url=docker_endpoint)
def _get_container_by_name(self, container_name: str) -> dict[str, Any]:
client = self._get_docker_client()
containers = client.containers(all=True)
for container in containers:
# Names in local docker environment are prefixed with /
clean_names = set(name.strip("/") for name in container["Names"])
if container_name in clean_names:
return container
return None
def _wait_for_container_to_be_in_state(
self, container_name: str, expected_state: str, timeout: int
) -> None:
iterations = 10
iteration_wait_time = timeout / iterations
# To speed things up, we break timeout in smaller iterations and check container state
# several times. This way waiting stops as soon as container reaches the expected state
for _ in range(iterations):
container = self._get_container_by_name(container_name)
logger.debug(f"Current container state\n:{json.dumps(container, indent=2)}")
if container and container["State"] == expected_state:
return
time.sleep(iteration_wait_time)
raise RuntimeError(f"Container {container_name} is not in {expected_state} state.")

View file

@ -0,0 +1,107 @@
import re
from typing import Any
from frostfs_testlib.hosting.config import HostConfig, ServiceConfig
from frostfs_testlib.hosting.interfaces import Host
from frostfs_testlib.plugins import load_plugin
class Hosting:
"""Hosting manages infrastructure where neoFS runs (machines and neoFS services)."""
_hosts: list[Host]
_host_by_address: dict[str, Host]
_host_by_service_name: dict[str, Host]
@property
def hosts(self) -> list[Host]:
"""Returns all hosts registered in the hosting.
Returns:
List of hosts.
"""
return self._hosts
def configure(self, config: dict[str, Any]) -> None:
"""Configures hosts from specified config.
All existing hosts will be removed from the hosting.
Args:
config: Dictionary with hosting configuration.
"""
hosts = []
host_by_address = {}
host_by_service_name = {}
host_configs = [HostConfig(**host_config) for host_config in config["hosts"]]
for host_config in host_configs:
host_class = load_plugin("frostfs.testlib.hosting", host_config.plugin_name)
host = host_class(host_config)
hosts.append(host)
host_by_address[host_config.address] = host
for service_config in host_config.services:
host_by_service_name[service_config.name] = host
self._hosts = hosts
self._host_by_address = host_by_address
self._host_by_service_name = host_by_service_name
def get_host_by_address(self, host_address: str) -> Host:
"""Returns host with specified address.
Args:
host_address: Address of the host.
Returns:
Host that manages machine with specified address.
"""
host = self._host_by_address.get(host_address)
if host is None:
raise ValueError(f"Unknown host address: '{host_address}'")
return host
def get_host_by_service(self, service_name: str) -> Host:
"""Returns host where service with specified name is located.
Args:
service_name: Name of the service.
Returns:
Host that manages machine where service is located.
"""
host = self._host_by_service_name.get(service_name)
if host is None:
raise ValueError(f"Unknown service name: '{service_name}'")
return host
def get_service_config(self, service_name: str) -> ServiceConfig:
"""Returns config of service with specified name.
Args:
service_name: Name of the service.
Returns:
Config of the service.
"""
host = self.get_host_by_service(service_name)
return host.get_service_config(service_name)
def find_service_configs(self, service_name_pattern: str) -> list[ServiceConfig]:
"""Finds configs of services where service name matches specified regular expression.
Args:
service_name_pattern - regular expression for service names.
Returns:
List of service configs matched with the regular expression.
"""
service_configs = [
service_config
for host in self.hosts
for service_config in host.config.services
if re.match(service_name_pattern, service_config.name)
]
return service_configs

View file

@ -0,0 +1,192 @@
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Any, Optional
from frostfs_testlib.hosting.config import CLIConfig, HostConfig, ServiceConfig
from frostfs_testlib.shell.interfaces import Shell
class DiskInfo(dict):
"""Dict wrapper for disk_info for disk management commands."""
class Host(ABC):
"""Interface of a host machine where neoFS services are running.
Allows to manage the machine and neoFS services that are hosted on it.
"""
def __init__(self, config: HostConfig) -> None:
self._config = config
self._service_config_by_name = {
service_config.name: service_config for service_config in config.services
}
self._cli_config_by_name = {cli_config.name: cli_config for cli_config in config.clis}
@property
def config(self) -> HostConfig:
"""Returns config of the host.
Returns:
Config of this host.
"""
return self._config
def get_service_config(self, service_name: str) -> ServiceConfig:
"""Returns config of service with specified name.
The service must be hosted on this host.
Args:
service_name: Name of the service.
Returns:
Config of the service.
"""
service_config = self._service_config_by_name.get(service_name)
if service_config is None:
raise ValueError(f"Unknown service name: '{service_name}'")
return service_config
def get_cli_config(self, cli_name: str) -> CLIConfig:
"""Returns config of CLI tool with specified name.
The CLI must be located on this host.
Args:
cli_name: Name of the CLI tool.
Returns:
Config of the CLI tool.
"""
cli_config = self._cli_config_by_name.get(cli_name)
if cli_config is None:
raise ValueError(f"Unknown CLI name: '{cli_name}'")
return cli_config
@abstractmethod
def get_shell(self) -> Shell:
"""Returns shell to this host.
Returns:
Shell that executes commands on this host.
"""
@abstractmethod
def start_host(self) -> None:
"""Starts the host machine."""
@abstractmethod
def stop_host(self, mode: str) -> None:
"""Stops the host machine.
Args:
mode: Specifies mode how host should be stopped. Mode might be host-specific.
"""
@abstractmethod
def start_service(self, service_name: str) -> None:
"""Starts the service with specified name and waits until it starts.
The service must be hosted on this host.
Args:
service_name: Name of the service to start.
"""
@abstractmethod
def stop_service(self, service_name: str) -> None:
"""Stops the service with specified name and waits until it stops.
The service must be hosted on this host.
Args:
service_name: Name of the service to stop.
"""
@abstractmethod
def restart_service(self, service_name: str) -> None:
"""Restarts the service with specified name and waits until it starts.
The service must be hosted on this host.
Args:
service_name: Name of the service to restart.
"""
@abstractmethod
def delete_storage_node_data(self, service_name: str, cache_only: bool = False) -> None:
"""Erases all data of the storage node with specified name.
Args:
service_name: Name of storage node service.
cache_only: To delete cache only.
"""
@abstractmethod
def detach_disk(self, device: str) -> DiskInfo:
"""Detaches disk device to simulate disk offline/failover scenario.
Args:
device: Device name to detach.
Returns:
internal service disk info related to host plugin (i.e. volume id for cloud devices),
which may be used to identify or re-attach existing volume back.
"""
@abstractmethod
def attach_disk(self, device: str, disk_info: DiskInfo) -> None:
"""Attaches disk device back.
Args:
device: Device name to attach.
service_info: any info required for host plugin to identify/attach disk.
"""
@abstractmethod
def is_disk_attached(self, device: str, disk_info: DiskInfo) -> bool:
"""Checks if disk device is attached.
Args:
device: Device name to check.
service_info: any info required for host plugin to identify disk.
Returns:
True if attached.
False if detached.
"""
@abstractmethod
def dump_logs(
self,
directory_path: str,
since: Optional[datetime] = None,
until: Optional[datetime] = None,
filter_regex: Optional[str] = None,
) -> None:
"""Dumps logs of all services on the host to specified directory.
Args:
directory_path: Path to the directory where logs should be stored.
since: If set, limits the time from which logs should be collected. Must be in UTC.
until: If set, limits the time until which logs should be collected. Must be in UTC.
filter_regex: regex to filter output
"""
@abstractmethod
def is_message_in_logs(
self,
message_regex: str,
since: Optional[datetime] = None,
until: Optional[datetime] = None,
) -> bool:
"""Checks logs on host for specified message regex.
Args:
message_regex: message to find.
since: If set, limits the time from which logs should be collected. Must be in UTC.
until: If set, limits the time until which logs should be collected. Must be in UTC.
Returns:
True if message found in logs in the given time frame.
False otherwise.
"""

View file

@ -0,0 +1,25 @@
import sys
from typing import Any
if sys.version_info < (3, 10):
# On Python prior 3.10 we need to use backport of entry points
from importlib_metadata import entry_points
else:
from importlib.metadata import entry_points
def load_plugin(plugin_group: str, name: str) -> Any:
"""Loads plugin using entry point specification.
Args:
plugin_group: Name of plugin group that contains the plugin.
name: Name of the plugin in the group.
Returns:
Plugin class if the plugin was found; otherwise returns None.
"""
plugins = entry_points(group=plugin_group)
if name not in plugins.names:
return None
plugin = plugins[name]
return plugin.load()

View file

@ -0,0 +1,17 @@
from frostfs_testlib.reporter.allure_handler import AllureHandler
from frostfs_testlib.reporter.interfaces import ReporterHandler
from frostfs_testlib.reporter.reporter import Reporter
__reporter = Reporter()
def get_reporter() -> Reporter:
"""Returns reporter that the library should use for storing artifacts.
Reporter is a singleton instance that can be configured with multiple handlers that store
artifacts in various systems. Most common use case is to use single handler.
Returns:
Singleton reporter instance.
"""
return __reporter

View file

@ -0,0 +1,34 @@
import os
from contextlib import AbstractContextManager
from textwrap import shorten
from typing import Any
import allure
from allure import attachment_type
from frostfs_testlib.reporter.interfaces import ReporterHandler
class AllureHandler(ReporterHandler):
"""Handler that stores test artifacts in Allure report."""
def step(self, name: str) -> AbstractContextManager:
name = shorten(name, width=70, placeholder="...")
return allure.step(name)
def attach(self, body: Any, file_name: str) -> None:
attachment_name, extension = os.path.splitext(file_name)
attachment_type = self._resolve_attachment_type(extension)
allure.attach(body, attachment_name, attachment_type, extension)
def _resolve_attachment_type(self, extension: str) -> attachment_type:
"""Try to find matching Allure attachment type by extension.
If no match was found, default to TXT format.
"""
extension = extension.lower()
return next(
(allure_type for allure_type in attachment_type if allure_type.extension == extension),
attachment_type.TEXT,
)

View file

@ -0,0 +1,28 @@
from abc import ABC, abstractmethod
from contextlib import AbstractContextManager
from typing import Any
class ReporterHandler(ABC):
"""Interface of handler that stores test artifacts in some reporting tool."""
@abstractmethod
def step(self, name: str) -> AbstractContextManager:
"""Register a new step in test execution.
Args:
name: Name of the step.
Returns:
Step context.
"""
@abstractmethod
def attach(self, content: Any, file_name: str) -> None:
"""Attach specified content with given file name to the test report.
Args:
content: Content to attach. If content value is not a string, it will be
converted to a string.
file_name: File name of attachment.
"""

View file

@ -0,0 +1,102 @@
from contextlib import AbstractContextManager, contextmanager
from types import TracebackType
from typing import Any, Optional
from frostfs_testlib.plugins import load_plugin
from frostfs_testlib.reporter.interfaces import ReporterHandler
@contextmanager
def _empty_step():
yield
class Reporter:
"""Root reporter that sends artifacts to handlers."""
handlers: list[ReporterHandler]
def __init__(self) -> None:
super().__init__()
self.handlers = []
def register_handler(self, handler: ReporterHandler) -> None:
"""Register a new handler for the reporter.
Args:
handler: Handler instance to add to the reporter.
"""
self.handlers.append(handler)
def configure(self, config: dict[str, Any]) -> None:
"""Configure handlers in the reporter from specified config.
All existing handlers will be removed from the reporter.
Args:
config: Dictionary with reporter configuration.
"""
# Reset current configuration
self.handlers = []
# Setup handlers from the specified config
handler_configs = config.get("handlers", [])
for handler_config in handler_configs:
handler_class = load_plugin("frostfs.testlib.reporter", handler_config["plugin_name"])
self.register_handler(handler_class())
def step(self, name: str) -> AbstractContextManager:
"""Register a new step in test execution.
Args:
name: Name of the step.
Returns:
Step context.
"""
if not self.handlers:
return _empty_step()
step_contexts = [handler.step(name) for handler in self.handlers]
return AggregateContextManager(step_contexts)
def attach(self, content: Any, file_name: str) -> None:
"""Attach specified content with given file name to the test report.
Args:
content: Content to attach. If content value is not a string, it will be
converted to a string.
file_name: File name of attachment.
"""
for handler in self.handlers:
handler.attach(content, file_name)
class AggregateContextManager(AbstractContextManager):
"""Aggregates multiple context managers in a single context."""
contexts: list[AbstractContextManager]
def __init__(self, contexts: list[AbstractContextManager]) -> None:
super().__init__()
self.contexts = contexts
def __enter__(self):
for context in self.contexts:
context.__enter__()
return self
def __exit__(
self,
exc_type: Optional[type[BaseException]],
exc_value: Optional[BaseException],
traceback: Optional[TracebackType],
) -> Optional[bool]:
suppress_decisions = []
for context in self.contexts:
suppress_decision = context.__exit__(exc_type, exc_value, traceback)
suppress_decisions.append(suppress_decision)
# If all context agreed to suppress exception, then suppress it;
# otherwise return None to reraise
return True if all(suppress_decisions) else None

View file

@ -0,0 +1,3 @@
from frostfs_testlib.shell.interfaces import CommandOptions, CommandResult, InteractiveInput, Shell
from frostfs_testlib.shell.local_shell import LocalShell
from frostfs_testlib.shell.ssh_shell import SSHShell

View file

@ -0,0 +1,13 @@
from frostfs_testlib.shell.interfaces import CommandInspector
class SudoInspector(CommandInspector):
"""Prepends command with sudo.
If command is already prepended with sudo, then has no effect.
"""
def inspect(self, command: str) -> str:
if not command.startswith("sudo"):
return f"sudo {command}"
return command

View file

@ -0,0 +1,93 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional
from neofs_testlib.defaults import Options
@dataclass
class InteractiveInput:
"""Interactive input for a shell command.
Attributes:
prompt_pattern: Regular expression that defines expected prompt from the command.
input: User input that should be supplied to the command in response to the prompt.
"""
prompt_pattern: str
input: str
class CommandInspector(ABC):
"""Interface of inspector that processes command text before execution."""
@abstractmethod
def inspect(self, command: str) -> str:
"""Transforms command text and returns modified command.
Args:
command: Command to transform with this inspector.
Returns:
Transformed command text.
"""
@dataclass
class CommandOptions:
"""Options that control command execution.
Attributes:
interactive_inputs: User inputs that should be interactively supplied to
the command during execution.
close_stdin: Controls whether stdin stream should be closed after feeding interactive
inputs or after requesting non-interactive command. If shell implementation does not
support this functionality, it should ignore this flag without raising an error.
timeout: Timeout for command execution (in seconds).
check: Controls whether to check return code of the command. Set to False to
ignore non-zero return codes.
no_log: Do not print output to logger if True.
"""
interactive_inputs: Optional[list[InteractiveInput]] = None
close_stdin: bool = False
timeout: Optional[int] = None
check: bool = True
no_log: bool = False
def __post_init__(self):
if self.timeout is None:
self.timeout = Options.get_default_shell_timeout()
@dataclass
class CommandResult:
"""Represents a result of a command executed via shell.
Attributes:
stdout: Complete content of stdout stream.
stderr: Complete content of stderr stream.
return_code: Return code (or exit code) of the command's process.
"""
stdout: str
stderr: str
return_code: int
class Shell(ABC):
"""Interface of a command shell on some system (local or remote)."""
@abstractmethod
def exec(self, command: str, options: Optional[CommandOptions] = None) -> CommandResult:
"""Executes specified command on this shell.
To execute interactive command, user inputs should be specified in *options*.
Args:
command: Command to execute on the shell.
options: Options that control command execution.
Returns:
Command's result.
"""

View file

@ -0,0 +1,150 @@
import logging
import subprocess
import tempfile
from datetime import datetime
from typing import IO, Optional
import pexpect
from frostfs_testlib.reporter import get_reporter
from frostfs_testlib.shell.interfaces import CommandInspector, CommandOptions, CommandResult, Shell
logger = logging.getLogger("frostfs.testlib.shell")
reporter = get_reporter()
class LocalShell(Shell):
"""Implements command shell on a local machine."""
def __init__(self, command_inspectors: Optional[list[CommandInspector]] = None) -> None:
super().__init__()
self.command_inspectors = command_inspectors or []
def exec(self, command: str, options: Optional[CommandOptions] = None) -> CommandResult:
# If no options were provided, use default options
options = options or CommandOptions()
for inspector in self.command_inspectors:
command = inspector.inspect(command)
logger.info(f"Executing command: {command}")
if options.interactive_inputs:
return self._exec_interactive(command, options)
return self._exec_non_interactive(command, options)
def _exec_interactive(self, command: str, options: CommandOptions) -> CommandResult:
start_time = datetime.utcnow()
log_file = tempfile.TemporaryFile() # File is reliable cross-platform way to capture output
try:
command_process = pexpect.spawn(command, timeout=options.timeout)
except (pexpect.ExceptionPexpect, OSError) as exc:
raise RuntimeError(f"Command: {command}") from exc
command_process.delaybeforesend = 1
command_process.logfile_read = log_file
try:
for interactive_input in options.interactive_inputs:
command_process.expect(interactive_input.prompt_pattern)
command_process.sendline(interactive_input.input)
except (pexpect.ExceptionPexpect, OSError) as exc:
if options.check:
raise RuntimeError(f"Command: {command}") from exc
finally:
result = self._get_pexpect_process_result(command_process)
log_file.close()
end_time = datetime.utcnow()
self._report_command_result(command, start_time, end_time, result)
if options.check and result.return_code != 0:
raise RuntimeError(
f"Command: {command}\nreturn code: {result.return_code}\n"
f"Output: {result.stdout}"
)
return result
def _exec_non_interactive(self, command: str, options: CommandOptions) -> CommandResult:
start_time = datetime.utcnow()
result = None
try:
command_process = subprocess.run(
command,
check=options.check,
universal_newlines=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=options.timeout,
shell=True,
)
result = CommandResult(
stdout=command_process.stdout or "",
stderr="",
return_code=command_process.returncode,
)
except subprocess.CalledProcessError as exc:
# TODO: always set check flag to false and capture command result normally
result = CommandResult(
stdout=exc.stdout or "",
stderr="",
return_code=exc.returncode,
)
raise RuntimeError(
f"Command: {command}\nError:\n"
f"return code: {exc.returncode}\n"
f"output: {exc.output}"
) from exc
except OSError as exc:
raise RuntimeError(f"Command: {command}\nOutput: {exc.strerror}") from exc
finally:
end_time = datetime.utcnow()
self._report_command_result(command, start_time, end_time, result)
return result
def _get_pexpect_process_result(self, command_process: pexpect.spawn) -> CommandResult:
"""
Captures output of the process.
"""
# Wait for child process to end it's work
if command_process.isalive():
command_process.expect(pexpect.EOF)
# Close the process to obtain the exit code
command_process.close()
return_code = command_process.exitstatus
# Capture output from the log file
log_file: IO[bytes] = command_process.logfile_read
log_file.seek(0)
output = log_file.read().decode()
return CommandResult(stdout=output, stderr="", return_code=return_code)
def _report_command_result(
self,
command: str,
start_time: datetime,
end_time: datetime,
result: Optional[CommandResult],
) -> None:
# TODO: increase logging level if return code is non 0, should be warning at least
logger.info(
f"Command: {command}\n"
f"{'Success:' if result and result.return_code == 0 else 'Error:'}\n"
f"return code: {result.return_code if result else ''} "
f"\nOutput: {result.stdout if result else ''}"
)
if result:
elapsed_time = end_time - start_time
command_attachment = (
f"COMMAND: {command}\n"
f"RETCODE: {result.return_code}\n\n"
f"STDOUT:\n{result.stdout}\n"
f"STDERR:\n{result.stderr}\n"
f"Start / End / Elapsed\t {start_time.time()} / {end_time.time()} / {elapsed_time}"
)
with reporter.step(f"COMMAND: {command}"):
reporter.attach(command_attachment, "Command execution.txt")

View file

@ -0,0 +1,304 @@
import logging
import socket
import textwrap
from datetime import datetime
from functools import lru_cache, wraps
from time import sleep
from typing import ClassVar, Optional, Tuple
from paramiko import (
AutoAddPolicy,
Channel,
ECDSAKey,
Ed25519Key,
PKey,
RSAKey,
SSHClient,
SSHException,
ssh_exception,
)
from paramiko.ssh_exception import AuthenticationException
from frostfs_testlib.reporter import get_reporter
from frostfs_testlib.shell.interfaces import CommandInspector, CommandOptions, CommandResult, Shell
logger = logging.getLogger("frostfs.testlib.shell")
reporter = get_reporter()
class HostIsNotAvailable(Exception):
"""Raised when host is not reachable via SSH connection."""
def __init__(self, host: str = None):
msg = f"Host {host} is not available"
super().__init__(msg)
def log_command(func):
@wraps(func)
def wrapper(
shell: "SSHShell", command: str, options: CommandOptions, *args, **kwargs
) -> CommandResult:
command_info = command.removeprefix("$ProgressPreference='SilentlyContinue'\n")
with reporter.step(command_info):
logger.info(f'Execute command "{command}" on "{shell.host}"')
start_time = datetime.utcnow()
result = func(shell, command, options, *args, **kwargs)
end_time = datetime.utcnow()
elapsed_time = end_time - start_time
log_message = (
f"HOST: {shell.host}\n"
f"COMMAND:\n{textwrap.indent(command, ' ')}\n"
f"RC:\n {result.return_code}\n"
f"STDOUT:\n{textwrap.indent(result.stdout, ' ')}\n"
f"STDERR:\n{textwrap.indent(result.stderr, ' ')}\n"
f"Start / End / Elapsed\t {start_time.time()} / {end_time.time()} / {elapsed_time}"
)
if not options.no_log:
logger.info(log_message)
reporter.attach(log_message, "SSH command.txt")
return result
return wrapper
@lru_cache
def _load_private_key(file_path: str, password: Optional[str]) -> PKey:
"""Loads private key from specified file.
We support several type formats, however paramiko doesn't provide functionality to determine
key type in advance. So we attempt to load file with each of the supported formats and then
cache the result so that we don't need to figure out type again on subsequent calls.
"""
logger.debug(f"Loading ssh key from {file_path}")
for key_type in (Ed25519Key, ECDSAKey, RSAKey):
try:
return key_type.from_private_key_file(file_path, password)
except SSHException as ex:
logger.warn(f"SSH key {file_path} can't be loaded with {key_type}: {ex}")
continue
raise SSHException(f"SSH key {file_path} is not supported")
class SSHShell(Shell):
"""Implements command shell on a remote machine via SSH connection."""
# Time in seconds to delay after remote command has completed. The delay is required
# to allow remote command to flush its output buffer
DELAY_AFTER_EXIT = 0.2
SSH_CONNECTION_ATTEMPTS: ClassVar[int] = 3
CONNECTION_TIMEOUT = 90
def __init__(
self,
host: str,
login: str,
password: Optional[str] = None,
private_key_path: Optional[str] = None,
private_key_passphrase: Optional[str] = None,
port: str = "22",
command_inspectors: Optional[list[CommandInspector]] = None,
) -> None:
super().__init__()
self.host = host
self.port = port
self.login = login
self.password = password
self.private_key_path = private_key_path
self.private_key_passphrase = private_key_passphrase
self.command_inspectors = command_inspectors or []
self.__connection: Optional[SSHClient] = None
@property
def _connection(self):
if not self.__connection:
self.__connection = self._create_connection()
return self.__connection
def drop(self):
self._reset_connection()
def exec(self, command: str, options: Optional[CommandOptions] = None) -> CommandResult:
options = options or CommandOptions()
for inspector in self.command_inspectors:
command = inspector.inspect(command)
if options.interactive_inputs:
result = self._exec_interactive(command, options)
else:
result = self._exec_non_interactive(command, options)
if options.check and result.return_code != 0:
raise RuntimeError(
f"Command: {command}\nreturn code: {result.return_code}\nOutput: {result.stdout}"
)
return result
@log_command
def _exec_interactive(self, command: str, options: CommandOptions) -> CommandResult:
stdin, stdout, stderr = self._connection.exec_command(
command, timeout=options.timeout, get_pty=True
)
for interactive_input in options.interactive_inputs:
input = interactive_input.input
if not input.endswith("\n"):
input = f"{input}\n"
try:
stdin.write(input)
except OSError:
logger.exception(f"Error while feeding {input} into command {command}")
if options.close_stdin:
stdin.close()
sleep(self.DELAY_AFTER_EXIT)
decoded_stdout, decoded_stderr = self._read_channels(stdout.channel, stderr.channel)
return_code = stdout.channel.recv_exit_status()
result = CommandResult(
stdout=decoded_stdout,
stderr=decoded_stderr,
return_code=return_code,
)
return result
@log_command
def _exec_non_interactive(self, command: str, options: CommandOptions) -> CommandResult:
try:
stdin, stdout, stderr = self._connection.exec_command(command, timeout=options.timeout)
if options.close_stdin:
stdin.close()
decoded_stdout, decoded_stderr = self._read_channels(stdout.channel, stderr.channel)
return_code = stdout.channel.recv_exit_status()
return CommandResult(
stdout=decoded_stdout,
stderr=decoded_stderr,
return_code=return_code,
)
except (
SSHException,
TimeoutError,
ssh_exception.NoValidConnectionsError,
ConnectionResetError,
AttributeError,
socket.timeout,
) as exc:
logger.exception(f"Can't execute command {command} on host: {self.host}")
self._reset_connection()
raise HostIsNotAvailable(self.host) from exc
def _read_channels(
self,
stdout: Channel,
stderr: Channel,
chunk_size: int = 4096,
) -> Tuple[str, str]:
"""Reads data from stdout/stderr channels.
Reading channels is required before we wait for exit status of the remote process.
Otherwise waiting step will hang indefinitely, see the warning from paramiko docs:
# https://docs.paramiko.org/en/stable/api/channel.html#paramiko.channel.Channel.recv_exit_status
Args:
stdout: Channel of stdout stream of the remote process.
stderr: Channel of stderr stream of the remote process.
chunk_size: Max size of data chunk that we read from channel at a time.
Returns:
Tuple with stdout and stderr channels decoded into strings.
"""
# We read data in chunks
stdout_chunks = []
stderr_chunks = []
# Read from channels (if data is ready) until process exits
while not stdout.exit_status_ready():
if stdout.recv_ready():
stdout_chunks.append(stdout.recv(chunk_size))
if stderr.recv_stderr_ready():
stderr_chunks.append(stderr.recv_stderr(chunk_size))
# Wait for command to complete and flush its buffer before we read final output
sleep(self.DELAY_AFTER_EXIT)
# Read the remaining data from the channels:
# If channel returns empty data chunk, it means that all data has been read
while True:
data_chunk = stdout.recv(chunk_size)
if not data_chunk:
break
stdout_chunks.append(data_chunk)
while True:
data_chunk = stderr.recv_stderr(chunk_size)
if not data_chunk:
break
stderr_chunks.append(data_chunk)
# Combine chunks and decode results into regular strings
full_stdout = b"".join(stdout_chunks)
full_stderr = b"".join(stderr_chunks)
return (full_stdout.decode(errors="ignore"), full_stderr.decode(errors="ignore"))
def _create_connection(self, attempts: int = SSH_CONNECTION_ATTEMPTS) -> SSHClient:
for attempt in range(attempts):
connection = SSHClient()
connection.set_missing_host_key_policy(AutoAddPolicy())
try:
if self.private_key_path:
logger.info(
f"Trying to connect to host {self.host} as {self.login} using SSH key "
f"{self.private_key_path} (attempt {attempt})"
)
connection.connect(
hostname=self.host,
port=self.port,
username=self.login,
pkey=_load_private_key(self.private_key_path, self.private_key_passphrase),
timeout=self.CONNECTION_TIMEOUT,
)
else:
logger.info(
f"Trying to connect to host {self.host} as {self.login} using password "
f"(attempt {attempt})"
)
connection.connect(
hostname=self.host,
port=self.port,
username=self.login,
password=self.password,
timeout=self.CONNECTION_TIMEOUT,
)
return connection
except AuthenticationException:
connection.close()
logger.exception(f"Can't connect to host {self.host}")
raise
except (
SSHException,
ssh_exception.NoValidConnectionsError,
AttributeError,
socket.timeout,
OSError,
) as exc:
connection.close()
can_retry = attempt + 1 < attempts
if can_retry:
logger.warn(f"Can't connect to host {self.host}, will retry. Error: {exc}")
continue
logger.exception(f"Can't connect to host {self.host}")
raise HostIsNotAvailable(self.host) from exc
def _reset_connection(self) -> None:
if self.__connection:
self.__connection.close()
self.__connection = None

View file

View file

@ -0,0 +1,69 @@
import base64
import binascii
import json
import base58
from neo3.wallet import wallet as neo3_wallet
def str_to_ascii_hex(input: str) -> str:
b = binascii.hexlify(input.encode())
return str(b)[2:-1]
def ascii_hex_to_str(input: str) -> bytes:
return bytes.fromhex(input)
# Two functions below do parsing of Base64-encoded byte arrays which
# tests receive from Neo node RPC calls.
def process_b64_bytearray_reverse(data: str) -> bytes:
"""
This function decodes input data from base64, reverses the byte
array and returns its string representation.
"""
arr = bytearray(base64.standard_b64decode(data))
arr.reverse()
return binascii.b2a_hex(arr)
def process_b64_bytearray(data: str) -> bytes:
"""
This function decodes input data from base64 and returns the
bytearray string representation.
"""
arr = bytearray(base64.standard_b64decode(data))
return binascii.b2a_hex(arr)
def contract_hash_to_address(chash: str) -> str:
"""
This function accepts contract hash in BE, then translates in to LE,
prepends NEO wallet prefix and encodes to base58. It is equal to
`UInt160ToString` method in NEO implementations.
"""
be = bytearray(bytes.fromhex(chash))
be.reverse()
return base58.b58encode_check(b"\x35" + bytes(be)).decode()
def get_contract_hash_from_manifest(manifest_path: str) -> str:
with open(manifest_path) as m:
data = json.load(m)
# cut off '0x' and return the hash
return data["abi"]["hash"][2:]
def get_wif_from_private_key(priv_key: bytes) -> str:
wif_version = b"\x80"
compressed_flag = b"\x01"
wif = base58.b58encode_check(wif_version + priv_key + compressed_flag)
return wif.decode("utf-8")
def load_wallet(path: str, passwd: str = "") -> neo3_wallet.Wallet:
with open(path, "r") as wallet_file:
wlt_data = wallet_file.read()
return neo3_wallet.Wallet.from_json(json.loads(wlt_data), password=passwd)

View file

@ -0,0 +1,38 @@
import json
import logging
from neo3.wallet import wallet as neo3_wallet
from neo3.wallet import account as neo3_account
logger = logging.getLogger("frostfs.testlib.utils")
def init_wallet(wallet_path: str, wallet_password: str):
"""
Create new wallet and new account.
Args:
wallet_path: The path to the wallet to save wallet.
wallet_password: The password for new wallet.
"""
wallet = neo3_wallet.Wallet()
account = neo3_account.Account.create_new(wallet_password)
wallet.account_add(account)
with open(wallet_path, "w") as out:
json.dump(wallet.to_json(), out)
logger.info(f"Init new wallet: {wallet_path}, address: {account.address}")
def get_last_address_from_wallet(wallet_path: str, wallet_password: str):
"""
Extracting the last address from the given wallet.
Args:
wallet_path: The path to the wallet to extract address from.
wallet_password: The password for the given wallet.
Returns:
The address for the wallet.
"""
with open(wallet_path) as wallet_file:
wallet = neo3_wallet.Wallet.from_json(json.load(wallet_file), password=wallet_password)
address = wallet.accounts[-1].address
logger.info(f"got address: {address}")
return address