forked from TrueCloudLab/frostfs-testlib
Rename neofs to frostfs
Signed-off-by: Yulia Kovshova <y.kovshova@yadro.com>
This commit is contained in:
parent
5a2c7ac98d
commit
6d3b6f0f2f
83 changed files with 330 additions and 338 deletions
1
src/frostfs_testlib/__init__.py
Normal file
1
src/frostfs_testlib/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
__version__ = "1.1.1"
|
2
src/frostfs_testlib/blockchain/__init__.py
Normal file
2
src/frostfs_testlib/blockchain/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
from frostfs_testlib.blockchain.multisig import Multisig
|
||||
from frostfs_testlib.blockchain.rpc_client import RPCClient
|
51
src/frostfs_testlib/blockchain/multisig.py
Normal file
51
src/frostfs_testlib/blockchain/multisig.py
Normal 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,
|
||||
)
|
156
src/frostfs_testlib/blockchain/role_designation.py
Normal file
156
src/frostfs_testlib/blockchain/role_designation.py
Normal 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]
|
80
src/frostfs_testlib/blockchain/rpc_client.py
Normal file
80
src/frostfs_testlib/blockchain/rpc_client.py
Normal 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("'", '"')
|
4
src/frostfs_testlib/cli/__init__.py
Normal file
4
src/frostfs_testlib/cli/__init__.py
Normal 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
|
74
src/frostfs_testlib/cli/cli_command.py
Normal file
74
src/frostfs_testlib/cli/cli_command.py
Normal 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)]
|
||||
),
|
||||
)
|
1
src/frostfs_testlib/cli/frostfs_adm/__init__.py
Normal file
1
src/frostfs_testlib/cli/frostfs_adm/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from frostfs_testlib.cli.frostfs_adm.adm import FrostfsAdm
|
22
src/frostfs_testlib/cli/frostfs_adm/adm.py
Normal file
22
src/frostfs_testlib/cli/frostfs_adm/adm.py
Normal 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)
|
22
src/frostfs_testlib/cli/frostfs_adm/config.py
Normal file
22
src/frostfs_testlib/cli/frostfs_adm/config.py
Normal 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"]
|
||||
},
|
||||
)
|
356
src/frostfs_testlib/cli/frostfs_adm/morph.py
Normal file
356
src/frostfs_testlib/cli/frostfs_adm/morph.py
Normal 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"]
|
||||
},
|
||||
)
|
23
src/frostfs_testlib/cli/frostfs_adm/storage_config.py
Normal file
23
src/frostfs_testlib/cli/frostfs_adm/storage_config.py
Normal 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"]
|
||||
},
|
||||
)
|
239
src/frostfs_testlib/cli/frostfs_adm/subnet.py
Normal file
239
src/frostfs_testlib/cli/frostfs_adm/subnet.py
Normal 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"]
|
||||
},
|
||||
)
|
12
src/frostfs_testlib/cli/frostfs_adm/version.py
Normal file
12
src/frostfs_testlib/cli/frostfs_adm/version.py
Normal 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)
|
1
src/frostfs_testlib/cli/frostfs_authmate/__init__.py
Normal file
1
src/frostfs_testlib/cli/frostfs_authmate/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from frostfs_testlib.cli.frostfs_authmate.authmate import FrostfsAuthmate
|
14
src/frostfs_testlib/cli/frostfs_authmate/authmate.py
Normal file
14
src/frostfs_testlib/cli/frostfs_authmate/authmate.py
Normal 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)
|
91
src/frostfs_testlib/cli/frostfs_authmate/secret.py
Normal file
91
src/frostfs_testlib/cli/frostfs_authmate/secret.py
Normal 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"]
|
||||
},
|
||||
)
|
12
src/frostfs_testlib/cli/frostfs_authmate/version.py
Normal file
12
src/frostfs_testlib/cli/frostfs_authmate/version.py
Normal 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)
|
1
src/frostfs_testlib/cli/frostfs_cli/__init__.py
Normal file
1
src/frostfs_testlib/cli/frostfs_cli/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from frostfs_testlib.cli.frostfs_cli.cli import FrostfsCli
|
30
src/frostfs_testlib/cli/frostfs_cli/accounting.py
Normal file
30
src/frostfs_testlib/cli/frostfs_cli/accounting.py
Normal 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"]},
|
||||
)
|
52
src/frostfs_testlib/cli/frostfs_cli/acl.py
Normal file
52
src/frostfs_testlib/cli/frostfs_cli/acl.py
Normal 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"]},
|
||||
)
|
38
src/frostfs_testlib/cli/frostfs_cli/cli.py
Normal file
38
src/frostfs_testlib/cli/frostfs_cli/cli.py
Normal 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)
|
264
src/frostfs_testlib/cli/frostfs_cli/container.py
Normal file
264
src/frostfs_testlib/cli/frostfs_cli/container.py
Normal 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"]},
|
||||
)
|
120
src/frostfs_testlib/cli/frostfs_cli/netmap.py
Normal file
120
src/frostfs_testlib/cli/frostfs_cli/netmap.py
Normal 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"]},
|
||||
)
|
351
src/frostfs_testlib/cli/frostfs_cli/object.py
Normal file
351
src/frostfs_testlib/cli/frostfs_cli/object.py
Normal 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"]},
|
||||
)
|
41
src/frostfs_testlib/cli/frostfs_cli/session.py
Normal file
41
src/frostfs_testlib/cli/frostfs_cli/session.py
Normal 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"]
|
||||
},
|
||||
)
|
138
src/frostfs_testlib/cli/frostfs_cli/shards.py
Normal file
138
src/frostfs_testlib/cli/frostfs_cli/shards.py
Normal 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"]
|
||||
},
|
||||
)
|
147
src/frostfs_testlib/cli/frostfs_cli/storagegroup.py
Normal file
147
src/frostfs_testlib/cli/frostfs_cli/storagegroup.py
Normal 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"]},
|
||||
)
|
56
src/frostfs_testlib/cli/frostfs_cli/util.py
Normal file
56
src/frostfs_testlib/cli/frostfs_cli/util.py
Normal 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"]},
|
||||
)
|
13
src/frostfs_testlib/cli/frostfs_cli/version.py
Normal file
13
src/frostfs_testlib/cli/frostfs_cli/version.py
Normal 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)
|
2
src/frostfs_testlib/cli/neogo/__init__.py
Normal file
2
src/frostfs_testlib/cli/neogo/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
from frostfs_testlib.cli.neogo.go import NeoGo
|
||||
from frostfs_testlib.cli.neogo.network_type import NetworkType
|
134
src/frostfs_testlib/cli/neogo/candidate.py
Normal file
134
src/frostfs_testlib/cli/neogo/candidate.py
Normal 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)
|
398
src/frostfs_testlib/cli/neogo/contract.py
Normal file
398
src/frostfs_testlib/cli/neogo/contract.py
Normal 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)
|
69
src/frostfs_testlib/cli/neogo/db.py
Normal file
69
src/frostfs_testlib/cli/neogo/db.py
Normal 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"]
|
||||
},
|
||||
)
|
37
src/frostfs_testlib/cli/neogo/go.py
Normal file
37
src/frostfs_testlib/cli/neogo/go.py
Normal 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)
|
240
src/frostfs_testlib/cli/neogo/nep17.py
Normal file
240
src/frostfs_testlib/cli/neogo/nep17.py
Normal 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,
|
||||
)
|
7
src/frostfs_testlib/cli/neogo/network_type.py
Normal file
7
src/frostfs_testlib/cli/neogo/network_type.py
Normal file
|
@ -0,0 +1,7 @@
|
|||
from enum import Enum
|
||||
|
||||
|
||||
class NetworkType(Enum):
|
||||
PRIVATE = "privnet"
|
||||
MAIN = "mainnet"
|
||||
TEST = "testnet"
|
16
src/frostfs_testlib/cli/neogo/node.py
Normal file
16
src/frostfs_testlib/cli/neogo/node.py
Normal 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})
|
100
src/frostfs_testlib/cli/neogo/query.py
Normal file
100
src/frostfs_testlib/cli/neogo/query.py
Normal 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"]
|
||||
},
|
||||
)
|
12
src/frostfs_testlib/cli/neogo/version.py
Normal file
12
src/frostfs_testlib/cli/neogo/version.py
Normal 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)
|
381
src/frostfs_testlib/cli/neogo/wallet.py
Normal file
381
src/frostfs_testlib/cli/neogo/wallet.py
Normal 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)
|
3
src/frostfs_testlib/hosting/__init__.py
Normal file
3
src/frostfs_testlib/hosting/__init__.py
Normal 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
|
70
src/frostfs_testlib/hosting/config.py
Normal file
70
src/frostfs_testlib/hosting/config.py
Normal 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 []]
|
238
src/frostfs_testlib/hosting/docker_host.py
Normal file
238
src/frostfs_testlib/hosting/docker_host.py
Normal 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.")
|
107
src/frostfs_testlib/hosting/hosting.py
Normal file
107
src/frostfs_testlib/hosting/hosting.py
Normal 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
|
192
src/frostfs_testlib/hosting/interfaces.py
Normal file
192
src/frostfs_testlib/hosting/interfaces.py
Normal 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.
|
||||
"""
|
25
src/frostfs_testlib/plugins/__init__.py
Normal file
25
src/frostfs_testlib/plugins/__init__.py
Normal 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()
|
17
src/frostfs_testlib/reporter/__init__.py
Normal file
17
src/frostfs_testlib/reporter/__init__.py
Normal 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
|
34
src/frostfs_testlib/reporter/allure_handler.py
Normal file
34
src/frostfs_testlib/reporter/allure_handler.py
Normal 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,
|
||||
)
|
28
src/frostfs_testlib/reporter/interfaces.py
Normal file
28
src/frostfs_testlib/reporter/interfaces.py
Normal 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.
|
||||
"""
|
102
src/frostfs_testlib/reporter/reporter.py
Normal file
102
src/frostfs_testlib/reporter/reporter.py
Normal 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
|
3
src/frostfs_testlib/shell/__init__.py
Normal file
3
src/frostfs_testlib/shell/__init__.py
Normal 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
|
13
src/frostfs_testlib/shell/command_inspectors.py
Normal file
13
src/frostfs_testlib/shell/command_inspectors.py
Normal 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
|
93
src/frostfs_testlib/shell/interfaces.py
Normal file
93
src/frostfs_testlib/shell/interfaces.py
Normal 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.
|
||||
"""
|
150
src/frostfs_testlib/shell/local_shell.py
Normal file
150
src/frostfs_testlib/shell/local_shell.py
Normal 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")
|
304
src/frostfs_testlib/shell/ssh_shell.py
Normal file
304
src/frostfs_testlib/shell/ssh_shell.py
Normal 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
|
0
src/frostfs_testlib/utils/__init__.py
Normal file
0
src/frostfs_testlib/utils/__init__.py
Normal file
69
src/frostfs_testlib/utils/converters.py
Normal file
69
src/frostfs_testlib/utils/converters.py
Normal 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)
|
38
src/frostfs_testlib/utils/wallet.py
Normal file
38
src/frostfs_testlib/utils/wallet.py
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue