diff --git a/api.md b/api.md
index dff57591c..ae12f3f15 100644
--- a/api.md
+++ b/api.md
@@ -714,6 +714,64 @@ Update service configuration `service_config_id` with the provided template.
---
+### `PATCH /api/v2/service/{service_config_id}`
+
+Partial update service configuration `service_config_id` with the provided (partial) template.
+
+![Partial updates](docs/images/partial_update_examples.png)
+
+
+ Request
+
+```json
+ {
+ "configurations": {...},
+ "description": "Trader agent for omen prediction markets",
+ "env_variables": {...},
+ "hash": "bafybeibpseosblmaw6sk6zsnic2kfxfsijrnfluuhkwboyqhx7ma7zw2me",
+ "image": "https://operate.olas.network/_next/image?url=%2Fimages%2Fprediction-agent.png&w=3840&q=75",
+ "home_chain": "gnosis",
+ "name": "valory/trader_omen_gnosis",
+ "service_version": "v0.19.0"
+ }
+```
+
+
+
+
+ Response
+
+- If the update is successful, the response contains the updated service configuration:
+
+ ```json
+ {
+ "chain_configs": {...},
+ "description": "Trader agent for omen prediction markets",
+ "env_variables": {...},
+ "hash": "bafybeidicxsruh3r4a2xarawzan6ocwyvpn3ofv42po5kxf7x6ck7kn22u",
+ "hash_history": {"1731487112": "bafybeidicxsruh3r4a2xarawzan6ocwyvpn3ofv42po5kxf7x6ck7kn22u", "1731490000": "bafybeibpseosblmaw6sk6zsnic2kfxfsijrnfluuhkwboyqhx7ma7zw2me"},
+ "home_chain": "gnosis",
+ "keys": [...],
+ "name": "valory/trader_omen_gnosis",
+ "service_config_id": "sc-85a7a12a-8c6b-46b8-919a-b8a3b8e3ad39",
+ "service_path": "/home/user/.operate/services/sc-85a7a12a-8c6b-46b8-919a-b8a3b8e3ad39/trader_omen_gnosis"
+ }
+
+ ```
+
+- If the update is not successful:
+
+ ```json
+ {
+ "error": "Error message",
+ "traceback": "Traceback message"
+ }
+ ```
+
+
+
+---
+
### `POST /api/service/{service_config_id}/stop`
Stop service with service configuration `service_configuration_id`.
diff --git a/docs/images/partial_update_examples.png b/docs/images/partial_update_examples.png
new file mode 100644
index 000000000..732ef3613
Binary files /dev/null and b/docs/images/partial_update_examples.png differ
diff --git a/frontend/client/types.ts b/frontend/client/types.ts
index 7c8113167..ccfd2c36d 100644
--- a/frontend/client/types.ts
+++ b/frontend/client/types.ts
@@ -39,8 +39,8 @@ export type ChainData = {
nft: string;
staking_program_id: StakingProgramId;
threshold: number;
- use_mech_marketplace: true;
- use_staking: true;
+ use_mech_marketplace: boolean;
+ use_staking: boolean;
};
};
diff --git a/frontend/components/MainPage/header/AgentButton/AgentNotRunningButton.tsx b/frontend/components/MainPage/header/AgentButton/AgentNotRunningButton.tsx
index 66d47ea22..3d347bc33 100644
--- a/frontend/components/MainPage/header/AgentButton/AgentNotRunningButton.tsx
+++ b/frontend/components/MainPage/header/AgentButton/AgentNotRunningButton.tsx
@@ -249,14 +249,9 @@ const useServiceDeployment = () => {
if (service.hash !== serviceTemplate.hash) {
await ServicesService.updateService({
serviceConfigId: service.service_config_id,
- stakingProgramId: selectedStakingProgramId,
- // chainId: selectedAgentConfig.evmHomeChainId,
- serviceTemplate,
- deploy: false, // TODO: deprecated will remove
- useMechMarketplace:
- STAKING_PROGRAMS[selectedAgentConfig.evmHomeChainId][
- selectedStakingProgramId
- ].mechType === MechType.Marketplace,
+ partialServiceTemplate: {
+ hash: serviceTemplate.hash,
+ },
});
}
}
diff --git a/frontend/components/ManageStakingPage/StakingContractSection/MigrateButton.tsx b/frontend/components/ManageStakingPage/StakingContractSection/MigrateButton.tsx
index 21de15047..52c051158 100644
--- a/frontend/components/ManageStakingPage/StakingContractSection/MigrateButton.tsx
+++ b/frontend/components/ManageStakingPage/StakingContractSection/MigrateButton.tsx
@@ -20,6 +20,7 @@ import {
} from '@/hooks/useStakingContractDetails';
import { useStakingProgram } from '@/hooks/useStakingProgram';
import { ServicesService } from '@/service/Services';
+import { DeepPartial } from '@/types/Util';
import { CountdownUntilMigration } from './CountdownUntilMigration';
import { CantMigrateReason, useMigrate } from './useMigrate';
@@ -45,11 +46,10 @@ export const MigrateButton = ({
const { service } = useService(serviceConfigId);
const serviceTemplate = useMemo(
() =>
- service
- ? getServiceTemplate(service.hash)
- : SERVICE_TEMPLATES.find(
- (template) => template.agentType === selectedAgentType,
- ),
+ (service && getServiceTemplate(service.hash)) ??
+ SERVICE_TEMPLATES.find(
+ (template) => template.agentType === selectedAgentType,
+ ),
[selectedAgentType, service],
);
@@ -128,23 +128,39 @@ export const MigrateButton = ({
overrideSelectedServiceStatus(MiddlewareDeploymentStatus.DEPLOYING);
goto(Pages.Main);
- const serviceConfigParams = {
- stakingProgramId: stakingProgramIdToMigrateTo,
- serviceTemplate,
- deploy: true,
- useMechMarketplace:
- stakingProgramIdToMigrateTo ===
- StakingProgramId.PearlBetaMechMarketplace,
- };
-
if (selectedService) {
// update service
await ServicesService.updateService({
- ...serviceConfigParams,
serviceConfigId,
+ partialServiceTemplate: {
+ configurations: {
+ ...Object.entries(serviceTemplate.configurations).reduce(
+ (acc, [middlewareChain]) => {
+ acc[middlewareChain] = {
+ staking_program_id: stakingProgramIdToMigrateTo,
+ use_mech_marketplace:
+ stakingProgramIdToMigrateTo ===
+ StakingProgramId.PearlBetaMechMarketplace,
+ };
+ return acc;
+ },
+ {} as DeepPartial,
+ ),
+ },
+ },
});
} else {
// create service if it doesn't exist
+
+ const serviceConfigParams = {
+ stakingProgramId: stakingProgramIdToMigrateTo,
+ serviceTemplate,
+ deploy: true,
+ useMechMarketplace:
+ stakingProgramIdToMigrateTo ===
+ StakingProgramId.PearlBetaMechMarketplace,
+ };
+
await ServicesService.createService(serviceConfigParams);
}
diff --git a/frontend/service/Services.ts b/frontend/service/Services.ts
index 32bc21965..86b13ed38 100644
--- a/frontend/service/Services.ts
+++ b/frontend/service/Services.ts
@@ -9,6 +9,7 @@ import { CONTENT_TYPE_JSON_UTF8 } from '@/constants/headers';
import { BACKEND_URL_V2 } from '@/constants/urls';
import { StakingProgramId } from '@/enums/StakingProgram';
import { Address } from '@/types/Address';
+import { DeepPartial } from '@/types/Util';
import { asEvmChainId } from '@/utils/middlewareHelpers';
/**
@@ -92,44 +93,19 @@ const createService = async ({
/**
* Updates a service
- * @param serviceTemplate
+ * @param partialServiceTemplate
* @returns Promise
*/
const updateService = async ({
- deploy,
- serviceTemplate,
+ partialServiceTemplate,
serviceConfigId,
- stakingProgramId,
- useMechMarketplace = false,
}: {
- deploy: boolean;
- serviceTemplate: ServiceTemplate;
+ partialServiceTemplate: DeepPartial;
serviceConfigId: string;
- stakingProgramId: StakingProgramId;
- useMechMarketplace?: boolean;
}): Promise =>
fetch(`${BACKEND_URL_V2}/service/${serviceConfigId}`, {
- method: 'PUT',
- body: JSON.stringify({
- ...serviceTemplate,
- deploy,
- configurations: {
- ...serviceTemplate.configurations,
- // overwrite defaults with chain-specific configurations
- ...Object.entries(serviceTemplate.configurations).reduce(
- (acc, [middlewareChain, config]) => {
- acc[middlewareChain] = {
- ...config,
- rpc: CHAIN_CONFIG[asEvmChainId(middlewareChain)].rpc,
- staking_program_id: stakingProgramId,
- use_mech_marketplace: useMechMarketplace,
- };
- return acc;
- },
- {} as typeof serviceTemplate.configurations,
- ),
- },
- }),
+ method: 'PATCH',
+ body: JSON.stringify({ ...partialServiceTemplate }),
headers: { ...CONTENT_TYPE_JSON_UTF8 },
}).then((response) => {
if (response.ok) {
diff --git a/frontend/types/Util.ts b/frontend/types/Util.ts
index e4bed10e6..b39a88304 100644
--- a/frontend/types/Util.ts
+++ b/frontend/types/Util.ts
@@ -4,6 +4,10 @@ export type Optional = T | undefined;
export type Maybe = Nullable>;
+export type DeepPartial = {
+ [P in keyof T]?: T[P] extends object ? DeepPartial : T[P];
+};
+
/**
* function to strip off the null or undefined types from a type by making an assertion.
* @note This function should be used if you are confident that the value will never ever be null or undefined.
diff --git a/operate/cli.py b/operate/cli.py
index 3cf562455..6f156f5ff 100644
--- a/operate/cli.py
+++ b/operate/cli.py
@@ -268,7 +268,7 @@ def pause_all_services_on_exit(signum: int, frame: t.Optional[FrameType]) -> Non
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
- allow_methods=["GET", "POST", "PUT", "DELETE"],
+ allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
)
def with_retries(f: t.Callable) -> t.Callable:
@@ -756,6 +756,7 @@ def _fn() -> None:
)
@app.put("/api/v2/service/{service_config_id}")
+ @app.patch("/api/v2/service/{service_config_id}")
@with_retries
async def _update_service(request: Request) -> JSONResponse:
"""Update a service."""
@@ -765,7 +766,6 @@ async def _update_service(request: Request) -> JSONResponse:
service_config_id = request.path_params["service_config_id"]
manager = operate.service_manager()
- print(service_config_id)
if not manager.exists(service_config_id=service_config_id):
return service_not_found_error(service_config_id=service_config_id)
@@ -773,10 +773,21 @@ async def _update_service(request: Request) -> JSONResponse:
allow_different_service_public_id = template.get(
"allow_different_service_public_id", False
)
+
+ if request.method == "PUT":
+ partial_update = False
+ else:
+ partial_update = True
+
+ logger.info(
+ f"_update_service {partial_update=} {allow_different_service_public_id=}"
+ )
+
output = manager.update(
service_config_id=service_config_id,
service_template=template,
allow_different_service_public_id=allow_different_service_public_id,
+ partial_update=partial_update,
)
return JSONResponse(content=output.json)
diff --git a/operate/operate_types.py b/operate/operate_types.py
index 9a6838377..b6b502c1b 100644
--- a/operate/operate_types.py
+++ b/operate/operate_types.py
@@ -246,7 +246,7 @@ class EnvVariableAttributes(TypedDict):
EnvVariables = t.Dict[str, EnvVariableAttributes]
-class ServiceTemplate(TypedDict):
+class ServiceTemplate(TypedDict, total=False):
"""Service template."""
name: str
diff --git a/operate/services/manage.py b/operate/services/manage.py
index 8840f9f43..f728ee3e2 100644
--- a/operate/services/manage.py
+++ b/operate/services/manage.py
@@ -585,8 +585,37 @@ def _deploy_service_onchain_from_safe( # pylint: disable=too-many-statements,to
):
# TODO: This is possibly not a good idea: we are setting up a computed variable based on
# the value passed in the template.
- db_path = service.path / "persistent_data/memeooorr.db"
- cookies_path = service.path / "persistent_data/twikit_cookies.json"
+ db_path = (
+ service.path
+ / "persistent_data"
+ / service.env_variables["TWIKIT_USERNAME"]["value"]
+ / "memeooorr.db"
+ )
+ cookies_path = (
+ service.path
+ / "persistent_data"
+ / service.env_variables["TWIKIT_USERNAME"]["value"]
+ / "twikit_cookies.json"
+ )
+
+ # Patch: Move existing configurations to the new location
+ old_db_path = service.path / "persistent_data" / "memeooorr.db"
+ old_cookies_path = service.path / "persistent_data" / "twikit_cookies.json"
+
+ for old_path, new_path in [
+ (old_db_path, db_path),
+ (old_cookies_path, cookies_path),
+ ]:
+ if old_path.exists():
+ self.logger.info(f"Moving {old_path} -> {new_path}")
+ new_path.parent.mkdir(parents=True, exist_ok=True)
+ try:
+ os.rename(old_path, new_path)
+ except OSError:
+ self.logger.info("Fallback to shutil.move")
+ shutil.move(str(old_path), str(new_path))
+ time.sleep(3)
+ # End patch
env_var_to_value.update(
{
@@ -1689,12 +1718,17 @@ def update(
service_config_id: str,
service_template: ServiceTemplate,
allow_different_service_public_id: bool = False,
+ partial_update: bool = True,
) -> Service:
"""Update a service."""
self.logger.info(f"Updating {service_config_id=}")
service = self.load(service_config_id=service_config_id)
- service.update(service_template, allow_different_service_public_id)
+ service.update(
+ service_template=service_template,
+ allow_different_service_public_id=allow_different_service_public_id,
+ partial_update=partial_update,
+ )
return service
def update_all_matching(
diff --git a/operate/services/service.py b/operate/services/service.py
index ec6ca340f..8a41859eb 100644
--- a/operate/services/service.py
+++ b/operate/services/service.py
@@ -98,7 +98,7 @@
SERVICE_CONFIG_VERSION = 4
SERVICE_CONFIG_PREFIX = "sc-"
-DUMMY_MULTISIG = "0xm"
+NON_EXISTENT_MULTISIG = "0xm"
NON_EXISTENT_TOKEN = -1
DEFAULT_TRADER_ENV_VARS = {
@@ -683,7 +683,7 @@ class Service(LocalResource):
_file = "config.json"
@classmethod
- def migrate_format(cls, path: Path) -> bool:
+ def migrate_format(cls, path: Path) -> bool: # pylint: disable=too-many-statements
"""Migrate the JSON file format if needed."""
if not path.is_dir():
@@ -897,7 +897,7 @@ def new( # pylint: disable=too-many-locals
chain_data = OnChainData(
instances=[],
token=NON_EXISTENT_TOKEN,
- multisig=DUMMY_MULTISIG,
+ multisig=NON_EXISTENT_MULTISIG,
staked=False,
on_chain_state=OnChainState.NON_EXISTENT,
user_params=OnChainUserParams.from_json(config), # type: ignore
@@ -981,47 +981,65 @@ def update(
self,
service_template: ServiceTemplate,
allow_different_service_public_id: bool = False,
+ partial_update: bool = False,
) -> None:
"""Update service."""
- target_hash = service_template["hash"]
- target_service_public_id = Service.get_service_public_id(target_hash, self.path)
-
- if not allow_different_service_public_id and (
- self.service_public_id() != target_service_public_id
- ):
- raise ValueError(
- f"Trying to update a service with a different public id: {self.service_public_id()=} {self.hash=} {target_service_public_id=} {target_hash=}."
+ target_hash = service_template.get("hash")
+ if target_hash:
+ target_service_public_id = Service.get_service_public_id(
+ target_hash, self.path
)
+ if not allow_different_service_public_id and (
+ self.service_public_id() != target_service_public_id
+ ):
+ raise ValueError(
+ f"Trying to update a service with a different public id: {self.service_public_id()=} {self.hash=} {target_service_public_id=} {target_hash=}."
+ )
+
+ self.hash = service_template.get("hash", self.hash)
+
+ # hash_history - Only update if latest inserted hash is different
+ if self.hash_history[max(self.hash_history.keys())] != self.hash:
+ current_timestamp = int(time.time())
+ self.hash_history[current_timestamp] = self.hash
+
+ self.home_chain = service_template.get("home_chain", self.home_chain)
+ self.description = service_template.get("description", self.description)
+ self.name = service_template.get("name", self.name)
+
shutil.rmtree(self.service_path)
service_path = Path(
IPFSTool().download(
- hash_id=service_template["hash"],
+ hash_id=self.hash,
target_dir=self.path,
)
)
self.service_path = service_path
- self.name = service_template["name"]
- self.hash = service_template["hash"]
- self.description = service_template["description"]
-
- # TODO temporarily disable update env variables - hotfix for Memeooorr
- # self.env_variables = service_template["env_variables"]
-
- # Only update hash_history if latest inserted hash is different
- if self.hash_history[max(self.hash_history.keys())] != service_template["hash"]:
- current_timestamp = int(time.time())
- self.hash_history[current_timestamp] = service_template["hash"]
- self.home_chain = service_template["home_chain"]
+ # env_variables
+ if partial_update:
+ for var, attrs in service_template.get("env_variables", {}).items():
+ self.env_variables.setdefault(var, {}).update(attrs)
+ else:
+ self.env_variables = service_template["env_variables"]
+ # chain_configs
+ # TODO support remove chains for non-partial updates
+ # TODO ensure all and only existing chains are passed for non-partial updates
ledger_configs = ServiceHelper(path=self.service_path).ledger_configs()
- for chain, config in service_template["configurations"].items():
+ for chain, new_config in service_template.get("configurations", {}).items():
if chain in self.chain_configs:
# The template is providing a chain configuration that already
# exists in this service - update only the user parameters.
# This is to avoid losing on-chain data like safe, token, etc.
+ if partial_update:
+ config = self.chain_configs[chain].chain_data.user_params.json
+ config.update(new_config)
+ else:
+ config = new_config
+
self.chain_configs[
chain
].chain_data.user_params = OnChainUserParams.from_json(
@@ -1032,15 +1050,15 @@ def update(
# not currently exist in this service - copy all config as
# when creating a new service.
ledger_config = ledger_configs[chain]
- ledger_config.rpc = config["rpc"]
+ ledger_config.rpc = new_config["rpc"]
chain_data = OnChainData(
instances=[],
token=NON_EXISTENT_TOKEN,
- multisig=DUMMY_MULTISIG,
+ multisig=NON_EXISTENT_MULTISIG,
staked=False,
on_chain_state=OnChainState.NON_EXISTENT,
- user_params=OnChainUserParams.from_json(config), # type: ignore
+ user_params=OnChainUserParams.from_json(new_config), # type: ignore
)
self.chain_configs[chain] = ChainConfig(
diff --git a/tests/test_services_manage.py b/tests/test_services_manage.py
new file mode 100644
index 000000000..4f840ceb7
--- /dev/null
+++ b/tests/test_services_manage.py
@@ -0,0 +1,280 @@
+# -*- coding: utf-8 -*-
+# ------------------------------------------------------------------------------
+#
+# Copyright 2024 Valory AG
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# ------------------------------------------------------------------------------
+
+"""Tests for services.service module."""
+
+import random
+import string
+import typing as t
+from pathlib import Path
+
+import pytest
+from deepdiff import DeepDiff
+
+from operate.cli import OperateApp
+from operate.operate_types import ServiceTemplate
+
+from .test_services_service import DEFAULT_CONFIG_KWARGS
+
+
+ROOT_PATH = Path(__file__).resolve().parent
+OPERATE_HOME = ROOT_PATH / ".operate_test"
+
+
+@pytest.fixture
+def random_string() -> str:
+ """random_string"""
+ length = 8
+ chars = string.ascii_letters + string.digits
+ return "".join(random.choices(chars, k=length)) # nosec B311
+
+
+def get_template(**kwargs: t.Any) -> ServiceTemplate:
+ """get_template"""
+
+ return {
+ "name": kwargs.get("name"),
+ "hash": kwargs.get("hash"),
+ "description": kwargs.get("description"),
+ "image": "https://image_url",
+ "service_version": "",
+ "home_chain": "gnosis",
+ "configurations": {
+ "gnosis": {
+ "staking_program_id": kwargs.get("staking_program_id"),
+ "nft": kwargs.get("nft"),
+ "rpc": "http://localhost:8545",
+ "threshold": kwargs.get("threshold"),
+ "agent_id": kwargs.get("agent_id"),
+ "use_staking": kwargs.get("use_staking"),
+ "use_mech_marketplace": kwargs.get("use_mech_marketplace"),
+ "cost_of_bond": kwargs.get("cost_of_bond"),
+ "fund_requirements": {
+ "agent": kwargs.get("fund_requirements_agent"),
+ "safe": kwargs.get("fund_requirements_safe"),
+ },
+ "fallback_chain_params": {},
+ }
+ },
+ "env_variables": {
+ "VAR1": {
+ "name": "var1_name",
+ "description": "var1_description",
+ "value": "var1_value",
+ "provision_type": "var1_provision_type",
+ },
+ "VAR2": {
+ "name": "var2_name",
+ "description": "var2_description",
+ "value": "var2_value",
+ "provision_type": "var2_provision_type",
+ },
+ },
+ }
+
+
+class TestServiceManager:
+ """Tests for services.manager.ServiceManager class."""
+
+ @pytest.mark.parametrize("update_new_var", [True, False])
+ @pytest.mark.parametrize("update_update_var", [True, False])
+ @pytest.mark.parametrize("update_name", [True, False])
+ @pytest.mark.parametrize("update_description", [True, False])
+ @pytest.mark.parametrize("update_hash", [True, False])
+ def test_service_manager_partial_update(
+ self,
+ update_new_var: bool,
+ update_update_var: bool,
+ update_name: bool,
+ update_description: bool,
+ update_hash: bool,
+ tmp_path: Path,
+ random_string: str,
+ ) -> None:
+ """Test operate.service_manager().update()"""
+
+ operate = OperateApp(
+ home=tmp_path / ".operate_test",
+ )
+ operate.setup()
+ password = random_string
+ operate.create_user_account(password=password)
+ operate.password = password
+ service_manager = operate.service_manager()
+ service_template = get_template(**DEFAULT_CONFIG_KWARGS)
+ service = service_manager.create(service_template)
+ service_config_id = service.service_config_id
+ service_json = service_manager.load(service_config_id).json
+
+ new_hash = "bafybeicts6zhavxzz2rxahz3wzs2pzamoq64n64wp4q4cdanfuz7id6c2q"
+ VAR2_updated_attributes = {
+ "name": "var2_name_updated",
+ "description": "var2_description_updated",
+ "value": "var2_value_updated",
+ "provision_type": "var2_provision_type_updated",
+ "extra_attr": "extra_val",
+ }
+
+ VAR3_attributes = {
+ "name": "var3_name",
+ "description": "var3_description",
+ "value": "var3_value",
+ "provision_type": "var3_provision_type",
+ }
+
+ # Partial update
+ update_template: t.Dict = {}
+ expected_service_json = service_json.copy()
+
+ if update_new_var:
+ update_template["env_variables"] = update_template.get("env_variables", {})
+ update_template["env_variables"]["VAR3"] = VAR3_attributes
+ expected_service_json["env_variables"]["VAR3"] = VAR3_attributes
+
+ if update_update_var:
+ update_template["env_variables"] = update_template.get("env_variables", {})
+ update_template["env_variables"]["VAR2"] = VAR2_updated_attributes
+ expected_service_json["env_variables"]["VAR2"] = VAR2_updated_attributes
+
+ if update_name:
+ update_template["name"] = "name_updated"
+ expected_service_json["name"] = "name_updated"
+
+ if update_description:
+ update_template["description"] = "description_updated"
+ expected_service_json["description"] = "description_updated"
+
+ if update_hash:
+ update_template["hash"] = new_hash
+ expected_service_json["hash"] = new_hash
+
+ service_manager.update(
+ service_config_id=service_config_id,
+ service_template=update_template,
+ allow_different_service_public_id=False,
+ partial_update=True,
+ )
+ service_json = service_manager.load(service_config_id).json
+
+ if update_hash:
+ timestamp = max(service_json["hash_history"].keys())
+ expected_service_json["hash_history"][timestamp] = new_hash
+
+ diff = DeepDiff(service_json, expected_service_json)
+ if diff:
+ print(diff)
+
+ assert not diff, "Updated service does not match expected service."
+
+ @pytest.mark.parametrize("update_new_var", [True, False])
+ @pytest.mark.parametrize("update_update_var", [True, False])
+ @pytest.mark.parametrize("update_delete_var", [True, False])
+ @pytest.mark.parametrize("update_name", [True, False])
+ @pytest.mark.parametrize("update_description", [True, False])
+ @pytest.mark.parametrize("update_hash", [True, False])
+ def test_service_manager_update(
+ self,
+ update_new_var: bool,
+ update_update_var: bool,
+ update_delete_var: bool,
+ update_name: bool,
+ update_description: bool,
+ update_hash: bool,
+ tmp_path: Path,
+ random_string: str,
+ ) -> None:
+ """Test operate.service_manager().update()"""
+
+ operate = OperateApp(
+ home=tmp_path / ".operate_test",
+ )
+ operate.setup()
+ password = random_string
+ operate.create_user_account(password=password)
+ operate.password = password
+ service_manager = operate.service_manager()
+ service_template = get_template(**DEFAULT_CONFIG_KWARGS)
+ service = service_manager.create(service_template)
+ service_config_id = service.service_config_id
+ service_json = service_manager.load(service_config_id).json
+
+ new_hash = "bafybeicts6zhavxzz2rxahz3wzs2pzamoq64n64wp4q4cdanfuz7id6c2q"
+ VAR2_updated_attributes = {
+ "name": "var2_name_updated",
+ "description": "var2_description_updated",
+ "value": "var2_value_updated",
+ "provision_type": "var2_provision_type_updated",
+ "extra_attr": "extra_val",
+ }
+
+ VAR3_attributes = {
+ "name": "var3_name",
+ "description": "var3_description",
+ "value": "var3_value",
+ "provision_type": "var3_provision_type",
+ }
+
+ # Partial update
+ update_template: t.Dict = service_template.copy()
+ expected_service_json = service_json.copy()
+
+ if update_new_var:
+ update_template["env_variables"] = update_template.get("env_variables", {})
+ update_template["env_variables"]["VAR3"] = VAR3_attributes
+ expected_service_json["env_variables"]["VAR3"] = VAR3_attributes
+
+ if update_update_var:
+ update_template["env_variables"] = update_template.get("env_variables", {})
+ update_template["env_variables"]["VAR2"] = VAR2_updated_attributes
+ expected_service_json["env_variables"]["VAR2"] = VAR2_updated_attributes
+
+ if update_delete_var:
+ update_template["env_variables"] = update_template.get("env_variables", {})
+ del update_template["env_variables"]["VAR1"]
+ del expected_service_json["env_variables"]["VAR1"]
+
+ if update_name:
+ update_template["name"] = "name_updated"
+ expected_service_json["name"] = "name_updated"
+
+ if update_description:
+ update_template["description"] = "description_updated"
+ expected_service_json["description"] = "description_updated"
+
+ if update_hash:
+ update_template["hash"] = new_hash
+ expected_service_json["hash"] = new_hash
+
+ service_manager.update(
+ service_config_id=service_config_id,
+ service_template=update_template,
+ allow_different_service_public_id=False,
+ partial_update=False,
+ )
+ service_json = service_manager.load(service_config_id).json
+
+ if update_hash:
+ timestamp = max(service_json["hash_history"].keys())
+ expected_service_json["hash_history"][timestamp] = new_hash
+
+ diff = DeepDiff(service_json, expected_service_json)
+ if diff:
+ print(diff)
+
+ assert not diff, "Updated service does not match expected service."
diff --git a/tox.ini b/tox.ini
index f294bf30c..27d456c3b 100644
--- a/tox.ini
+++ b/tox.ini
@@ -230,6 +230,9 @@ ignore_missing_imports = True
[mypy-deepdiff.*]
ignore_missing_imports = True
+[mypy-twikit.*]
+ignore_missing_imports = True
+
[testenv:unit-tests]
deps =
pytest==7.2.1