olas-operate-middleware 0.10.20__py3-none-any.whl → 0.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {olas_operate_middleware-0.10.20.dist-info → olas_operate_middleware-0.11.0.dist-info}/METADATA +3 -1
- {olas_operate_middleware-0.10.20.dist-info → olas_operate_middleware-0.11.0.dist-info}/RECORD +25 -22
- operate/bridge/bridge_manager.py +10 -12
- operate/bridge/providers/native_bridge_provider.py +1 -1
- operate/bridge/providers/provider.py +21 -34
- operate/cli.py +438 -108
- operate/constants.py +20 -0
- operate/ledger/__init__.py +55 -5
- operate/ledger/profiles.py +79 -11
- operate/operate_types.py +205 -2
- operate/quickstart/run_service.py +1 -1
- operate/services/agent_runner.py +5 -3
- operate/services/deployment_runner.py +3 -0
- operate/services/funding_manager.py +904 -0
- operate/services/manage.py +165 -306
- operate/services/protocol.py +392 -140
- operate/services/service.py +81 -5
- operate/settings.py +70 -0
- operate/utils/gnosis.py +79 -24
- operate/utils/single_instance.py +226 -0
- operate/wallet/master.py +214 -177
- operate/wallet/wallet_recovery_manager.py +5 -5
- {olas_operate_middleware-0.10.20.dist-info → olas_operate_middleware-0.11.0.dist-info}/WHEEL +0 -0
- {olas_operate_middleware-0.10.20.dist-info → olas_operate_middleware-0.11.0.dist-info}/entry_points.txt +0 -0
- {olas_operate_middleware-0.10.20.dist-info → olas_operate_middleware-0.11.0.dist-info}/licenses/LICENSE +0 -0
operate/services/service.py
CHANGED
|
@@ -35,6 +35,7 @@ from json import JSONDecodeError
|
|
|
35
35
|
from pathlib import Path
|
|
36
36
|
from traceback import print_exc
|
|
37
37
|
|
|
38
|
+
import requests
|
|
38
39
|
from aea.configurations.constants import (
|
|
39
40
|
DEFAULT_LEDGER,
|
|
40
41
|
LEDGER,
|
|
@@ -42,6 +43,7 @@ from aea.configurations.constants import (
|
|
|
42
43
|
PRIVATE_KEY_PATH_SCHEMA,
|
|
43
44
|
SKILL,
|
|
44
45
|
)
|
|
46
|
+
from aea.helpers.logging import setup_logger
|
|
45
47
|
from aea.helpers.yaml_utils import yaml_dump, yaml_load, yaml_load_all
|
|
46
48
|
from aea_cli_ipfs.ipfs_utils import IPFSTool
|
|
47
49
|
from autonomy.cli.helpers.deployment import run_deployment, stop_deployment
|
|
@@ -64,16 +66,21 @@ from autonomy.deploy.generators.kubernetes.base import KubernetesGenerator
|
|
|
64
66
|
from docker import from_env
|
|
65
67
|
|
|
66
68
|
from operate.constants import (
|
|
69
|
+
AGENT_FUNDS_STATUS_URL,
|
|
67
70
|
AGENT_PERSISTENT_STORAGE_ENV_VAR,
|
|
68
71
|
CONFIG_JSON,
|
|
69
72
|
DEPLOYMENT_DIR,
|
|
70
73
|
DEPLOYMENT_JSON,
|
|
71
74
|
HEALTHCHECK_JSON,
|
|
75
|
+
SERVICE_SAFE_PLACEHOLDER,
|
|
76
|
+
ZERO_ADDRESS,
|
|
72
77
|
)
|
|
73
78
|
from operate.keys import KeysManager
|
|
79
|
+
from operate.ledger import get_default_rpc
|
|
74
80
|
from operate.operate_http.exceptions import NotAllowed
|
|
75
81
|
from operate.operate_types import (
|
|
76
82
|
Chain,
|
|
83
|
+
ChainAmounts,
|
|
77
84
|
ChainConfig,
|
|
78
85
|
ChainConfigs,
|
|
79
86
|
DeployedNodes,
|
|
@@ -106,6 +113,8 @@ NON_EXISTENT_TOKEN = -1
|
|
|
106
113
|
|
|
107
114
|
AGENT_TYPE_IDS = {"mech": 37, "optimus": 40, "modius": 40, "trader": 25}
|
|
108
115
|
|
|
116
|
+
logger = setup_logger("operate.services.service")
|
|
117
|
+
|
|
109
118
|
|
|
110
119
|
def mkdirs(build_dir: Path) -> None:
|
|
111
120
|
"""Build necessary directories."""
|
|
@@ -825,11 +834,12 @@ class Service(LocalResource):
|
|
|
825
834
|
)
|
|
826
835
|
)
|
|
827
836
|
|
|
828
|
-
ledger_configs = ServiceHelper(path=package_absolute_path).ledger_configs()
|
|
829
|
-
|
|
830
837
|
chain_configs = {}
|
|
831
|
-
for
|
|
832
|
-
|
|
838
|
+
for chain_str, config in service_template["configurations"].items():
|
|
839
|
+
chain = Chain(chain_str)
|
|
840
|
+
ledger_config = LedgerConfig(
|
|
841
|
+
rpc=get_default_rpc(Chain(chain_str)), chain=chain
|
|
842
|
+
)
|
|
833
843
|
ledger_config.rpc = config["rpc"]
|
|
834
844
|
|
|
835
845
|
chain_data = OnChainData(
|
|
@@ -839,7 +849,7 @@ class Service(LocalResource):
|
|
|
839
849
|
user_params=OnChainUserParams.from_json(config), # type: ignore
|
|
840
850
|
)
|
|
841
851
|
|
|
842
|
-
chain_configs[
|
|
852
|
+
chain_configs[chain_str] = ChainConfig(
|
|
843
853
|
ledger_config=ledger_config,
|
|
844
854
|
chain_data=chain_data,
|
|
845
855
|
)
|
|
@@ -1118,3 +1128,69 @@ class Service(LocalResource):
|
|
|
1118
1128
|
|
|
1119
1129
|
if updated:
|
|
1120
1130
|
self.store()
|
|
1131
|
+
|
|
1132
|
+
def get_initial_funding_amounts(self) -> ChainAmounts:
|
|
1133
|
+
"""Get funding amounts as a dict structure."""
|
|
1134
|
+
amounts = ChainAmounts()
|
|
1135
|
+
|
|
1136
|
+
for chain_str, chain_config in self.chain_configs.items():
|
|
1137
|
+
fund_requirements = chain_config.chain_data.user_params.fund_requirements
|
|
1138
|
+
service_safe = chain_config.chain_data.multisig
|
|
1139
|
+
|
|
1140
|
+
if service_safe is None or service_safe == ZERO_ADDRESS:
|
|
1141
|
+
service_safe = SERVICE_SAFE_PLACEHOLDER
|
|
1142
|
+
|
|
1143
|
+
chain_amounts = amounts.setdefault(chain_str, {})
|
|
1144
|
+
for asset, req in fund_requirements.items():
|
|
1145
|
+
chain_amounts.setdefault(service_safe, {})[asset] = req.safe
|
|
1146
|
+
for agent_address in self.agent_addresses:
|
|
1147
|
+
chain_amounts.setdefault(agent_address, {})[asset] = req.agent
|
|
1148
|
+
|
|
1149
|
+
return amounts
|
|
1150
|
+
|
|
1151
|
+
def get_funding_requests(self) -> ChainAmounts:
|
|
1152
|
+
"""Get funding amounts requested by the agent."""
|
|
1153
|
+
agent_response = {}
|
|
1154
|
+
funding_requests = ChainAmounts()
|
|
1155
|
+
|
|
1156
|
+
if self.deployment.status != DeploymentStatus.DEPLOYED:
|
|
1157
|
+
return funding_requests
|
|
1158
|
+
|
|
1159
|
+
try:
|
|
1160
|
+
resp = requests.get(AGENT_FUNDS_STATUS_URL, timeout=10)
|
|
1161
|
+
resp.raise_for_status()
|
|
1162
|
+
agent_response = resp.json()
|
|
1163
|
+
except Exception as e: # pylint: disable=broad-except
|
|
1164
|
+
logger.warning(
|
|
1165
|
+
f"[FUNDING MANAGER] Cannot read url {AGENT_FUNDS_STATUS_URL}: {e}"
|
|
1166
|
+
)
|
|
1167
|
+
|
|
1168
|
+
for chain_str, addresses in agent_response.items():
|
|
1169
|
+
for address, assets in addresses.items():
|
|
1170
|
+
if chain_str not in self.chain_configs:
|
|
1171
|
+
raise ValueError(
|
|
1172
|
+
f"Service {self.service_config_id} asked funding for an unknown chain {chain_str}."
|
|
1173
|
+
)
|
|
1174
|
+
|
|
1175
|
+
if (
|
|
1176
|
+
address not in self.agent_addresses
|
|
1177
|
+
and address != self.chain_configs[chain_str].chain_data.multisig
|
|
1178
|
+
):
|
|
1179
|
+
raise ValueError(
|
|
1180
|
+
f"Service {self.service_config_id} asked funding for an unknown address {address} on chain {chain_str}."
|
|
1181
|
+
)
|
|
1182
|
+
|
|
1183
|
+
funding_requests.setdefault(chain_str, {})
|
|
1184
|
+
funding_requests[chain_str].setdefault(address, {})
|
|
1185
|
+
for asset, amounts in assets.items():
|
|
1186
|
+
try:
|
|
1187
|
+
funding_requests[chain_str][address][asset] = int(
|
|
1188
|
+
amounts["deficit"]
|
|
1189
|
+
)
|
|
1190
|
+
except (ValueError, TypeError):
|
|
1191
|
+
logger.warning(
|
|
1192
|
+
f"[FUNDING MANAGER] Invalid funding amount {amounts['deficit']} for asset {asset} on chain {chain_str} for address {address}. Setting to 0."
|
|
1193
|
+
)
|
|
1194
|
+
funding_requests[chain_str][address][asset] = 0
|
|
1195
|
+
|
|
1196
|
+
return funding_requests
|
operate/settings.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# ------------------------------------------------------------------------------
|
|
3
|
+
#
|
|
4
|
+
# Copyright 2024 Valory AG
|
|
5
|
+
#
|
|
6
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
# you may not use this file except in compliance with the License.
|
|
8
|
+
# You may obtain a copy of the License at
|
|
9
|
+
#
|
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
#
|
|
12
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
# See the License for the specific language governing permissions and
|
|
16
|
+
# limitations under the License.
|
|
17
|
+
#
|
|
18
|
+
# ------------------------------------------------------------------------------
|
|
19
|
+
"""Settings for operate."""
|
|
20
|
+
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Dict, Optional
|
|
23
|
+
|
|
24
|
+
from operate.constants import SETTINGS_JSON
|
|
25
|
+
from operate.ledger.profiles import DEFAULT_EOA_TOPUPS
|
|
26
|
+
from operate.operate_types import ChainAmounts
|
|
27
|
+
from operate.resource import LocalResource
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
SETTINGS_JSON_VERSION = 1
|
|
31
|
+
DEFAULT_SETTINGS = {
|
|
32
|
+
"version": SETTINGS_JSON_VERSION,
|
|
33
|
+
"eoa_topups": DEFAULT_EOA_TOPUPS,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Settings(LocalResource):
|
|
38
|
+
"""Settings for operate."""
|
|
39
|
+
|
|
40
|
+
_file = SETTINGS_JSON
|
|
41
|
+
|
|
42
|
+
version: int
|
|
43
|
+
eoa_topups: Dict[str, Dict[str, int]]
|
|
44
|
+
|
|
45
|
+
def __init__(self, path: Optional[Path] = None, **kwargs: Any) -> None:
|
|
46
|
+
"""Initialize settings."""
|
|
47
|
+
super().__init__(path=path)
|
|
48
|
+
if path is not None and (path / self._file).exists():
|
|
49
|
+
self.load(path)
|
|
50
|
+
|
|
51
|
+
for key, default_value in DEFAULT_SETTINGS.items():
|
|
52
|
+
value = kwargs.get(key, default_value)
|
|
53
|
+
if not hasattr(self, key):
|
|
54
|
+
setattr(self, key, value)
|
|
55
|
+
|
|
56
|
+
if self.version != SETTINGS_JSON_VERSION:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"Settings version {self.version} is not supported. Expected version {SETTINGS_JSON_VERSION}."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def get_eoa_topups(self, with_safe: bool = False) -> ChainAmounts:
|
|
62
|
+
"""Get the EOA topups."""
|
|
63
|
+
return (
|
|
64
|
+
self.eoa_topups
|
|
65
|
+
if with_safe
|
|
66
|
+
else {
|
|
67
|
+
chain: {asset: amount * 2 for asset, amount in asset_amount.items()}
|
|
68
|
+
for chain, asset_amount in self.eoa_topups.items()
|
|
69
|
+
}
|
|
70
|
+
)
|
operate/utils/gnosis.py
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import binascii
|
|
23
23
|
import itertools
|
|
24
24
|
import secrets
|
|
25
|
+
import time
|
|
25
26
|
import typing as t
|
|
26
27
|
from enum import Enum
|
|
27
28
|
|
|
@@ -39,6 +40,7 @@ from operate.constants import (
|
|
|
39
40
|
ON_CHAIN_INTERACT_TIMEOUT,
|
|
40
41
|
ZERO_ADDRESS,
|
|
41
42
|
)
|
|
43
|
+
from operate.ledger import get_default_ledger_api
|
|
42
44
|
from operate.operate_types import Chain
|
|
43
45
|
|
|
44
46
|
|
|
@@ -174,11 +176,7 @@ def create_safe(
|
|
|
174
176
|
tx = registry_contracts.gnosis_safe.get_deploy_transaction(
|
|
175
177
|
ledger_api=ledger_api,
|
|
176
178
|
deployer_address=crypto.address,
|
|
177
|
-
owners=(
|
|
178
|
-
[crypto.address]
|
|
179
|
-
if backup_owner is None
|
|
180
|
-
else [crypto.address, backup_owner]
|
|
181
|
-
),
|
|
179
|
+
owners=([crypto.address]),
|
|
182
180
|
threshold=1,
|
|
183
181
|
salt_nonce=salt_nonce,
|
|
184
182
|
)
|
|
@@ -209,7 +207,31 @@ def create_safe(
|
|
|
209
207
|
contract_address="0xa6b71e26c5e0845f74c812102ca7114b6a896ab2",
|
|
210
208
|
)
|
|
211
209
|
(event,) = instance.events.ProxyCreation().process_receipt(receipt)
|
|
212
|
-
|
|
210
|
+
safe_address = event["args"]["proxy"]
|
|
211
|
+
|
|
212
|
+
if backup_owner is not None:
|
|
213
|
+
retry_delays = [0, 60, 120, 180, 240]
|
|
214
|
+
for attempt in range(1, len(retry_delays) + 1):
|
|
215
|
+
try:
|
|
216
|
+
add_owner(
|
|
217
|
+
ledger_api=ledger_api,
|
|
218
|
+
crypto=crypto,
|
|
219
|
+
safe=safe_address,
|
|
220
|
+
owner=backup_owner,
|
|
221
|
+
)
|
|
222
|
+
break # success
|
|
223
|
+
except Exception as e: # pylint: disable=broad-except
|
|
224
|
+
if attempt == len(retry_delays):
|
|
225
|
+
raise RuntimeError(
|
|
226
|
+
f"Failed to add backup owner {backup_owner} after {len(retry_delays)} attempts: {e}"
|
|
227
|
+
) from e
|
|
228
|
+
next_delay = retry_delays[attempt]
|
|
229
|
+
logger.error(
|
|
230
|
+
f"Retry add owner {attempt}/{len(retry_delays)} in {next_delay} seconds due to error: {e}"
|
|
231
|
+
)
|
|
232
|
+
time.sleep(next_delay)
|
|
233
|
+
|
|
234
|
+
return safe_address, salt_nonce, tx_hash
|
|
213
235
|
|
|
214
236
|
|
|
215
237
|
def get_owners(ledger_api: LedgerApi, safe: str) -> t.List[str]:
|
|
@@ -274,6 +296,9 @@ def send_safe_txs(
|
|
|
274
296
|
chain_type=Chain.from_id(
|
|
275
297
|
ledger_api._chain_id # pylint: disable=protected-access
|
|
276
298
|
),
|
|
299
|
+
timeout=ON_CHAIN_INTERACT_TIMEOUT,
|
|
300
|
+
retries=ON_CHAIN_INTERACT_RETRIES,
|
|
301
|
+
sleep=ON_CHAIN_INTERACT_SLEEP,
|
|
277
302
|
)
|
|
278
303
|
setattr(tx_settler, "build", _build_tx) # noqa: B010
|
|
279
304
|
tx_receipt = tx_settler.transact(
|
|
@@ -443,6 +468,9 @@ def transfer(
|
|
|
443
468
|
chain_type=Chain.from_id(
|
|
444
469
|
ledger_api._chain_id # pylint: disable=protected-access
|
|
445
470
|
),
|
|
471
|
+
timeout=ON_CHAIN_INTERACT_TIMEOUT,
|
|
472
|
+
retries=ON_CHAIN_INTERACT_RETRIES,
|
|
473
|
+
sleep=ON_CHAIN_INTERACT_SLEEP,
|
|
446
474
|
)
|
|
447
475
|
setattr(tx_settler, "build", _build_tx) # noqa: B010
|
|
448
476
|
tx_receipt = tx_settler.transact(
|
|
@@ -485,6 +513,33 @@ def transfer_erc20_from_safe(
|
|
|
485
513
|
)
|
|
486
514
|
|
|
487
515
|
|
|
516
|
+
def estimate_transfer_tx_fee(chain: Chain, sender_address: str, to: str) -> int:
|
|
517
|
+
"""Estimate transfer transaction fee."""
|
|
518
|
+
ledger_api = get_default_ledger_api(chain)
|
|
519
|
+
tx = ledger_api.get_transfer_transaction(
|
|
520
|
+
sender_address=sender_address,
|
|
521
|
+
destination_address=to,
|
|
522
|
+
amount=0,
|
|
523
|
+
tx_fee=0,
|
|
524
|
+
tx_nonce="0x",
|
|
525
|
+
chain_id=chain.id,
|
|
526
|
+
raise_on_try=True,
|
|
527
|
+
)
|
|
528
|
+
tx = ledger_api.update_with_gas_estimate(
|
|
529
|
+
transaction=tx,
|
|
530
|
+
raise_on_try=False,
|
|
531
|
+
)
|
|
532
|
+
chain_fee = tx["gas"] * tx["maxFeePerGas"]
|
|
533
|
+
if chain in (
|
|
534
|
+
Chain.ARBITRUM_ONE,
|
|
535
|
+
Chain.BASE,
|
|
536
|
+
Chain.OPTIMISM,
|
|
537
|
+
Chain.MODE,
|
|
538
|
+
):
|
|
539
|
+
chain_fee += ledger_api.get_l1_data_fee(tx)
|
|
540
|
+
return chain_fee
|
|
541
|
+
|
|
542
|
+
|
|
488
543
|
def drain_eoa(
|
|
489
544
|
ledger_api: LedgerApi,
|
|
490
545
|
crypto: Crypto,
|
|
@@ -505,34 +560,34 @@ def drain_eoa(
|
|
|
505
560
|
*args: t.Any, **kwargs: t.Any
|
|
506
561
|
) -> t.Dict:
|
|
507
562
|
"""Build transaction"""
|
|
563
|
+
chain_fee = estimate_transfer_tx_fee(
|
|
564
|
+
chain=Chain.from_id(chain_id),
|
|
565
|
+
sender_address=crypto.address,
|
|
566
|
+
to=withdrawal_address,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
amount = ledger_api.get_balance(crypto.address) - chain_fee
|
|
570
|
+
if amount <= 0:
|
|
571
|
+
raise ChainInteractionError(
|
|
572
|
+
f"No balance to drain from wallet: {crypto.address}"
|
|
573
|
+
)
|
|
574
|
+
|
|
508
575
|
tx = ledger_api.get_transfer_transaction(
|
|
509
576
|
sender_address=crypto.address,
|
|
510
577
|
destination_address=withdrawal_address,
|
|
511
|
-
amount=
|
|
578
|
+
amount=amount,
|
|
512
579
|
tx_fee=0,
|
|
513
580
|
tx_nonce="0x",
|
|
514
581
|
chain_id=chain_id,
|
|
515
582
|
raise_on_try=True,
|
|
516
583
|
)
|
|
517
|
-
|
|
518
|
-
|
|
584
|
+
empty_tx = tx.copy()
|
|
585
|
+
empty_tx["value"] = 0
|
|
586
|
+
empty_tx = ledger_api.update_with_gas_estimate(
|
|
587
|
+
transaction=empty_tx,
|
|
519
588
|
raise_on_try=False,
|
|
520
589
|
)
|
|
521
|
-
|
|
522
|
-
chain_fee = tx["gas"] * tx["maxFeePerGas"]
|
|
523
|
-
if Chain.from_id(chain_id) in (
|
|
524
|
-
Chain.ARBITRUM_ONE,
|
|
525
|
-
Chain.BASE,
|
|
526
|
-
Chain.OPTIMISM,
|
|
527
|
-
Chain.MODE,
|
|
528
|
-
):
|
|
529
|
-
chain_fee += ledger_api.get_l1_data_fee(tx)
|
|
530
|
-
|
|
531
|
-
tx["value"] = ledger_api.get_balance(crypto.address) - chain_fee
|
|
532
|
-
if tx["value"] <= 0:
|
|
533
|
-
raise ChainInteractionError(
|
|
534
|
-
f"No balance to drain from wallet: {crypto.address}"
|
|
535
|
-
)
|
|
590
|
+
tx["gas"] = empty_tx["gas"]
|
|
536
591
|
|
|
537
592
|
logger.info(
|
|
538
593
|
f"Draining {tx['value']} native units from wallet: {crypto.address}"
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# ------------------------------------------------------------------------------
|
|
3
|
+
#
|
|
4
|
+
# Copyright 2025 Valory AG
|
|
5
|
+
#
|
|
6
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
# you may not use this file except in compliance with the License.
|
|
8
|
+
# You may obtain a copy of the License at
|
|
9
|
+
#
|
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
#
|
|
12
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
# See the License for the specific language governing permissions and
|
|
16
|
+
# limitations under the License.
|
|
17
|
+
#
|
|
18
|
+
# ------------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
"""Utility module for enforcing single-instance application behavior and monitoring parent process."""
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import socket
|
|
26
|
+
import time
|
|
27
|
+
from contextlib import suppress
|
|
28
|
+
from typing import Callable, Optional
|
|
29
|
+
|
|
30
|
+
import psutil
|
|
31
|
+
import requests
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AppSingleInstance:
|
|
35
|
+
"""Ensure that only one instance of an application is running."""
|
|
36
|
+
|
|
37
|
+
host = "127.0.0.1"
|
|
38
|
+
after_kill_sleep_time = 1
|
|
39
|
+
proc_kill_wait_timeout = 10
|
|
40
|
+
proc_terminate_wait_timeout = 10
|
|
41
|
+
http_request_timeout = 3
|
|
42
|
+
|
|
43
|
+
def __init__(self, port_number: int, shutdown_endpoint: str = "/shutdown") -> None:
|
|
44
|
+
"""Initialize the AppSingleInstance manager."""
|
|
45
|
+
self.port_number = port_number
|
|
46
|
+
self.shutdown_endpoint = shutdown_endpoint
|
|
47
|
+
self.logger = logging.getLogger("app_single_instance")
|
|
48
|
+
self.logger.setLevel(logging.DEBUG)
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def is_port_in_use(port: int) -> bool:
|
|
52
|
+
"""Return True if a given TCP port is currently in use."""
|
|
53
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
54
|
+
return s.connect_ex(("127.0.0.1", port)) == 0
|
|
55
|
+
|
|
56
|
+
def shutdown_previous_instance(self) -> None:
|
|
57
|
+
"""Attempt to stop a previously running instance of the application."""
|
|
58
|
+
if not self.is_port_in_use(self.port_number):
|
|
59
|
+
self.logger.info(f"Port {self.port_number} is free. All good.")
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
self.logger.warning(f"Port {self.port_number} is in use. Trying to free it!")
|
|
63
|
+
self.logger.warning(
|
|
64
|
+
f"Trying to stop previous instance via shutdown endpoint: {self.shutdown_endpoint}"
|
|
65
|
+
)
|
|
66
|
+
self.try_shutdown_with_endpoint()
|
|
67
|
+
|
|
68
|
+
if not self.is_port_in_use(self.port_number):
|
|
69
|
+
self.logger.info(f"Port {self.port_number} is free. All good.")
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
self.logger.warning(
|
|
73
|
+
f"Trying to stop previous instance by killing process using port {self.port_number}"
|
|
74
|
+
)
|
|
75
|
+
self.try_kill_proc_using_port()
|
|
76
|
+
|
|
77
|
+
if not self.is_port_in_use(self.port_number):
|
|
78
|
+
self.logger.info(f"Port {self.port_number} is free. All good.")
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
self.logger.error(f"Port {self.port_number} still in use. Cannot continue.")
|
|
82
|
+
raise RuntimeError(f"Port {self.port_number} is in use, cannot continue!")
|
|
83
|
+
|
|
84
|
+
def try_shutdown_with_endpoint(self) -> None:
|
|
85
|
+
"""Attempt to gracefully shut down the previous instance via HTTP or HTTPS."""
|
|
86
|
+
try:
|
|
87
|
+
self.logger.warning(
|
|
88
|
+
"Attempting to stop previous instance via HTTPS shutdown endpoint."
|
|
89
|
+
)
|
|
90
|
+
requests.get(
|
|
91
|
+
f"https://{self.host}:{self.port_number}{self.shutdown_endpoint}",
|
|
92
|
+
timeout=self.http_request_timeout,
|
|
93
|
+
verify=False, # nosec
|
|
94
|
+
)
|
|
95
|
+
time.sleep(self.after_kill_sleep_time)
|
|
96
|
+
except requests.exceptions.SSLError:
|
|
97
|
+
self.logger.warning("HTTPS shutdown failed, retrying without SSL.")
|
|
98
|
+
try:
|
|
99
|
+
requests.get(
|
|
100
|
+
f"http://{self.host}:{self.port_number}{self.shutdown_endpoint}",
|
|
101
|
+
timeout=self.http_request_timeout,
|
|
102
|
+
)
|
|
103
|
+
time.sleep(self.after_kill_sleep_time)
|
|
104
|
+
except Exception as e: # pylint: disable=broad-except
|
|
105
|
+
self.logger.error(
|
|
106
|
+
f"Failed to stop previous instance (HTTP). Error: {e}"
|
|
107
|
+
)
|
|
108
|
+
except Exception as e: # pylint: disable=broad-except
|
|
109
|
+
self.logger.error(f"Failed to stop previous instance (HTTPS). Error: {e}")
|
|
110
|
+
|
|
111
|
+
def try_kill_proc_using_port(self) -> None:
|
|
112
|
+
"""Attempt to forcibly terminate the process occupying the target port."""
|
|
113
|
+
for conn in psutil.net_connections(kind="tcp"):
|
|
114
|
+
if (
|
|
115
|
+
conn.laddr.port == self.port_number
|
|
116
|
+
and conn.status == psutil.CONN_LISTEN
|
|
117
|
+
):
|
|
118
|
+
if conn.pid is None:
|
|
119
|
+
self.logger.info(
|
|
120
|
+
f"Process using port {self.port_number} found but PID is None. Cannot continue."
|
|
121
|
+
)
|
|
122
|
+
return
|
|
123
|
+
self.logger.info(
|
|
124
|
+
f"Process using port {self.port_number} found (PID={conn.pid}). Terminating..."
|
|
125
|
+
)
|
|
126
|
+
try:
|
|
127
|
+
self.kill_process_tree(conn.pid)
|
|
128
|
+
time.sleep(self.after_kill_sleep_time)
|
|
129
|
+
return
|
|
130
|
+
except Exception as e: # pylint: disable=broad-except
|
|
131
|
+
self.logger.error(f"Error stopping process {conn.pid}: {e}")
|
|
132
|
+
self.logger.info(f"No process found using port {self.port_number}.")
|
|
133
|
+
|
|
134
|
+
def kill_process_tree(self, pid: int) -> None:
|
|
135
|
+
"""Terminate a process and all its child processes."""
|
|
136
|
+
try:
|
|
137
|
+
parent = psutil.Process(pid)
|
|
138
|
+
children = parent.children(recursive=True)
|
|
139
|
+
|
|
140
|
+
for child in children:
|
|
141
|
+
with suppress(psutil.NoSuchProcess):
|
|
142
|
+
child.terminate()
|
|
143
|
+
|
|
144
|
+
_, still_alive = psutil.wait_procs(
|
|
145
|
+
children, timeout=self.proc_terminate_wait_timeout
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
for child in still_alive:
|
|
149
|
+
with suppress(psutil.NoSuchProcess):
|
|
150
|
+
child.kill()
|
|
151
|
+
|
|
152
|
+
parent.terminate()
|
|
153
|
+
try:
|
|
154
|
+
parent.wait(timeout=self.proc_terminate_wait_timeout)
|
|
155
|
+
except psutil.TimeoutExpired:
|
|
156
|
+
parent.kill()
|
|
157
|
+
parent.wait(timeout=self.proc_kill_wait_timeout)
|
|
158
|
+
|
|
159
|
+
except psutil.NoSuchProcess:
|
|
160
|
+
self.logger.info(f"Process {pid} already terminated.")
|
|
161
|
+
except Exception as e: # pylint: disable=broad-except
|
|
162
|
+
self.logger.error(f"Error killing process {pid}: {e}")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
logger = logging.getLogger("parent_watchdog")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class ParentWatchdog:
|
|
169
|
+
"""Monitor the parent process and trigger a shutdown when it exits."""
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self, on_parent_exit: Callable[[], asyncio.Future], check_interval: int = 3
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Initialize the ParentWatchdog."""
|
|
175
|
+
self.on_parent_exit = on_parent_exit
|
|
176
|
+
self.check_interval = check_interval
|
|
177
|
+
self._task: Optional[asyncio.Task] = None
|
|
178
|
+
self._stopping = False
|
|
179
|
+
|
|
180
|
+
async def _watch_loop(self) -> None:
|
|
181
|
+
"""Continuously monitor the parent process and invoke the shutdown callback when it exits."""
|
|
182
|
+
try:
|
|
183
|
+
own_pid = os.getpid()
|
|
184
|
+
logger.info(f"ParentWatchdog started (pid={own_pid}, ppid={os.getppid()})")
|
|
185
|
+
|
|
186
|
+
while not self._stopping:
|
|
187
|
+
try:
|
|
188
|
+
parent = psutil.Process(os.getppid())
|
|
189
|
+
if not parent.is_running() or os.getppid() == 1:
|
|
190
|
+
logger.warning(
|
|
191
|
+
"Parent process no longer alive, initiating shutdown."
|
|
192
|
+
)
|
|
193
|
+
await self.on_parent_exit()
|
|
194
|
+
break
|
|
195
|
+
except psutil.NoSuchProcess:
|
|
196
|
+
logger.warning("Parent process not found, initiating shutdown.")
|
|
197
|
+
await self.on_parent_exit()
|
|
198
|
+
break
|
|
199
|
+
except Exception: # pylint: disable=broad-except
|
|
200
|
+
logger.exception("Parent check iteration failed.")
|
|
201
|
+
await asyncio.sleep(self.check_interval)
|
|
202
|
+
|
|
203
|
+
except asyncio.CancelledError:
|
|
204
|
+
logger.info("ParentWatchdog task cancelled.")
|
|
205
|
+
except Exception: # pylint: disable=broad-except
|
|
206
|
+
logger.exception("ParentWatchdog crashed unexpectedly.")
|
|
207
|
+
finally:
|
|
208
|
+
logger.info("ParentWatchdog stopped.")
|
|
209
|
+
|
|
210
|
+
def start(self, loop: Optional[asyncio.AbstractEventLoop] = None) -> asyncio.Task:
|
|
211
|
+
"""Start monitoring the parent process."""
|
|
212
|
+
if self._task:
|
|
213
|
+
logger.warning("ParentWatchdog already running.")
|
|
214
|
+
return self._task
|
|
215
|
+
loop = loop or asyncio.get_running_loop()
|
|
216
|
+
self._task = loop.create_task(self._watch_loop())
|
|
217
|
+
return self._task
|
|
218
|
+
|
|
219
|
+
async def stop(self) -> None:
|
|
220
|
+
"""Stop the parent process watchdog."""
|
|
221
|
+
self._stopping = True
|
|
222
|
+
if self._task:
|
|
223
|
+
with suppress(Exception):
|
|
224
|
+
self._task.cancel()
|
|
225
|
+
await self._task
|
|
226
|
+
self._task = None
|