From 85c4b967f29b462e631cc59904376f957c735ab1 Mon Sep 17 00:00:00 2001 From: nieznanysprawiciel Date: Mon, 1 Jul 2019 15:24:14 +0200 Subject: [PATCH] Cgi/transcoding/merge develop (#4406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Don't use DockerCommandHandler We don't need this. All commands are executed via docker-py. * Renamed cleanup() methods to clean_up() Because 'cleanup' is a noun and method names should be verbs. * Added failure after 100 tries on the same step * Make step_verify_income retry like all others. * Add 30 seconds of sleep after requesting tETH or tGNT * Upgrade SCI to 1.10.0 (#4231) * Fix installation script (#4220) * Non-hypervised Docker CPU Environment A temporary subclass of DockerCPUEnvironment that always uses DummyHypervisor and therefore performs no operations on Docker virtual machine. It is meant to enable usage of the new Environment API without removing DockerManager yet. Using standard DockerCPUEnvironment would cause potential conflicts during VM reconfiguration. * Moved __init__() to start of class for mypy * Added 'Failure' to step_wait_subtask_completed() Co-Authored-By: Adam Wierzbicki * Increase subtask_timeout for timeout_test because the buildbot node is slower then local machines * moved rpc and sentry spam INFO logs to DEBUG * Reduced severity from vague warning message * Fix tests broken by introducing DockerCPUEnvironment Setting up DockerCPUEnvironment in TaskComputer.__init__() requires two conditions to be met: * Twisted reactor running (because prepare() returns a Deferred) * ClientConfigDescriptor with proper memory and CPU settings * fixed separate_hyperg test after update to develop * Wamp serialization error fix: huge unsigned int (#4236) * Wamp serialization error fix: huge unsigned int * deep bigint to string conversion definition for collections * lint * all integers converted to string * Made dummy task runner more asynchronous Client.start() requires to be run with reactor. * Fix race condition in taskkeeper test by adding sleeps after each header * Update urllib3 to 1.24.3 (#4134) * Use freezegun instead of sleeps * Add log message for the selected docker-hypervisor * Migrate Payments to TaskPayment * Remove entrypoint.sh from Docker images (#4242) * Remove entrypoint.sh from Docker images * use proper tags * mypy vs peewee_migrate * Add funds validation to task/subtask restart endpoints (#4247) Adds funds validation and error reporting to RPC endpoints responsible for restarting tasks and subtasks. Replaces the RPC endpoint `comp.task.restart_subtasks` with `comp.task.subtasks.restart`, updating it to handle both finished and active tasks. * [review] Test client.get_task * [review] Move dt_task to TYPE_CHECKING * address issues caused by `EthereumConfig` getting changed behind the scenes * lenghten retry limits in concent tests * add "fixme" note * extend the concent tests' timeouts * lint * lint... * fix tests * simpleserializer: support Enum * disabled/lenient verification * Fix get_next_subtask() call * Remove unused variables * Automatic change * Use datetimes instead of integers * Revert automatic rust change * tasksession: eliminate race condition when preserving `ReportComputedTask` for use in `ForceReportComputedTask` * bump messages to 3.6 * Finish rename of rpc command comp.task.subtasks.restart * [review] Grant @Krigpl 's wish * Fix foreign key migration (TaskPayment) * [review] Move eth rpcs dependend on GM to separate module * Adjust migration numbering after merge * Less verbose faucet error messages (#4270) * * eliminate message send / sign race condition (#4269) + add a failure trigger for `SubtaskResultsSettled` in the concent force accept integration test * fix force payment playbook (#4272) * hopefully fix the windows build... (#4273) * Crossbar json serializer (#4258) * remove unneeded CrossbarRouterOptions * remove dead crossbar_log_level * Enable json serializer in crossbar router * Decrease severity taskeeper maintenance logs #4201 (#4263) * decrease severity of TaskKeeper maintenance logs to DEBUG (#4201) * TaskKeeper maintanence logs redacted (#4201) TaskKeeper maintenance logs redacted to more explicitly state what happened * lints removed (#4201) * Fix call of methods with new signatures * Remove unused imports * [review] 00 to 0x * [review] Remove default WalletOperation.status * Rename rpc.api.ethereum It fails in lintdiff.sh (confusion with ethereum module) * [review] Rename processed_ts_deadline * StatusPublisher's events with message codes (#4209) * requirements update (#4277) * bump requirements to address security concerns * one more bump * fix concent acceptance tests (#4282) * add promissory note signatures to concent acceptance tests * fixes to concent acceptance tests * Fix golemcli account info * [WTCT] explicit ETH addr instead of derivation from ETH pub key (#4170) * [WTCT] explicit ETH key instead of derivation from ETH pub key * tests for taskserver fixed * requirements + egg info * inline get_eth_addr() method * bump Golem-Messages to 3.7.0 * Fix golemcli account info * Don't allow use to chose Docker Toolbox if Hyper-V is installed (#4268) * Don't allow use to chose Docker Toolbox if Hyper-V is installed The installer doesn't support downgrading from Hyper-V to Docker Toolbox Therefore user should not be presented the dialog window prompting to select the preferred hypervisor if Hyper-V is already installed. * Changed installation condition for DockerForWin The old condition didn't use `HYPER_V_INSTALLED` variable so it would not install Docker binaries if Hyper-V had been already installed on user's machine. * Install hyperg from the 'simple-transfer' repo * Add missing field in walletoperation table * Fix volume mounting on windows (#4275) * fix incorrect volumes * fix: create work dir instead of mounting root * creating resource directory if not exists * copy all result files fix * support for multi resources * fix gvisor installation in the Linux installer * Install environment prerequisites When a task header with environment prerequistes is received provider will attempt to install the specified prerequisites and report positive support status only if the installation was succesful. * Unit tests for TaskHeaderKeeper.check_support() * Bump min HyperG version to 0.3.1 (#4299) * Docker: run hello-world after the service is restarted (#4298) Docker: provide more meaningful error message * Implemented subtasks restart cost estimation RPC This adds the RPC endpoint `comp.task.subtasks.estimated.cost`. It accepts an array of subtask IDs and returns a cost estimation for restarting the selected subtasks. * Added disable_concent flag to task restart RPC * Fixed funds validation for partial restarts * Created integration test for failed subtasks restart * Updated Golem-Messages to 3.8.0 Golem-Messages 3.8.0 version introduces environment_prerequisites field to TaskHeader. * Synchronize TaskPayment model with migrations * treat retrieval of an incompatible message from history as a "message… (#4305) * treat retrieval of an incompatible message from history as a "message not found" * Added config to node_integration_tests, ignoring non test directories (#4289) * Fix GLambda benchmark (#4306) * Fix GLambda benchmark * Add a comment explaining why set HOME for GLambda * sqlite, you're doing it wrong ;) * Update issue templates * bring `node_name` back to `SubtaskState` ... (#4312) * bring `node_name` back to `SubtaskState` ... * grant @maaktweluit's wish ;) * fix issue when nodekeeper doesn't have information about a payer node in the Income list (#4317) * Test DROP NOT NULL * Enhanced transcoding integration tests with ffprobe report (#4249) * StreamOperator: Implement get_metadata() * ffprobe_report: Implement FuzzyDuration and FuzzyInt * ffprobe_report: Implement FfprobeFormatReport * ffprobe_report: Implement stream report classes with a common base class * ffprobe_report: Add FfprobeFormatReport.stream_reports property * ffprobe_report: Cache stream reports in the object instead of rebuilding them every time * ffprobe_report: Rewrite diff() methods * StreamOperator.get_metadata(): Detect missing and invalid data returned by ffprobe * ffprobe_report: Add __repr__ for format and stream report classes * ffprobe_report: Don't duplicate return value if both resolution and widthxheight present but they're identical * Add a dependency on parameterized * ffprobe_report: Implement number_if_possible(), fuzzy_duration_if_possible(), fuzzy_int_if_possible() * ffprobe_report: Convert numeric properties to numbers when possible * ffprobe_report: Just fail if codec_type is missing - Lots of other things will fail if we just return None here and mypy can see that. Let's just fail earlier. * ffprobe_report: Add select_streams() * test_ffmpegintegration: Extract task definition into _create_task_def_for_transcoding() * test_ffmpegintegration: Extract part of the code into an intermediate base class * test_ffmpegintegration: Add optional parameters to _create_task_def_for_transcoding * ffprobe_report tests: Add file with sample raw reports to test FfprobeFormatReport * ffprobe_report tests: Unit tests for FfprobeFormatReport * ffprobe_report tests: Add raw report with mpeg4 video codec and 'nb_frames' field * StreamOperator.get_metadata(): accept work_dir and output_dir as parameters * Implement FfprobeReportSet * ffprobe_report: Accept path to the temporary directory in FfprobeFormatReport.build() and put results for each file in a separate subdirectory * ffprobe_report: Make video_paths a list in build() rather than variable argument list * ffprobe_report: Add 'excludes' parameter to diff() * ffprobe_report_set: Make information about mismatched streams more compact * Add more tests for FfprobeReport * Add tests for FfprobeReportSet * test_ffmpegintegration: Codec change tests * Implement SimulatedTranscodingOperation * ffprobe_report: Extract a function for parsing frame_rate * SimulatedTranscodingOperation: Use parse_ffprobe_frame_rate() for the frame rate * Add tests for SimulatedTranscodingOperation * test_ffmpegintegration: Tests for splitting into various numbers of segments * test_ffmpegintegration: Disable maxDiff in FfmpegIntegrationTestCase - To make sure that diffs from FfprobeFormatReport are shown in full * test_ffmpegintegration: Explicitly set codec and resolution in codec change and segment split tests - It used to be possible to transcode without setting resolution or codec explicitly but now this does not pass validations * test_ffmpegintegration: Don't compare bitrate and frame_count in codec change and segment split tests - Bitrate preservation is not reliable and thus not integrated into ffmpeg_tools yet - ffprobe reports wrong frame_count (100) for test_video.mp4 transcoded using the current method even though the actual numbe of frames in the file is correct (50). * test_ffmpegintegration: Resolution change tests * test_ffmpegintegration: Frame rate change tests * testutils: Define a custom exception type for a failing Docker job in TestTaskIntegration * test_ffmpegintegration: Explicitly set codec in resolution change tests * test_ffmpegintegration: Explicitly set codec and resolution in frame rate change tests * test_ffmpegintegration: Don't compare bitrate in resolution change tests - Bitrate preservation is not reliable and thus not integrated into ffmpeg_tools yet * test_ffmpegintegration: Don't compare bitrate in frame rate change tests - Bitrate preservation is not reliable and thus not integrated into ffmpeg_tools yet * Merge TestffmpegIntegration and FfmpegIntegrationTestCase back into a single test case - There's only one class derived from the base class so we don't really need this separation. It was meant as a starting point for further refactoring but eventually we just created helper classes for support code. - ci_skip does not seem to support the case where one class inherits from another and also uses class variables from that class. This breaks Windows and Mac OS tests in CI. Merging those classes lets us side-step the problem. * File structure fix - move files related to ffprobe reports to utils directory * Save FfprobeReportSet to a file in the test directory * Hard-coding important fixture values in tests instead of verifying them with assert * Constants for reason field in the diff * Rename streams: modified -> expected, original -> actual * Make == in FuzzyDuration and FuzzyInt still take tolerance into account even if only one of the values is fuzzy * SimulatedTranscodingOperation: Make _set_override() public * simulated_transcoding_operation: Fix output file name generation - The extension was not stripped correctly from the input file - In some cases there were two dots before the output file name * TestFfprobeFormatReport: Enable showing longer diffs in tests * simulated_transcoding_operation: Put container immediately after codec in input column names - This way sorting works better for the codec change test. Results with the same codec/container but different resolutions get grouped together. * simulated_transcoding_operation: Make it possible not to include some of the explicitly specified task options in the table column headers - This makes it possible to for example specify a different resolution for each file in codec change test and still have the results in a single column. Otherwise each resolution gets its own column. * test_ffmpegintegration: Use dont_include_in_option_description to get one column for each tested parameter combination * Move DiffReason to ffprobe_report * Replace all remaining hard-coded reason values with DiffReason * protect `comp.tasks.stats` from changes to the structure of subtasks' `extra_data` (#4321) * Migrate payment to taskpayment (#4297) * Migrate payment to taskpayment * Update migration numbering * add `eventlet` to requirements (#4322) * Remove the 'duration' field from RPC Task dict representation (#4327) * Add hook for numpy in pyinstaller * Use original hook-numpy-core.py from pyinstaller, but changed to the `DLLs\` folder * hmm... * Remove key difficulty pow (#4325) * Add paging to scripts/get-slow-argument.py * Split out key_reuse.py from base.py and conftest.py. * Added granary as a provider, enabled by hostname * review comments - fixed redunant if and print * - private logging function - better name for the key reuse singleton - added comments for all key reuse classes - better default password - better use of json.load / dump * - Split set_dir() from get() in NodeKeyReuseConfig - Improved logging, less private more public calls * cleaned up for loops, only take the variables we need * linter * fixes the `object has no attribute '_handshake_error'` error (#4331) * fixes the `object has no attribute '_handshake_error'` error * fix the logging message closes #4261 * add `apps/rendering/resources/taskcollector/Release/` to `.gitignore` (#4333) * bump hyperg requirement to `0.3.2` (#4335) * bump hyperg requirement to `0.3.2` * + display current version * add an artificial limit to `pay.payments` RPC to work around crossbar payload size limits before we implement: (#4338) todo: https://github.com/golemfactory/golem/issues/3970 todo: https://github.com/golemfactory/golem/issues/3971 * Finished rename of processed_ts to creation_date * Proper docker tag (1.5) for blender_verifier (#4336) * Updated golem-messages to v3.9.0 * Migrate deposit payments to TaskPayment * Migrate Incomes to TaskPayment * Review comment, use model.TaskPayment * Take test fix from #4342 * [review] migration * [review] Refactor ETS.IK tests * Move Ethereum RPC logic to its module (#4344) * WIP * expect_income() patch (#4346) expect_income() patch * Handling arbitrary args in _restart_task_error * WIP * fix of incorrect resources hierarchy (#4350) * fix of incorrect resources hierarchy * Prevent negative ETH estimations * fix the integration tests that rely on a different package (Blender s… (#4349) * fix the integration tests that rely on a different package (Blender scene) supplied to them (those tests stopped using those scenes and reverted to the default `test_task_1` after the last refactoring + add a specific integration test to test a blender scene composed of multiple `.blend` files * move the config to playbook (as, actually, the playbook is very closely bound with the scene anyway) * Fix HyperV warning events (#4353) * Task.initialize() executed in background (#4324) - task.rpc: run initialize_task and enqueue_new_task in another thread - task.taskstate: new 'creating' and 'errorCreating' TaskStatus vals - initial TaskStatus is 'creating', the task is remembered immediately - when task creation fails, TaskStatus is set to 'errorCreating' * fix tests * Disable Task.initialize execution timeout (#4354) * Add tests for subtask_accepted & get_incomes_list * + `test_large_result` node integration test optimization * Added unit test for eth_for_batch_payment * [review] Remove depositpayment * CODEOWNERS: @Wiezzel @maaktweluit * Rename deposit_payment to deposit_transfer * * reduce severity of mask mismatch * refactor `taskkeeper.check_support` tests :p * grant @jiivan's wish ;p * Moved requesting resources out of TaskComputer * add missing integration tests to pytest * + ownership of the concent-related code + ownership of the integration tests * additional logs for `comp.task.create` * fix tests * Made 'cubes' the default scene, this saves 200mb per test * Updated node_integration_tests to disable concent by default * Fixed low funds error not being returned by task restart RPC * Also initialize task resources on restarts * concent: verify concent_enabled flag in overdue payments Resolves #4304 * Fix dummy job test (#4373) * Add run_benchmark to environments (#4372) * Add run_benchmark to environments * Apply suggestions from code review Co-Authored-By: Adam Wierzbicki * Adjust operation type in forced payment * Refactored resource_collected and resource_failure The methods were moved to TaskServer while TaskComputer was added two new public methods: task_interrupted() and start_computation(). This wasy TaskComputer is agnostic of any resource management. * Fix docker image for cpu benchmark (#4383) * send srr if fgtrf is in history resolves #4216 * Cleaner logs in golem/task/rpc.py * Moved requesting tasks out of TaskComputer to TaskServer * Assert that assigned subtask is not None in _task_finished() * Moved interval checking to the top of _request_random_task() * + Docker image integrity verifier * + README.md for the dockerhub image verification script * lint... * fix readme * resolution of the dispute with @Krigpl * fix readme once again * + add `requests` to the specifically-required files in `requirements-test` * @Krigpl ... that better? ;p * Merge first step * Added video set to gitignore * Fix Docker benchmark (#4401) * Fix TestTaskIntegration to enable database usage and remove need of TestTaskManager * Fix tests by adding initialization step --- .github/CODEOWNERS | 11 + .github/ISSUE_TEMPLATE/bug-report.md | 8 +- .github/ISSUE_TEMPLATE/new-feature.md | 9 +- .gitignore | 8 + Installer/Installer_Linux/install.sh | 55 +- Installer/Installer_Win/Golem.aip | 96 +-- apps/blender/blenderenvironment.py | 4 +- .../resources/images/blender.Dockerfile | 2 +- .../resources/images/blender_nvgpu.Dockerfile | 2 +- .../images/blender_verifier.Dockerfile | 2 +- .../file_extension}/__init__.py | 0 .../verifier_tools/file_extension/matcher.py | 27 + .../verifier_tools/file_extension/types.py | 68 ++ .../scripts/verifier_tools/verificator.py | 30 +- apps/blender/task/blenderrendertask.py | 1 + apps/core/resources/images/base.Dockerfile | 14 +- apps/core/resources/images/nvgpu.Dockerfile | 12 - apps/core/task/coretask.py | 28 +- apps/core/task/coretaskstate.py | 38 +- apps/dummy/dummyenvironment.py | 2 +- apps/dummy/resources/images/Dockerfile | 2 +- apps/entrypoint.sh | 14 - apps/glambda/glambdaenvironment.py | 7 +- apps/glambda/resources/images/Dockerfile | 2 +- apps/glambda/task/glambdatask.py | 11 +- apps/images.ini | 14 +- apps/rendering/task/renderingtask.py | 4 - apps/wasm/environment.py | 2 +- apps/wasm/resources/images/Dockerfile | 2 +- apps/wasm/task.py | 13 + codecov.yml | 2 +- golem/appconfig.py | 4 +- golem/client.py | 104 +-- golem/clientconfigdescriptor.py | 21 +- golem/config/active.py | 10 +- golem/config/environments/mainnet.py | 71 +- golem/config/environments/testnet.py | 67 +- golem/core/deferred.py | 38 +- golem/core/keysauth.py | 85 +-- golem/core/processmonitor.py | 13 +- golem/core/simpleserializer.py | 52 +- golem/core/variables.py | 5 +- golem/database/database.py | 3 +- golem/database/schemas/007_schema.py | 6 +- golem/database/schemas/013_schema.py | 10 - golem/database/schemas/018_schema.py | 4 +- .../schemas/026_create_docker_whitelist.py | 22 + .../schemas/027_create_wallet_operation.py | 29 + .../schemas/028_create_task_payment.py | 28 + .../schemas/029_wallet_operation_type.py | 16 + .../schemas/030_wallet_operation_alter.py | 51 ++ .../031_migrate_payment_to_task_payment.py | 74 ++ .../032_migrate_income_to_task_payment.py | 79 ++ ...033_deposit_payment_to_wallet_operation.py | 72 ++ golem/docker/commands/docker.py | 7 +- golem/docker/hypervisor/__init__.py | 51 +- golem/docker/hypervisor/docker_machine.py | 15 +- golem/docker/hypervisor/dummy.py | 40 + golem/docker/hypervisor/hyperv.py | 56 +- golem/docker/hypervisor/virtualbox.py | 9 +- golem/docker/hypervisor/xhyve.py | 5 + golem/docker/job.py | 21 +- golem/docker/manager.py | 6 +- golem/docker/task_thread.py | 6 +- golem/environments/environment.py | 14 +- golem/envs/__init__.py | 526 +++++++++++++ golem/envs/docker/__init__.py | 41 + golem/envs/docker/benchmark/Dockerfile | 8 + golem/envs/docker/benchmark/entrypoint.py | 6 + .../docker/benchmark/minilight/__init__.py | 9 + .../benchmark/minilight/cornellbox.ml.txt | 0 .../benchmark/minilight/src/__init__.py | 0 .../docker}/benchmark/minilight/src/camera.py | 0 .../docker}/benchmark/minilight/src/image.py | 0 .../docker}/benchmark/minilight/src/img.py | 0 .../benchmark/minilight/src/maxilight.py | 0 .../benchmark/minilight/src/minilight.py | 0 .../benchmark/minilight/src/randommini.py | 0 .../benchmark/minilight/src/raytracer.py | 0 .../benchmark/minilight/src/rendertask.py | 0 .../minilight/src/rendertaskcreator.py | 0 .../benchmark/minilight/src/renderworker.py | 0 .../docker}/benchmark/minilight/src/scene.py | 0 .../benchmark/minilight/src/spatialindex.py | 0 .../benchmark/minilight/src/surfacepoint.py | 0 .../benchmark/minilight/src/task_data_0.py | 0 .../benchmark/minilight/src/taskablelight.py | 0 .../minilight/src/taskablerenderer.py | 0 .../benchmark/minilight/src/triangle.py | 0 .../benchmark/minilight/src/vector3f.py | 0 golem/envs/docker/cpu.py | 705 ++++++++++++++++++ golem/envs/docker/non_hypervised.py | 14 + golem/envs/docker/whitelist.py | 34 + golem/envs/manager.py | 46 ++ golem/ethereum/incomeskeeper.py | 136 ++-- golem/ethereum/paymentprocessor.py | 206 +++-- golem/ethereum/paymentskeeper.py | 104 +-- golem/ethereum/transactionsystem.py | 141 ++-- golem/interface/cli.py | 2 +- golem/interface/client/account.py | 15 +- golem/interface/client/payments.py | 7 +- golem/interface/client/tasks.py | 5 +- golem/manager/nodestatesnapshot.py | 23 +- golem/model.py | 251 +++---- golem/monitor/model/nodemetadatamodel.py | 4 +- golem/network/concent/client.py | 12 +- golem/network/concent/received_handler.py | 30 +- .../concent/resources/ssl/certs/staging.crt | 44 +- .../concent/resources/ssl/certs/test.crt | 44 +- golem/network/history.py | 8 +- golem/network/hyperdrive/daemon_manager.py | 6 +- golem/network/p2p/p2pservice.py | 4 +- golem/network/p2p/peersession.py | 21 +- golem/network/transport/tcpnetwork.py | 2 +- golem/node.py | 22 +- golem/report.py | 28 +- golem/resource/resourcehandshake.py | 1 - golem/rpc/api/ethereum_.py | 125 ++++ golem/rpc/router.py | 45 +- golem/rpc/session.py | 11 +- golem/rpc/utils.py | 14 + golem/task/benchmarkmanager.py | 2 + golem/task/rpc.py | 397 +++++++--- golem/task/server/helpers.py | 15 +- golem/task/server/resources.py | 13 +- golem/task/server/verification.py | 58 +- golem/task/taskbase.py | 2 +- golem/task/taskcomputer.py | 177 ++--- golem/task/taskkeeper.py | 125 +++- golem/task/taskmanager.py | 135 ++-- golem/task/taskserver.py | 150 +++- golem/task/tasksession.py | 97 ++- golem/task/taskstate.py | 30 +- golem/task/timer.py | 4 +- golem/testutils.py | 76 +- golem/tools/talkback.py | 5 +- golem/tools/testchildprocesses.py | 27 + golem/tools/testwithreactor.py | 7 +- golem/utils.py | 4 - golem/verificator/blender_verifier.py | 40 +- golemapp.py | 31 +- requirements-lint.txt | 12 +- requirements-lint_to-freeze.txt | 1 + requirements-test.txt | 9 +- requirements-test_to-freeze.txt | 3 + requirements.txt | 26 +- requirements_to-freeze.txt | 16 +- .../additional_verification/base.py | 34 +- .../additional_verification/test_messages.py | 24 +- scripts/concent_acceptance_tests/base.py | 51 +- .../basic/test_basic.py | 4 +- .../test_requestor_doesnt_send.py | 38 +- .../force_download/base.py | 2 +- .../force_download/test_messages.py | 3 +- .../force_payment/test_payment.py | 43 +- .../force_report/test_messages.py | 26 +- scripts/docker/clean-docker-vm.ps1 | 10 +- scripts/docker/create-share.ps1 | 27 +- scripts/docker_integrity/README.md | 33 + scripts/docker_integrity/image_integrity.ini | 12 + scripts/docker_integrity/verify.py | 242 ++++++ scripts/get-slow-argument.py | 42 +- scripts/node_integration_tests/conftest.py | 46 +- scripts/node_integration_tests/helpers.py | 28 +- scripts/node_integration_tests/key_reuse.py | 263 +++++++ .../nodes/json_serializer.py | 15 + .../node_integration_tests/playbooks/base.py | 145 ++-- .../additional_verification/playbook.py | 2 + .../additional_verification/test_config.py | 5 +- .../playbooks/concent/concent_base.py | 6 +- .../playbooks/concent/concent_config_base.py | 8 + .../concent/force_accept/playbook.py | 3 +- .../concent/force_accept/test_config.py | 5 +- .../concent/force_download/test_config.py | 5 +- .../concent/force_payment/playbook.py | 5 +- .../concent/force_payment/test_config.py | 6 +- .../concent/force_report/test_config.py | 5 +- .../golem/{no_concent.py => concent.py} | 2 +- .../playbooks/golem/disabled_verification.py | 18 + .../playbooks/golem/fake_result.png | Bin 0 -> 950 bytes .../playbooks/golem/jpg/test_config.py | 11 + .../__init__.py | 0 .../golem/json_serializer/playbook.py | 26 + .../golem/json_serializer/test_config.py | 13 + .../golem/lenient_verification/__init__.py | 0 .../golem/lenient_verification/playbook.py | 66 ++ .../golem/lenient_verification/test_config.py | 24 + .../golem/multinode_regular_run/playbook.py | 3 +- .../multinode_regular_run/test_config.py | 3 + .../playbooks/golem/nested_column/__init__.py | 0 .../playbooks/golem/nested_column/playbook.py | 1 + .../golem/nested_column/test_config.py | 14 + .../golem/restart_failed_subtasks/__init__.py | 0 .../golem/restart_failed_subtasks/playbook.py | 56 ++ .../restart_failed_subtasks/test_config.py | 25 + .../playbooks/golem/restart_frame/playbook.py | 1 - .../golem/rpc_test/mainnet/playbook.py | 4 +- .../golem/rpc_test/mainnet/test_config.py | 2 +- .../golem/rpc_test/no_concent/test_config.py | 8 - .../golem/rpc_test/testnet/__init__.py | 0 .../{no_concent => testnet}/playbook.py | 0 .../golem/rpc_test/testnet/test_config.py | 5 + .../playbooks/golem/separate_hyperg.py | 8 + .../playbooks/golem/task_timeout/playbook.py | 15 +- .../playbooks/golem/zero_price.py | 3 + .../playbooks/test_config_base.py | 18 +- scripts/node_integration_tests/pytest.ini | 2 + scripts/node_integration_tests/run_test.py | 2 + .../node_integration_tests/tasks/__init__.py | 40 +- .../tasks/column/assets/altocumulus.jpg | Bin 0 -> 113195 bytes .../tasks/column/assets/column.blend | Bin 0 -> 435592 bytes .../tasks/column/assets/jello.blend | Bin 0 -> 466716 bytes .../tasks/column/the_column.blend | Bin 0 -> 531792 bytes .../tasks/cubes/cubes.blend | Bin 0 -> 513204 bytes scripts/node_integration_tests/tests/base.py | 89 +-- .../tests/test_fails.py | 3 + .../tests/test_golem.py | 25 +- scripts/pyinstaller/hooks/hook-eventlet.py | 3 + scripts/pyinstaller/hooks/hook-golem.py | 5 +- scripts/pyinstaller/hooks/hook-numpy.core.py | 35 + scripts/test-daemon-start.sh | 3 + scripts/test-daemon-stop.sh | 1 + .../check-hyperv-installation.ps1 | 17 + setup.py | 5 +- .../benchmark/test_blenderbenchmark.py | 1 + .../blender/task/test_blenderrendertask.py | 13 +- .../verification/test_extension_matcher.py | 31 + tests/apps/ffmpeg/task/test_ffmpegtask.py | 10 +- tests/factories/granary.py | 78 ++ tests/factories/model.py | 50 +- tests/factories/task/taskstate.py | 2 +- tests/factories/taskserver.py | 13 +- tests/golem/core/keygen_benchmark.py | 21 - tests/golem/core/test_deferred.py | 89 ++- tests/golem/core/test_keysauth.py | 81 +- tests/golem/core/test_simpleserializer.py | 15 + tests/golem/database/test_migration.py | 175 ++++- .../docker/docker-blender-cycles-task.json | 11 +- .../docker/docker-blender-render-task.json | 11 +- .../golem/docker/docker-dummy-test-task.json | 3 +- tests/golem/docker/test_blender_job.py | 2 +- .../golem/docker/test_docker_blender_task.py | 3 +- tests/golem/docker/test_docker_dummy_task.py | 4 +- tests/golem/docker/test_docker_environment.py | 2 +- tests/golem/docker/test_docker_job.py | 1 - tests/golem/docker/test_docker_manager.py | 2 +- tests/golem/docker/test_docker_task.py | 14 +- tests/golem/docker/test_docker_task_thread.py | 12 +- tests/golem/docker/test_dummy_job.py | 2 +- tests/golem/docker/test_hyperv.py | 24 +- tests/golem/docker/test_hypervisor.py | 35 +- tests/golem/envs/__init__.py | 0 tests/golem/envs/docker/__init__.py | 0 tests/golem/envs/docker/cpu/__init__.py | 0 tests/golem/envs/docker/cpu/test_config.py | 57 ++ tests/golem/envs/docker/cpu/test_env.py | 591 +++++++++++++++ tests/golem/envs/docker/cpu/test_input.py | 28 + .../envs/docker/cpu/test_input_socket.py | 87 +++ .../golem/envs/docker/cpu/test_integration.py | 94 +++ tests/golem/envs/docker/cpu/test_output.py | 45 ++ tests/golem/envs/docker/cpu/test_runtime.py | 700 +++++++++++++++++ tests/golem/envs/docker/test_payload.py | 67 ++ tests/golem/envs/docker/test_prerequisites.py | 41 + tests/golem/envs/docker/test_whitelist.py | 24 + tests/golem/envs/test_env.py | 120 +++ tests/golem/envs/test_manager.py | 81 ++ tests/golem/envs/test_runtime.py | 102 +++ tests/golem/ethereum/test_incomeskeeper.py | 171 +++-- tests/golem/ethereum/test_paymentprocessor.py | 258 ++++--- tests/golem/ethereum/test_paymentskeeper.py | 55 +- .../golem/ethereum/test_transactionsystem.py | 165 +++- tests/golem/interface/test_client_commands.py | 21 +- .../network/concent/test_concent_client.py | 13 +- .../network/concent/test_received_handler.py | 51 +- tests/golem/network/p2p/test_peersession.py | 46 +- tests/golem/network/test_history.py | 42 +- .../golem/resource/test_resourcehandshake.py | 5 +- tests/golem/rpc/api/test_ethereum.py | 91 +++ tests/golem/rpc/test_router.py | 26 +- tests/golem/task/dummy/runner.py | 59 +- tests/golem/task/dummy/test_runner_script.py | 16 +- tests/golem/task/server/test_helpers.py | 1 - tests/golem/task/server/test_queue.py | 5 +- tests/golem/task/server/test_resources.py | 69 +- tests/golem/task/test_concent_logic.py | 16 +- tests/golem/task/test_rpc.py | 402 +++++++--- tests/golem/task/test_taskcomputer.py | 443 ++++++++--- tests/golem/task/test_taskkeeper.py | 509 ++++++++++--- tests/golem/task/test_taskmanager.py | 98 ++- tests/golem/task/test_taskserver.py | 398 +++++++--- tests/golem/task/test_tasksession.py | 70 +- tests/golem/test_client.py | 109 +-- tests/golem/test_model.py | 113 +-- tests/golem/test_opt_node.py | 46 +- tests/golem/test_utils.py | 11 +- tests/golem/vm/test_memorychecker.py | 102 ++- tests/test_clientconfigdescriptor.py | 10 - 297 files changed, 10482 insertions(+), 3121 deletions(-) create mode 100644 .github/CODEOWNERS rename apps/{rendering/benchmark/minilight => blender/resources/images/entrypoints/scripts/verifier_tools/file_extension}/__init__.py (100%) create mode 100644 apps/blender/resources/images/entrypoints/scripts/verifier_tools/file_extension/matcher.py create mode 100644 apps/blender/resources/images/entrypoints/scripts/verifier_tools/file_extension/types.py delete mode 100644 apps/entrypoint.sh create mode 100644 golem/database/schemas/026_create_docker_whitelist.py create mode 100644 golem/database/schemas/027_create_wallet_operation.py create mode 100644 golem/database/schemas/028_create_task_payment.py create mode 100644 golem/database/schemas/029_wallet_operation_type.py create mode 100644 golem/database/schemas/030_wallet_operation_alter.py create mode 100644 golem/database/schemas/031_migrate_payment_to_task_payment.py create mode 100644 golem/database/schemas/032_migrate_income_to_task_payment.py create mode 100644 golem/database/schemas/033_deposit_payment_to_wallet_operation.py create mode 100644 golem/docker/hypervisor/dummy.py create mode 100644 golem/envs/__init__.py create mode 100644 golem/envs/docker/__init__.py create mode 100644 golem/envs/docker/benchmark/Dockerfile create mode 100644 golem/envs/docker/benchmark/entrypoint.py create mode 100644 golem/envs/docker/benchmark/minilight/__init__.py rename {apps/rendering => golem/envs/docker}/benchmark/minilight/cornellbox.ml.txt (100%) rename {apps/rendering => golem/envs/docker}/benchmark/minilight/src/__init__.py (100%) rename {apps/rendering => golem/envs/docker}/benchmark/minilight/src/camera.py (100%) rename {apps/rendering => golem/envs/docker}/benchmark/minilight/src/image.py (100%) rename {apps/rendering => golem/envs/docker}/benchmark/minilight/src/img.py (100%) rename {apps/rendering => golem/envs/docker}/benchmark/minilight/src/maxilight.py (100%) rename {apps/rendering => golem/envs/docker}/benchmark/minilight/src/minilight.py (100%) rename {apps/rendering => golem/envs/docker}/benchmark/minilight/src/randommini.py (100%) rename {apps/rendering => golem/envs/docker}/benchmark/minilight/src/raytracer.py (100%) rename {apps/rendering => golem/envs/docker}/benchmark/minilight/src/rendertask.py (100%) rename {apps/rendering => golem/envs/docker}/benchmark/minilight/src/rendertaskcreator.py (100%) rename {apps/rendering => golem/envs/docker}/benchmark/minilight/src/renderworker.py (100%) rename {apps/rendering => golem/envs/docker}/benchmark/minilight/src/scene.py (100%) rename {apps/rendering => golem/envs/docker}/benchmark/minilight/src/spatialindex.py (100%) rename {apps/rendering => golem/envs/docker}/benchmark/minilight/src/surfacepoint.py (100%) rename {apps/rendering => golem/envs/docker}/benchmark/minilight/src/task_data_0.py (100%) rename {apps/rendering => golem/envs/docker}/benchmark/minilight/src/taskablelight.py (100%) rename {apps/rendering => golem/envs/docker}/benchmark/minilight/src/taskablerenderer.py (100%) rename {apps/rendering => golem/envs/docker}/benchmark/minilight/src/triangle.py (100%) rename {apps/rendering => golem/envs/docker}/benchmark/minilight/src/vector3f.py (100%) create mode 100644 golem/envs/docker/cpu.py create mode 100644 golem/envs/docker/non_hypervised.py create mode 100644 golem/envs/docker/whitelist.py create mode 100644 golem/envs/manager.py create mode 100644 golem/rpc/api/ethereum_.py create mode 100644 golem/tools/testchildprocesses.py create mode 100644 scripts/docker_integrity/README.md create mode 100644 scripts/docker_integrity/image_integrity.ini create mode 100755 scripts/docker_integrity/verify.py create mode 100644 scripts/node_integration_tests/key_reuse.py create mode 100644 scripts/node_integration_tests/nodes/json_serializer.py create mode 100644 scripts/node_integration_tests/playbooks/concent/concent_config_base.py rename scripts/node_integration_tests/playbooks/golem/{no_concent.py => concent.py} (80%) create mode 100644 scripts/node_integration_tests/playbooks/golem/disabled_verification.py create mode 100644 scripts/node_integration_tests/playbooks/golem/fake_result.png rename scripts/node_integration_tests/playbooks/golem/{rpc_test/no_concent => json_serializer}/__init__.py (100%) create mode 100644 scripts/node_integration_tests/playbooks/golem/json_serializer/playbook.py create mode 100644 scripts/node_integration_tests/playbooks/golem/json_serializer/test_config.py create mode 100644 scripts/node_integration_tests/playbooks/golem/lenient_verification/__init__.py create mode 100644 scripts/node_integration_tests/playbooks/golem/lenient_verification/playbook.py create mode 100644 scripts/node_integration_tests/playbooks/golem/lenient_verification/test_config.py create mode 100644 scripts/node_integration_tests/playbooks/golem/nested_column/__init__.py create mode 100644 scripts/node_integration_tests/playbooks/golem/nested_column/playbook.py create mode 100644 scripts/node_integration_tests/playbooks/golem/nested_column/test_config.py create mode 100644 scripts/node_integration_tests/playbooks/golem/restart_failed_subtasks/__init__.py create mode 100644 scripts/node_integration_tests/playbooks/golem/restart_failed_subtasks/playbook.py create mode 100644 scripts/node_integration_tests/playbooks/golem/restart_failed_subtasks/test_config.py delete mode 100644 scripts/node_integration_tests/playbooks/golem/rpc_test/no_concent/test_config.py create mode 100644 scripts/node_integration_tests/playbooks/golem/rpc_test/testnet/__init__.py rename scripts/node_integration_tests/playbooks/golem/rpc_test/{no_concent => testnet}/playbook.py (100%) create mode 100644 scripts/node_integration_tests/playbooks/golem/rpc_test/testnet/test_config.py create mode 100644 scripts/node_integration_tests/playbooks/golem/separate_hyperg.py create mode 100644 scripts/node_integration_tests/pytest.ini create mode 100644 scripts/node_integration_tests/tasks/column/assets/altocumulus.jpg create mode 100644 scripts/node_integration_tests/tasks/column/assets/column.blend create mode 100644 scripts/node_integration_tests/tasks/column/assets/jello.blend create mode 100644 scripts/node_integration_tests/tasks/column/the_column.blend create mode 100644 scripts/node_integration_tests/tasks/cubes/cubes.blend create mode 100644 scripts/node_integration_tests/tests/test_fails.py create mode 100644 scripts/pyinstaller/hooks/hook-eventlet.py create mode 100644 scripts/pyinstaller/hooks/hook-numpy.core.py create mode 100644 scripts/virtualization/check-hyperv-installation.ps1 create mode 100644 tests/apps/blender/verification/test_extension_matcher.py create mode 100644 tests/factories/granary.py delete mode 100644 tests/golem/core/keygen_benchmark.py create mode 100644 tests/golem/envs/__init__.py create mode 100644 tests/golem/envs/docker/__init__.py create mode 100644 tests/golem/envs/docker/cpu/__init__.py create mode 100644 tests/golem/envs/docker/cpu/test_config.py create mode 100644 tests/golem/envs/docker/cpu/test_env.py create mode 100644 tests/golem/envs/docker/cpu/test_input.py create mode 100644 tests/golem/envs/docker/cpu/test_input_socket.py create mode 100644 tests/golem/envs/docker/cpu/test_integration.py create mode 100644 tests/golem/envs/docker/cpu/test_output.py create mode 100644 tests/golem/envs/docker/cpu/test_runtime.py create mode 100644 tests/golem/envs/docker/test_payload.py create mode 100644 tests/golem/envs/docker/test_prerequisites.py create mode 100644 tests/golem/envs/docker/test_whitelist.py create mode 100644 tests/golem/envs/test_env.py create mode 100644 tests/golem/envs/test_manager.py create mode 100644 tests/golem/envs/test_runtime.py create mode 100644 tests/golem/rpc/api/test_ethereum.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..4c8dfa4926 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,11 @@ +/golem/docker/ @Wiezzel +/golem/ethereum/ @Krigpl +/golem/envs/ @Wiezzel +/golem/network/concent/ @shadeofblue @jiivan + +/scripts/concent_acceptance_tests/ @shadeofblue +/scripts/docker/ @Wiezzel +/scripts/node_integration_tests/ @shadeofblue +/scripts/virtualization/ @Wiezzel + +/Installer/Installer_Win/ @Wiezzel @maaktweluit diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index 7f88b0ae4b..544e74e646 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -2,7 +2,7 @@ name: Bug report about: Please tell us if you encountered an issue with Golem title: '' -labels: brass, bug +labels: P3, brass, bug assignees: ZmijaWA --- @@ -21,6 +21,12 @@ assignees: ZmijaWA **Mainnet/Testnet**: +**Priority label is set to the lowest by default. To setup higher priority please change the label** +_P0 label is set for Severity-Critical/Effort-easy +P1 label is set for Severity-Critical/Effort-hard +P2 label is set for Severity-Low/ Effort-easy +P3 label is set for Severity-Low/Effort-hard_ + **Description of the issue**: _A clear and concise description of what went wrong, in which component, when and where._ diff --git a/.github/ISSUE_TEMPLATE/new-feature.md b/.github/ISSUE_TEMPLATE/new-feature.md index 4aaea2aa3c..751cf47911 100644 --- a/.github/ISSUE_TEMPLATE/new-feature.md +++ b/.github/ISSUE_TEMPLATE/new-feature.md @@ -2,7 +2,7 @@ name: New feature about: 'Define a new feature / change for Golem ' title: '' -labels: '' +labels: P3 assignees: '' --- @@ -99,6 +99,13 @@ _Add test scenarios for the new feature_ * [ ] Concent integration tests pass * [ ] Concent acceptance tests pass + +**Please choose the priority label for QA. It is set to the lowest by default. To setup higher priority please change the label** +_P0 label is set for Severity-Critical/Effort-easy +P1 label is set for Severity-Critical/Effort-hard +P2 label is set for Severity-Low/ Effort-easy +P3 label is set for Severity-Low/Effort-hard_ + ### QA team * [ ] Base scenario passes * [ ] Additional test 1 passes... diff --git a/.gitignore b/.gitignore index ed6624ec6c..14d667f51a 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,7 @@ yacctab.py data/ # Install and build +apps/rendering/resources/taskcollector/x64/Release/* apps/rendering/resources/taskcollector/Release/* apps/rendering/resources/taskcollector/taskcollector.vcxproj Installer/Installer_Win/deps/* @@ -81,3 +82,10 @@ apps/dummy/test_tmp .python-version luxrender-output.png .testmondata + +# video extended test set +tests/apps/ffmpeg/resources/videos/ + +# developer tools +.vscode/ + diff --git a/Installer/Installer_Linux/install.sh b/Installer/Installer_Linux/install.sh index 3f2fa4338d..58eaa99c17 100755 --- a/Installer/Installer_Linux/install.sh +++ b/Installer/Installer_Linux/install.sh @@ -3,8 +3,8 @@ #description :This script will install Golem and required dependencies #author :Golem Team #email :contact@golem.network -#date :20190111 -#version :0.5 +#date :20190606 +#version :0.6 #usage :sh install.sh #notes :Only for Ubuntu and Mint #============================================================================== @@ -14,7 +14,7 @@ declare -r PYTHON=python3 declare -r HOME=$(readlink -f ~) declare -r GOLEM_DIR="$HOME/golem" declare -r PACKAGE="golem-linux.tar.gz" -declare -r HYPERG_PACKAGE=/tmp/hyperg.tar.gz +declare -r HYPERG_PACKAGE="hyperg.tar.gz" declare -r ELECTRON_PACKAGE="electron.tar.gz" # Questions @@ -126,11 +126,8 @@ function check_dependencies() info_msg "Already installed: nvidia-docker2" fi - if [[ -z "$(which runsc)" ]]; then - INSTALL_GVISOR_RUNTIME=1 - else - info_msg "Already installed: gvisor runsc" - fi + # Installer will overwrite existing /usr/local/bin/runsc + INSTALL_GVISOR_RUNTIME=1 # Check for nvidia-modprobe if [[ ${INSTALL_NVIDIA_DOCKER} -eq 1 ]]; then @@ -305,7 +302,7 @@ function install_dependencies() ! sudo apt-mark hold nvidia-docker2 docker-ce fi - declare -r hyperg=$(release_url "https://api.github.com/repos/golemfactory/golem-hyperdrive/releases") + declare -r hyperg=$(release_url "https://api.github.com/repos/golemfactory/simple-transfer/releases") hyperg_release=$( echo ${hyperg} | cut -d '/' -f 8 | sed 's/v//' ) # Older version of HyperG doesn't have `--version`, so need to kill ( hyperg_version=$( hyperg --version 2>/dev/null ) ) & pid=$! @@ -321,10 +318,10 @@ function install_dependencies() wget --show-progress -qO- ${hyperg} > ${HYPERG_PACKAGE} info_msg "Installing HyperG into $HOME/hyperg" [[ -d $HOME/hyperg ]] && rm -rf $HOME/hyperg - tar -xvf ${HYPERG_PACKAGE} >/dev/null - [[ "$PWD" != "$HOME" ]] && mv hyperg $HOME/ + hyperg_dir=$(tar -tzf ${HYPERG_PACKAGE} | head -1 | cut -f1 -d"/") + tar -xf ${HYPERG_PACKAGE} > /dev/null + mv ${hyperg_dir} $HOME/hyperg [[ ! -f /usr/local/bin/hyperg ]] && sudo ln -s $HOME/hyperg/hyperg /usr/local/bin/hyperg - [[ ! -f /usr/local/bin/hyperg-worker ]] && sudo ln -s $HOME/hyperg/hyperg-worker /usr/local/bin/hyperg-worker rm -f ${HYPERG_PACKAGE} &>/dev/null fi @@ -335,28 +332,17 @@ function install_dependencies() else sudo usermod -aG docker ${SUDO_USER} fi - sudo docker run hello-world &>/dev/null - if [[ ${?} -eq 0 ]]; then - info_msg "Docker installed successfully" - else - warning_msg "Error occurred during installation" - sleep 5s - fi - fi - - if [[ ${INSTALL_NVIDIA_DOCKER} -eq 1 ]]; then - sudo pkill -SIGHUP dockerd fi if [[ ${INSTALL_GVISOR_RUNTIME} -eq 1 ]]; then - # TODO: replace `latest` with fixed version - wget https://storage.googleapis.com/gvisor/releases/nightly/latest/runsc - wget https://storage.googleapis.com/gvisor/releases/nightly/latest/runsc.sha512 + wget https://storage.googleapis.com/gvisor/releases/nightly/2019-04-01/runsc + wget https://storage.googleapis.com/gvisor/releases/nightly/2019-04-01/runsc.sha512 sha512sum -c runsc.sha512 rm runsc.sha512 # Add runtime configuration - sudo python << EOF + sudo mkdir -p /etc/docker + sudo ${PYTHON} << EOF import json runtime_config = { @@ -375,11 +361,24 @@ with open('/etc/docker/daemon.json', 'w+') as f: daemon_json['runtimes']['runsc'] = runtime_config json.dump(daemon_json, f, indent=4) EOF - sudo service docker restart chmod a+x runsc sudo mv runsc /usr/local/bin fi + sudo service docker restart || true + + if [[ ${INSTALL_DOCKER} -eq 1 ]]; then + output=$(sudo docker run hello-world) + exit_code=${?} + + if [[ ${exit_code} -eq 0 ]]; then + info_msg "Docker installed successfully" + else + warning_msg "Docker installation error: 'docker run hello-world' failed with exit code ${exit_code}" + warning_msg "${output}" + sleep 5s + fi + fi } # @brief Download latest Golem package (if package wasn't passed) diff --git a/Installer/Installer_Win/Golem.aip b/Installer/Installer_Win/Golem.aip index 83c7b303dc..c3c49ee4e9 100644 --- a/Installer/Installer_Win/Golem.aip +++ b/Installer/Installer_Win/Golem.aip @@ -14,19 +14,21 @@ + + - + - + @@ -78,7 +80,7 @@ - + @@ -134,7 +136,6 @@ - @@ -144,6 +145,7 @@ + @@ -159,7 +161,7 @@ - + @@ -220,7 +222,7 @@ - + @@ -229,11 +231,10 @@ - - + + - - + @@ -247,7 +248,7 @@ - + @@ -258,6 +259,7 @@ + @@ -299,8 +301,9 @@ + + - @@ -404,6 +407,7 @@ + @@ -411,7 +415,7 @@ - + @@ -466,10 +470,10 @@ - + @@ -510,7 +514,7 @@ - + @@ -561,47 +565,47 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - + + - - + + - - + + + + - - - + + + @@ -609,13 +613,13 @@ - - - - - - + + + + + + diff --git a/apps/blender/blenderenvironment.py b/apps/blender/blenderenvironment.py index 88959796d8..7e6d4d5bf7 100644 --- a/apps/blender/blenderenvironment.py +++ b/apps/blender/blenderenvironment.py @@ -9,7 +9,7 @@ class BlenderEnvironment(DockerEnvironment): DOCKER_IMAGE = "golemfactory/blender" - DOCKER_TAG = "1.9" + DOCKER_TAG = "1.10" ENV_ID = "BLENDER" SHORT_DESCRIPTION = "Blender (www.blender.org)" @@ -17,7 +17,7 @@ class BlenderEnvironment(DockerEnvironment): class BlenderNVGPUEnvironment(BlenderEnvironment): DOCKER_IMAGE = "golemfactory/blender_nvgpu" - DOCKER_TAG = "1.3" + DOCKER_TAG = "1.4" ENV_ID = "BLENDER_NVGPU" SHORT_DESCRIPTION = "Blender + NVIDIA GPU (www.blender.org)" diff --git a/apps/blender/resources/images/blender.Dockerfile b/apps/blender/resources/images/blender.Dockerfile index e903e02977..4d73b04537 100644 --- a/apps/blender/resources/images/blender.Dockerfile +++ b/apps/blender/resources/images/blender.Dockerfile @@ -2,7 +2,7 @@ # Blender setup is based on # https://github.com/ikester/blender-docker/blob/master/Dockerfile -FROM golemfactory/base:1.4 +FROM golemfactory/base:1.5 MAINTAINER Golem Tech diff --git a/apps/blender/resources/images/blender_nvgpu.Dockerfile b/apps/blender/resources/images/blender_nvgpu.Dockerfile index 5d375c53f7..55f13ee57d 100644 --- a/apps/blender/resources/images/blender_nvgpu.Dockerfile +++ b/apps/blender/resources/images/blender_nvgpu.Dockerfile @@ -1,4 +1,4 @@ -FROM golemfactory/nvgpu:1.3 +FROM golemfactory/nvgpu:1.4 # Contents of blender.Dockerfile diff --git a/apps/blender/resources/images/blender_verifier.Dockerfile b/apps/blender/resources/images/blender_verifier.Dockerfile index 183ca5c6ad..a3a11040a4 100644 --- a/apps/blender/resources/images/blender_verifier.Dockerfile +++ b/apps/blender/resources/images/blender_verifier.Dockerfile @@ -1,4 +1,4 @@ -FROM golemfactory/blender:1.9 +FROM golemfactory/blender:1.10 # Install scripts requirements first, then add scripts. ADD entrypoints/scripts/verifier_tools/requirements.txt /golem/work/ diff --git a/apps/rendering/benchmark/minilight/__init__.py b/apps/blender/resources/images/entrypoints/scripts/verifier_tools/file_extension/__init__.py similarity index 100% rename from apps/rendering/benchmark/minilight/__init__.py rename to apps/blender/resources/images/entrypoints/scripts/verifier_tools/file_extension/__init__.py diff --git a/apps/blender/resources/images/entrypoints/scripts/verifier_tools/file_extension/matcher.py b/apps/blender/resources/images/entrypoints/scripts/verifier_tools/file_extension/matcher.py new file mode 100644 index 0000000000..83f9dfd0b3 --- /dev/null +++ b/apps/blender/resources/images/entrypoints/scripts/verifier_tools/file_extension/matcher.py @@ -0,0 +1,27 @@ +from . import types + +FILE_TYPES = [ + types.Bmp(), + types.Jpeg(), + types.Tga() +] + + +def get_expected_extension(extension: str) -> str: + """ + Based on the provided file extension string, returns the expected alias + extension that Blender will use for its output. This can be used to avoid + output file names mismatch (e.g. .jpg vs .jpeg extensions). The check is + based on a predefined list of file types. + :param extension: file extension string (with leading dot) to check + against. + :return: expected output file extension (lowercase, with leading dot). + Returns the provided file extension if no alias was found. + """ + lower_extension = extension.lower() + + for file_type in FILE_TYPES: + if lower_extension in file_type.extensions: + return file_type.output_extension + + return lower_extension diff --git a/apps/blender/resources/images/entrypoints/scripts/verifier_tools/file_extension/types.py b/apps/blender/resources/images/entrypoints/scripts/verifier_tools/file_extension/types.py new file mode 100644 index 0000000000..29c66eb7f9 --- /dev/null +++ b/apps/blender/resources/images/entrypoints/scripts/verifier_tools/file_extension/types.py @@ -0,0 +1,68 @@ +import abc +import typing + + +class FileType(abc.ABC): + @property + @abc.abstractmethod + def extensions(self) -> typing.AbstractSet[str]: + """ + List of possible extensions (aliases) for the represented file type. + :return: a set of strings, each prefixed with a dot. + """ + pass + + @property + @abc.abstractmethod + def output_extension(self) -> str: + """ + Returns the file extension expected to be used by Blender for its + output files of the represented file type. + :return: a file extension string, prefixed with a dot. + """ + pass + + +class Bmp(FileType): + @property + def extensions(self) -> typing.AbstractSet[str]: + return frozenset([ + '.bmp', + '.dib' + ]) + + @property + def output_extension(self) -> str: + return '.bmp' + + +class Jpeg(FileType): + @property + def extensions(self) -> typing.AbstractSet[str]: + return frozenset([ + '.jpg', + '.jpeg', + '.jpe', + '.jif', + '.jfif', + '.jfi' + ]) + + @property + def output_extension(self) -> str: + return '.jpg' + + +class Tga(FileType): + @property + def extensions(self) -> typing.AbstractSet[str]: + return frozenset([ + '.tga', + '.icb', + '.vda', + '.vst' + ]) + + @property + def output_extension(self) -> str: + return '.tga' diff --git a/apps/blender/resources/images/entrypoints/scripts/verifier_tools/verificator.py b/apps/blender/resources/images/entrypoints/scripts/verifier_tools/verificator.py index 3238d4f56e..0c965cb6fe 100644 --- a/apps/blender/resources/images/entrypoints/scripts/verifier_tools/verificator.py +++ b/apps/blender/resources/images/entrypoints/scripts/verifier_tools/verificator.py @@ -1,9 +1,11 @@ import json import os +from pathlib import Path from typing import List, Optional from ..render_tools import blender_render as blender from .crop_generator import WORK_DIR, OUTPUT_DIR, SubImage, Region, PixelRegion, \ generate_single_random_crop_data, Crop +from .file_extension.matcher import get_expected_extension from .img_metrics_calculator import calculate_metrics def get_crop_with_id(id: int, crops: [List[Crop]]) -> Optional[Crop]: @@ -77,7 +79,7 @@ def make_verdict( subtask_file_paths, crops, results ): print("top " + str(top)) for crop, subtask in zip(crop_data['results'], subtask_file_paths): - crop_path = os.path.join(OUTPUT_DIR, crop) + crop_path = get_crop_path(OUTPUT_DIR, crop) results_path = calculate_metrics(crop_path, subtask, left, top, @@ -92,6 +94,32 @@ def make_verdict( subtask_file_paths, crops, results ): json.dump({'verdict': verdict}, f) +def get_crop_path(parent: str, filename: str) -> str: + """ + Attempts to get the path to a crop file. If no file exists under the + provided path, the original file extension is replaced with an expected + one. + :param parent: directory where crops are located. + :param filename: the expected crop file name, based on the file extension + provided in verifier parameters. + :return: path to the requested crop file, possibly with a different file + extension. + :raises FileNotFoundError if no matching crop file could be found. + """ + crop_path = Path(parent, filename) + + if crop_path.exists(): + return str(crop_path) + + expected_extension = get_expected_extension(crop_path.suffix) + expected_path = crop_path.with_suffix(expected_extension) + + if expected_path.exists(): + return str(expected_path) + + raise FileNotFoundError(f'Could not find crop file. Paths checked:' + f'{crop_path}, {expected_path}') + def verify(subtask_file_paths, subtask_border, scene_file_path, resolution, samples, frames, output_format, basefilename, crops_count=3, crops_borders=None): diff --git a/apps/blender/task/blenderrendertask.py b/apps/blender/task/blenderrendertask.py index 90cf114112..ff76110ec2 100644 --- a/apps/blender/task/blenderrendertask.py +++ b/apps/blender/task/blenderrendertask.py @@ -622,6 +622,7 @@ class BlenderRenderTaskBuilder(FrameRenderingTaskBuilder): def build_dictionary(cls, definition): dictionary = super().build_dictionary(definition) dictionary['options']['compositing'] = definition.options.compositing + dictionary['options']['samples'] = definition.options.samples return dictionary @classmethod diff --git a/apps/core/resources/images/base.Dockerfile b/apps/core/resources/images/base.Dockerfile index 5b3939afd8..13a7ee0140 100644 --- a/apps/core/resources/images/base.Dockerfile +++ b/apps/core/resources/images/base.Dockerfile @@ -7,31 +7,19 @@ MAINTAINER Golem Tech RUN set -x \ && apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates wget curl \ + && apt-get install -y --no-install-recommends ca-certificates curl \ && apt-get install -y python3.6 \ && apt-get clean \ && apt-get -y autoremove \ && rm -rf /var/lib/apt/lists/* \ && ln -s /usr/bin/python3.6 /usr/bin/python3 -RUN wget -O /tmp/su-exec "https://github.com/golemfactory/golem/wiki/binaries/su-exec" \ - && test "60e8c3010aaa85f5d919448d082ecdf6e8b75a1c /tmp/su-exec" = "$(sha1sum /tmp/su-exec)" \ - && mv /tmp/su-exec /usr/local/bin/su-exec \ - && chmod +x /usr/local/bin/su-exec \ - && su-exec nobody true - RUN mkdir /golem \ && mkdir /golem/work \ && mkdir /golem/resources \ && mkdir /golem/output -COPY entrypoint.sh /usr/local/bin/entrypoint.sh -RUN sed -i -e 's/\r$//' /usr/local/bin/entrypoint.sh -RUN chmod +x /usr/local/bin/entrypoint.sh - COPY core/resources/images/scripts/ /golem/ RUN chmod +x /golem/install_py_libs.sh WORKDIR /golem/work/ - -ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] \ No newline at end of file diff --git a/apps/core/resources/images/nvgpu.Dockerfile b/apps/core/resources/images/nvgpu.Dockerfile index 8fa2ac02ed..e27b841f17 100644 --- a/apps/core/resources/images/nvgpu.Dockerfile +++ b/apps/core/resources/images/nvgpu.Dockerfile @@ -14,21 +14,11 @@ RUN set -x \ && rm -rf /var/lib/apt/lists/* \ && ln -s /usr/bin/python3.6 /usr/bin/python3 -RUN wget -O /tmp/su-exec "https://github.com/golemfactory/golem/wiki/binaries/su-exec" \ - && test "60e8c3010aaa85f5d919448d082ecdf6e8b75a1c /tmp/su-exec" = "$(sha1sum /tmp/su-exec)" \ - && mv /tmp/su-exec /usr/local/bin/su-exec \ - && chmod +x /usr/local/bin/su-exec \ - && su-exec nobody true - RUN mkdir /golem \ && mkdir /golem/work \ && mkdir /golem/resources \ && mkdir /golem/output -COPY entrypoint.sh /usr/local/bin/entrypoint.sh -RUN sed -i -e 's/\r$//' /usr/local/bin/entrypoint.sh -RUN chmod +x /usr/local/bin/entrypoint.sh - COPY core/resources/images/scripts/ /golem/ RUN chmod +x /golem/install_py_libs.sh @@ -36,5 +26,3 @@ WORKDIR /golem/work/ ENV DISPLAY="" ENV LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/cuda/lib:/usr/local/cuda/lib64 - -ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/apps/core/task/coretask.py b/apps/core/task/coretask.py index 35155afca1..dc8e6f6fb6 100644 --- a/apps/core/task/coretask.py +++ b/apps/core/task/coretask.py @@ -30,6 +30,8 @@ from golem.verificator.core_verifier import CoreVerifier from golem.verificator.verifier import SubtaskVerificationState +from .coretaskstate import RunVerification + if TYPE_CHECKING: # pylint:disable=unused-import, ungrouped-imports @@ -208,10 +210,19 @@ def computation_finished(self, subtask_id, task_result, self.interpret_task_results(subtask_id, task_result) result_files = self.results.get(subtask_id) - def verification_finished_(subtask_id, verdict, result): + def verification_finished_(subtask_id, + verdict: SubtaskVerificationState, result): self.verification_finished(subtask_id, verdict, result) verification_finished() + if self.task_definition.run_verification == RunVerification.disabled: + logger.debug("verification disabled; calling verification_finished." + " subtask_id=%s", subtask_id) + result = {'extra_data': {'results': result_files}} + verification_finished_( + subtask_id, SubtaskVerificationState.VERIFIED, result) + return + self.VERIFICATION_QUEUE.submit( self.VERIFIER_CLASS, subtask_id, @@ -521,10 +532,7 @@ def __init__(self, def build(self): # pylint:disable=abstract-class-instantiated - task = self.TASK_CLASS(**self.get_task_kwargs()) - - task.initialize(self.dir_manager) - return task + return self.TASK_CLASS(**self.get_task_kwargs()) def get_task_kwargs(self, **kwargs): kwargs['total_tasks'] = int(self.task_definition.subtasks_count) @@ -544,8 +552,12 @@ def build_minimal_definition(cls, task_type: CoreTaskTypeInfo, dictionary) \ definition.options = task_type.options() definition.task_type = task_type.name definition.compute_on = dictionary.get('compute_on', 'cpu') - definition.resources = set(dictionary['resources']) definition.subtasks_count = int(dictionary['subtasks_count']) + definition.concent_enabled = dictionary.get('concent_enabled', False) + + if 'resources' in dictionary: + definition.resources = set(dictionary['resources']) + return definition @classmethod @@ -580,6 +592,10 @@ def build_full_definition(cls, definition.output_file = cls.get_output_path(dictionary, definition) definition.estimated_memory = dictionary.get('estimated_memory', 0) + if 'x-run-verification' in dictionary: + definition.run_verification = \ + RunVerification(dictionary['x-run-verification']) + return definition # TODO: Backward compatibility only. The rendering tasks should diff --git a/apps/core/task/coretaskstate.py b/apps/core/task/coretaskstate.py index 23339e73a6..e849e19c74 100644 --- a/apps/core/task/coretaskstate.py +++ b/apps/core/task/coretaskstate.py @@ -1,5 +1,5 @@ +import enum from os import path, remove -from typing import Any, Dict from ethereum.utils import denoms @@ -13,6 +13,30 @@ DEFAULT_SUBTASK_TIMEOUT = 20 * 60 +class RunVerification(enum.Enum): + """ + Enabled: (default) + Perform verification and act accordingly. + Lenient: + The verification should be performed and then in the event of negative + verification result, the subtask itself should be marked as failed as + usual and rescheduled, the provider should also be banned from + performing any additional subtasks in this task and a failure should + still be reported to the golem monitor. The provider should get a + SubtaskResultsAccepted response and be issued a payment just as it + would had the result been correct. + Disabled: + Completely disable verification for the given task and treat all + results as valid without performing any verification. + """ + def _generate_next_value_(name, *_): # pylint:disable=no-self-argument + return name + + enabled = enum.auto() + lenient = enum.auto() + disabled = enum.auto() + + class TaskDefaults(object): """ Suggested default values for task parameters """ @@ -51,6 +75,7 @@ def __init__(self): self.max_price = 0 + self.run_verification: RunVerification = RunVerification.enabled self.options = Options() self.docker_images = None self.compute_on = "cpu" @@ -88,6 +113,10 @@ def __setstate__(self, state): if 'timeout' not in attributes: attributes['timeout'] = attributes.pop('full_task_timeout') + if pickled_version < 3: + if 'run_verification' not in attributes: + attributes['run_verification'] = RunVerification.enabled + for key in attributes: setattr(self, key, attributes[key]) @@ -121,7 +150,7 @@ def to_dict(self) -> dict: subtask_timeout = timeout_to_string(int(self.subtask_timeout)) output_path = self.build_output_path() - return { + d = { 'id': self.task_id, 'type': self.task_type, 'compute_on': self.compute_on, @@ -137,6 +166,11 @@ def to_dict(self) -> dict: 'concent_enabled': self.concent_enabled, } + if self.run_verification != RunVerification.enabled: + d['x-run-verification'] = self.run_verification + + return d + def build_output_path(self) -> str: return self.output_file.rsplit(path.sep, 1)[0] diff --git a/apps/dummy/dummyenvironment.py b/apps/dummy/dummyenvironment.py index 0e7bdea547..d418d2332c 100644 --- a/apps/dummy/dummyenvironment.py +++ b/apps/dummy/dummyenvironment.py @@ -3,7 +3,7 @@ class DummyTaskEnvironment(DockerEnvironment): DOCKER_IMAGE = "golemfactory/dummy" - DOCKER_TAG = "1.1" + DOCKER_TAG = "1.2" ENV_ID = "DUMMYPOW" SHORT_DESCRIPTION = "Dummy task (example app calculating proof-of-work " \ "hash)" diff --git a/apps/dummy/resources/images/Dockerfile b/apps/dummy/resources/images/Dockerfile index b1b903a086..789af5016f 100644 --- a/apps/dummy/resources/images/Dockerfile +++ b/apps/dummy/resources/images/Dockerfile @@ -1,4 +1,4 @@ -FROM golemfactory/base:1.4 +FROM golemfactory/base:1.5 MAINTAINER Golem Tech diff --git a/apps/entrypoint.sh b/apps/entrypoint.sh deleted file mode 100644 index bff29611a9..0000000000 --- a/apps/entrypoint.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash - -if [ "$LOCAL_USER_ID" != "" ]; then - useradd --shell /bin/bash -u "$LOCAL_USER_ID" -o -c "" -m task - export HOME=/home/task - exec /usr/local/bin/su-exec task /bin/sh -c "$@" -elif [ "$OSX_USER" != "" ]; then - OSX_USER_ID=$(ls -n /golem | grep work | sed 's/\s\s*/ /g' | cut -d' ' -f3) - useradd --shell /bin/bash -u "$OSX_USER_ID" -o -c "" -m task - export HOME=/home/task - exec /usr/local/bin/su-exec task /bin/sh -c "$@" -else - "$@" -fi diff --git a/apps/glambda/glambdaenvironment.py b/apps/glambda/glambdaenvironment.py index 454aca6a4b..43b9e3163d 100644 --- a/apps/glambda/glambdaenvironment.py +++ b/apps/glambda/glambdaenvironment.py @@ -8,7 +8,7 @@ class GLambdaTaskEnvironment(DockerEnvironment): DOCKER_IMAGE = "golemfactory/glambda" - DOCKER_TAG = "1.3" + DOCKER_TAG = "1.4" ENV_ID = "glambda" SHORT_DESCRIPTION = "GLambda PoC" @@ -26,5 +26,8 @@ def get_container_config(self) -> Dict: volumes=[], binds={}, devices=[], - environment={} + # gVisor uses HOME variable before starting the image. + # Starting docker as a particular user (docker --user parameter) + # does not set HOME and that's why we define it here. + environment={'HOME': '/home/user'} if is_linux() else {} ) diff --git a/apps/glambda/resources/images/Dockerfile b/apps/glambda/resources/images/Dockerfile index 0ac34cb7df..adbb8ea058 100644 --- a/apps/glambda/resources/images/Dockerfile +++ b/apps/glambda/resources/images/Dockerfile @@ -37,7 +37,7 @@ RUN apt-get update && \ make install && \ chmod -R 757 ${RASPA_DIR}/ -FROM golemfactory/base:1.4 +FROM golemfactory/base:1.5 ENV RASPA_DIR=/opt/RASPA RUN set -x \ diff --git a/apps/glambda/task/glambdatask.py b/apps/glambda/task/glambdatask.py index b563f78005..e25f008145 100644 --- a/apps/glambda/task/glambdatask.py +++ b/apps/glambda/task/glambdatask.py @@ -9,7 +9,7 @@ from golem_messages.datastructures import p2p as dt_p2p from apps.core.task.coretask import CoreTask, CoreTaskBuilder, \ - CoreVerifier + CoreVerifier, CoreTaskTypeInfo from apps.core.task.coretaskstate import TaskDefinition, Options from apps.glambda.glambdaenvironment import GLambdaTaskEnvironment from golem.resource.dirmanager import DirManager @@ -246,14 +246,9 @@ def get_task_kwargs(self, **kwargs): return kwargs @classmethod - def build_minimal_definition(cls, task_type: TaskTypeInfo, dictionary): - definition = task_type.definition() - definition.task_type = task_type.name - definition.compute_on = dictionary.get('compute_on', 'cpu') - if 'resources' in dictionary: - definition.resources = set(dictionary['resources']) + def build_minimal_definition(cls, task_type: CoreTaskTypeInfo, dictionary): + definition = super().build_minimal_definition(task_type, dictionary) options = dictionary['options'] - definition.subtasks_count = int(dictionary['subtasks_count']) definition.options.method = options['method'] definition.options.args = options['args'] definition.options.verification = options['verification'] diff --git a/apps/images.ini b/apps/images.ini index 96ed9335e8..d71cef3368 100644 --- a/apps/images.ini +++ b/apps/images.ini @@ -1,9 +1,9 @@ golemfactory/base core/resources/images/base.Dockerfile 1.5 . -golemfactory/nvgpu core/resources/images/nvgpu.Dockerfile 1.3 . apps.core.nvgpu.is_supported -golemfactory/blender blender/resources/images/blender.Dockerfile 1.9 blender/resources/images/ -golemfactory/blender_verifier blender/resources/images/blender_verifier.Dockerfile 1.1 blender/resources/images/ -golemfactory/blender_nvgpu blender/resources/images/blender_nvgpu.Dockerfile 1.3 . apps.core.nvgpu.is_supported -golemfactory/dummy dummy/resources/images/Dockerfile 1.1 dummy/resources/images -golemfactory/wasm wasm/resources/images/Dockerfile 0.2.1 . +golemfactory/nvgpu core/resources/images/nvgpu.Dockerfile 1.4 . apps.core.nvgpu.is_supported +golemfactory/blender blender/resources/images/blender.Dockerfile 1.10 blender/resources/images/ +golemfactory/blender_verifier blender/resources/images/blender_verifier.Dockerfile 1.5 blender/resources/images/ +golemfactory/blender_nvgpu blender/resources/images/blender_nvgpu.Dockerfile 1.4 . apps.core.nvgpu.is_supported +golemfactory/dummy dummy/resources/images/Dockerfile 1.2 dummy/resources/images +golemfactory/wasm wasm/resources/images/Dockerfile 0.3.0 wasm/resources/images +golemfactory/glambda glambda/resources/images/Dockerfile 1.4 . golemfactory/ffmpeg-experimental transcoding/ffmpeg/resources/images/ffmpeg.Dockerfile 0.94 transcoding/ffmpeg/resources -golemfactory/glambda glambda/resources/images/Dockerfile 1.3 . diff --git a/apps/rendering/task/renderingtask.py b/apps/rendering/task/renderingtask.py index 66309ea535..db45cb201e 100644 --- a/apps/rendering/task/renderingtask.py +++ b/apps/rendering/task/renderingtask.py @@ -296,10 +296,6 @@ def get_task_kwargs(self, **kwargs): kwargs['total_tasks'] = self._calculate_total(self.DEFAULTS()) return kwargs - def build(self): - task = super(RenderingTaskBuilder, self).build() - return task - @classmethod def build_dictionary(cls, definition): parent = super(RenderingTaskBuilder, cls) diff --git a/apps/wasm/environment.py b/apps/wasm/environment.py index ad14b34f37..1994a78249 100644 --- a/apps/wasm/environment.py +++ b/apps/wasm/environment.py @@ -3,6 +3,6 @@ class WasmTaskEnvironment(DockerEnvironment): DOCKER_IMAGE = "golemfactory/wasm" - DOCKER_TAG = "0.2.1" + DOCKER_TAG = "0.3.0" ENV_ID = "WASM" SHORT_DESCRIPTION = "WASM Sandbox" diff --git a/apps/wasm/resources/images/Dockerfile b/apps/wasm/resources/images/Dockerfile index e4dcd56cb3..9d80cd376a 100644 --- a/apps/wasm/resources/images/Dockerfile +++ b/apps/wasm/resources/images/Dockerfile @@ -12,7 +12,7 @@ ENV CXX=clang++-6.0 RUN cargo install --path /sp-wasm/sp-wasm-cli --root /usr RUN cargo clean -FROM golemfactory/base:1.4 +FROM golemfactory/base:1.5 WORKDIR / COPY --from=builder /usr/bin/wasm-sandbox / COPY scripts/ /golem/scripts/ diff --git a/apps/wasm/task.py b/apps/wasm/task.py index 212dfb9727..51404f9677 100644 --- a/apps/wasm/task.py +++ b/apps/wasm/task.py @@ -141,6 +141,19 @@ def query_extra_data_for_test_task(self) -> ComputeTaskDef: subtask_id=self.create_subtask_id(), extra_data=next_extra_data ) + def filter_task_results(self, task_results, subtask_id, log_ext=".log", + err_log_ext="err.log"): + filtered_task_results = [] + for tr in task_results: + if tr.endswith(err_log_ext): + self.stderr[subtask_id] = tr + elif tr.endswith(log_ext): + self.stdout[subtask_id] = tr + else: + filtered_task_results.append(tr) + + return filtered_task_results + class WasmTaskBuilder(CoreTaskBuilder): TASK_CLASS: Type[WasmTask] = WasmTask diff --git a/codecov.yml b/codecov.yml index 5cbd182b89..e02fb41ba1 100644 --- a/codecov.yml +++ b/codecov.yml @@ -14,9 +14,9 @@ coverage: target: auto changes: false ignore: - - "apps/core/benchmark/minilight/*" - "apps/rendering/resources/taskcollector/*" - "golem/database/schemas" + - "golem/envs/docker/benchmark/minilight/*" - "golem/testutils.py" - "golem/tools/pyuic.py" - "save/*" diff --git a/golem/appconfig.py b/golem/appconfig.py index 3622d4f224..c43cd2b2ff 100644 --- a/golem/appconfig.py +++ b/golem/appconfig.py @@ -8,7 +8,6 @@ from golem.config.active import ENABLE_TALKBACK from golem.clientconfigdescriptor import ClientConfigDescriptor from golem.core.simpleconfig import SimpleConfig, ConfigEntry -from golem.core.variables import KEY_DIFFICULTY from golem.ranking.helper.trust_const import \ REQUESTING_TRUST, \ @@ -151,7 +150,6 @@ def load_config(cls, datadir, cfg_file_name=CONFIG_FILENAME): seed_port=START_PORT, seeds="", opt_peer_num=OPTIMAL_PEER_NUM, - key_difficulty=KEY_DIFFICULTY, # flags in_shutdown=0, accept_tasks=ACCEPT_TASKS, @@ -201,6 +199,8 @@ def load_config(cls, datadir, cfg_file_name=CONFIG_FILENAME): hyperdrive_address=DEFAULT_HYPERDRIVE_ADDRESS, hyperdrive_rpc_port=DEFAULT_HYPERDRIVE_RPC_PORT, hyperdrive_rpc_address=DEFAULT_HYPERDRIVE_RPC_ADDRESS, + # testing + overwrite_results=None, ) cfg = SimpleConfig(node_config, cfg_file, keep_old=False) diff --git a/golem/client.py b/golem/client.py index f66e7dc546..83fb82f159 100644 --- a/golem/client.py +++ b/golem/client.py @@ -19,11 +19,11 @@ from apps.appsmanager import AppsManager import golem +from golem import model from golem.appconfig import TASKARCHIVE_MAINTENANCE_INTERVAL, AppConfig from golem.clientconfigdescriptor import ConfigApprover, ClientConfigDescriptor from golem.core import variables from golem.core.common import ( - datetime_to_timestamp_utc, get_timestamp_utc, node_info_str, string_to_timeout, @@ -31,9 +31,8 @@ ) from golem.core.fileshelper import du from golem.hardware.presets import HardwarePresets -from golem.config.active import EthereumConfig from golem.core.keysauth import KeysAuth -from golem.core.service import LoopingCallService, IService +from golem.core.service import LoopingCallService from golem.core.simpleserializer import DictSerializer from golem.database import Database from golem.diag.service import DiagnosticsService, DiagnosticsOutputFormat @@ -42,7 +41,6 @@ from golem.manager.nodestatesnapshot import ComputingSubtaskStateSnapshot from golem.ethereum import exceptions as eth_exceptions from golem.ethereum.fundslocker import FundsLocker -from golem.model import PaymentStatus from golem.ethereum.transactionsystem import TransactionSystem from golem.monitor.model.nodemetadatamodel import NodeMetadataModel from golem.monitor.monitor import SystemMonitor @@ -236,6 +234,7 @@ def get_wamp_rpc_mapping(self): from golem.environments.minperformancemultiplier import \ MinPerformanceMultiplier from golem.network.concent import soft_switch as concent_soft_switch + from golem.rpc.api import ethereum_ as api_ethereum from golem.task import rpc as task_rpc task_rpc_provider = task_rpc.ClientProvider(self) providers = ( @@ -248,6 +247,7 @@ def get_wamp_rpc_mapping(self): self.environments_manager, self.transaction_system, task_rpc_provider, + api_ethereum.ETSProvider(self.transaction_system), ) mapping = {} for rpc_provider in providers: @@ -652,10 +652,10 @@ def quit(self): self.db.close() def resource_collected(self, res_id): - self.task_server.task_computer.resource_collected(res_id) + self.task_server.resource_collected(res_id) def resource_failure(self, res_id, reason): - self.task_server.task_computer.resource_failure(res_id, reason) + self.task_server.resource_failure(res_id, reason) @rpc_utils.expose('comp.tasks.check.abort') def abort_test_task(self) -> bool: @@ -753,10 +753,6 @@ def get_dir_manager(self): def get_key_id(self): return self.keys_auth.key_id - @rpc_utils.expose('crypto.difficulty') - def get_difficulty(self): - return self.keys_auth.get_difficulty() - @rpc_utils.expose('net.ident.key') def get_node_key(self): key = self.node.key @@ -834,21 +830,31 @@ def get_task(self, task_id: str) -> Optional[dict]: # Get total value and total fee for payments for the given subtask IDs subtasks_payments = \ self.transaction_system.get_subtasks_payments(subtask_ids) + statuses_of_interest = ( + model.WalletOperation.STATUS.sent, + model.WalletOperation.STATUS.confirmed, + ) all_sent = all( - p.status in [PaymentStatus.sent, PaymentStatus.confirmed] + p.wallet_operation.status in statuses_of_interest for p in subtasks_payments) if not subtasks_payments or not all_sent: task_dict['cost'] = None task_dict['fee'] = None else: - # Because details are JSON field - task_dict['cost'] = sum(p.value or 0 for p in subtasks_payments) + task_dict['cost'] = sum( + p.wallet_operation.amount for p in subtasks_payments + ) task_dict['fee'] = \ - sum(p.details.fee or 0 for p in subtasks_payments) + sum( + p.wallet_operation.gas_cost for p in subtasks_payments + if p.wallet_operation.gas_cost + ) # Convert to string because RPC serializer fails on big numbers - for k in ('cost', 'fee', 'estimated_cost', 'estimated_fee'): - if task_dict[k] is not None: + # and enums + for k in ('cost', 'fee', 'estimated_cost', 'estimated_fee', + 'x-run-verification'): + if k in task_dict and task_dict[k] is not None: task_dict[k] = str(task_dict[k]) return task_dict @@ -920,11 +926,6 @@ def get_unsupport_reasons(self, last_days): return self.task_archiver.get_unsupport_reasons(last_days) return self.task_server.task_keeper.get_unsupport_reasons() - @rpc_utils.expose('pay.ident') - def get_payment_address(self): - address = self.transaction_system.get_payment_address() - return str(address) if address else None - def get_comp_stat(self, name): if self.task_server and self.task_server.task_computer: return self.task_server.task_computer.stats.get_stats(name) @@ -952,7 +953,7 @@ def get_balance(self): 'contract_addresses': { contract.name: address for contract, address in - EthereumConfig.CONTRACT_ADDRESSES.items() + self.transaction_system.contract_addresses.items() } } @@ -982,65 +983,6 @@ class DepositStatus(msg_datastructures.StringEnum): 'timelock': str(timelock), } - @rpc_utils.expose('pay.gas_price') - def get_gas_price(self) -> Dict[str, str]: - return { - "current_gas_price": str(self.transaction_system.gas_price), - "gas_price_limit": str(self.transaction_system.gas_price_limit) - } - - @rpc_utils.expose('pay.payments') - def get_payments_list( - self, - num: Optional[int] = None, - last_seconds: Optional[int] = None, - ) -> List[Dict[str, Any]]: - interval = None - if last_seconds is not None: - interval = timedelta(seconds=last_seconds) - return self.transaction_system.get_payments_list(num, interval) - - @rpc_utils.expose('pay.incomes') - def get_incomes_list(self) -> List[Dict[str, Any]]: - incomes = self.transaction_system.get_incomes_list() - - def item(o): - status = "confirmed" if o.transaction else "awaiting" - - return { - "subtask": to_unicode(o.subtask), - "payer": to_unicode(o.sender_node), - "value": to_unicode(o.value), - "status": to_unicode(status), - "transaction": to_unicode(o.transaction), - "created": datetime_to_timestamp_utc(o.created_date), - "modified": datetime_to_timestamp_utc(o.modified_date) - } - - return [item(income) for income in incomes] - - @rpc_utils.expose('pay.deposit_payments') - @classmethod - def get_deposit_payments_list(cls, limit=1000, offset=0)\ - -> List[Dict[str, Any]]: - deposit_payments = TransactionSystem.get_deposit_payments_list( - limit, - offset, - ) - result = [] - for dpayment in deposit_payments: - entry = {} - entry['value'] = to_unicode(dpayment.value) - entry['status'] = to_unicode(dpayment.status.name) - entry['fee'] = to_unicode(dpayment.fee) - entry['transaction'] = to_unicode(dpayment.tx) - entry['created'] = datetime_to_timestamp_utc(dpayment.created_date) - entry['modified'] = datetime_to_timestamp_utc( - dpayment.modified_date, - ) - result.append(entry) - return result - @rpc_utils.expose('pay.withdraw.gas_cost') def get_withdraw_gas_cost( self, diff --git a/golem/clientconfigdescriptor.py b/golem/clientconfigdescriptor.py index 8ef4270cbb..5afb94dc05 100644 --- a/golem/clientconfigdescriptor.py +++ b/golem/clientconfigdescriptor.py @@ -1,8 +1,6 @@ import logging import typing -from golem.core.variables import KEY_DIFFICULTY - logger = logging.getLogger(__name__) @@ -21,7 +19,6 @@ def __init__(self): self.send_pings = 0 self.pings_interval = 0.0 self.use_ipv6 = 0 - self.key_difficulty = 0 self.use_upnp = 0 self.enable_talkback = 0 self.enable_monitor = 0 @@ -80,6 +77,8 @@ def __init__(self): self.hyperdrive_rpc_port: typing.Optional[int] = None self.hyperdrive_rpc_address: typing.Optional[str] = None + self.overwrite_results: typing.Optional[str] = None + def __repr__(self): return '{}: {}'.format(self.__class__, { v: getattr(self, v) for v in vars(self)}) @@ -106,7 +105,6 @@ class ConfigApprover(object): to_int_opt = { 'seed_port', 'num_cores', 'opt_peer_num', 'p2p_session_timeout', 'task_session_timeout', 'pings_interval', 'max_results_sending_delay', - 'key_difficulty', } to_big_int_opt = { 'min_price', 'max_price', @@ -115,7 +113,6 @@ class ConfigApprover(object): 'getting_peers_interval', 'getting_tasks_interval', 'computing_trust', 'requesting_trust' } - max_opt = {'key_difficulty': KEY_DIFFICULTY} def __init__(self, config_desc): """ Create config approver class that keeps old config descriptor @@ -127,7 +124,6 @@ def __init__(self, config_desc): (self.to_int_opt, self._to_int), (self.to_big_int_opt, self._to_int), (self.to_float_opt, self._to_float), - (self.max_opt, self._max_value) ] self.config_desc = config_desc @@ -186,16 +182,3 @@ def _to_float(val, name): except ValueError: logger.warning("{} value '{}' is not a number".format(name, val)) return val - - @classmethod - def _max_value(cls, val, name): - """Try to set a maximum numeric value of val or the default value. - :param val: value that should be changed to float - :param str name: name of a config description option for logs - :return: max(val, min_value) or unchanged value if it's not possible - """ - try: - return max(val, cls.max_opt[name]) - except (KeyError, ValueError): - logger.warning('Cannot apply a minimum value to %r', name) - return val diff --git a/golem/config/active.py b/golem/config/active.py index d199172cc5..52ecc41b9e 100644 --- a/golem/config/active.py +++ b/golem/config/active.py @@ -3,15 +3,9 @@ from golem_sci.chains import MAINNET -from golem.config.environments import GOLEM_ENVIRONMENT_VARIABLE, \ - CONCENT_ENVIRONMENT_VARIABLE -from golem.core import variables +from golem.config.environments import GOLEM_ENVIRONMENT_VARIABLE if os.environ.get(GOLEM_ENVIRONMENT_VARIABLE) == MAINNET: from golem.config.environments.mainnet import * # noqa else: - from golem.config.environments.testnet import * # noqa - -CONCENT_VARIANT = variables.CONCENT_CHOICES[ - os.environ.get(CONCENT_ENVIRONMENT_VARIABLE, 'disabled') -] + from golem.config.environments.testnet import * # type: ignore # pylint:disable=unused-wildcard-import, wildcard-import diff --git a/golem/config/environments/mainnet.py b/golem/config/environments/mainnet.py index c85d77a207..29de82b4da 100644 --- a/golem/config/environments/mainnet.py +++ b/golem/config/environments/mainnet.py @@ -3,51 +3,54 @@ from golem_sci import contracts from golem_sci.chains import MAINNET -from golem.core.variables import PROTOCOL_CONST +from golem.core.variables import PROTOCOL_CONST, CONCENT_CHOICES from . import CONCENT_ENVIRONMENT_VARIABLE -IS_MAINNET = True -ACTIVE_NET = MAINNET # CORE DATA_DIR = 'mainnet' ENABLE_TALKBACK = 0 -# CONCENT - -os.environ[CONCENT_ENVIRONMENT_VARIABLE] = os.environ.get( - CONCENT_ENVIRONMENT_VARIABLE, 'disabled' -) - - # ETH + class EthereumConfig: - NODE_LIST = [ - 'https://geth.golem.network:55555', - 'https://0.geth.golem.network:55555', - 'https://1.geth.golem.network:55555', - 'https://2.geth.golem.network:55555', - 'https://geth.golem.network:2137', - 'https://0.geth.golem.network:2137', - 'https://1.geth.golem.network:2137', - 'https://2.geth.golem.network:2137', - ] - - FALLBACK_NODE_LIST = [ - 'https://proxy.geth.golem.network:2137', - ] - - CHAIN = MAINNET - FAUCET_ENABLED = False - - CONTRACT_ADDRESSES = { - contracts.GNT: '0xa74476443119A942dE498590Fe1f2454d7D4aC0d', - contracts.GNTB: '0xA7dfb33234098c66FdE44907e918DAD70a3f211c', - } - - WITHDRAWALS_ENABLED = True + def __init__(self): + self.IS_MAINNET = True + self.ACTIVE_NET = MAINNET + self.NODE_LIST = [ + 'https://geth.golem.network:55555', + 'https://0.geth.golem.network:55555', + 'https://1.geth.golem.network:55555', + 'https://2.geth.golem.network:55555', + 'https://geth.golem.network:2137', + 'https://0.geth.golem.network:2137', + 'https://1.geth.golem.network:2137', + 'https://2.geth.golem.network:2137', + ] + + self.FALLBACK_NODE_LIST = [ + 'https://proxy.geth.golem.network:2137', + ] + + self.CHAIN = MAINNET + self.FAUCET_ENABLED = False + + self.CONTRACT_ADDRESSES = { + contracts.GNT: '0xa74476443119A942dE498590Fe1f2454d7D4aC0d', + contracts.GNTB: '0xA7dfb33234098c66FdE44907e918DAD70a3f211c', + } + + os.environ[CONCENT_ENVIRONMENT_VARIABLE] = os.environ.get( + CONCENT_ENVIRONMENT_VARIABLE, 'disabled' + ) + + self.CONCENT_VARIANT = CONCENT_CHOICES[ + os.environ.get(CONCENT_ENVIRONMENT_VARIABLE, 'disabled') + ] + + self.WITHDRAWALS_ENABLED = True # P2P diff --git a/golem/config/environments/testnet.py b/golem/config/environments/testnet.py index 452f11acdd..c80f6c7500 100644 --- a/golem/config/environments/testnet.py +++ b/golem/config/environments/testnet.py @@ -7,51 +7,58 @@ from golem.core.variables import PROTOCOL_CONST, CONCENT_CHOICES from . import TESTNET, CONCENT_ENVIRONMENT_VARIABLE -IS_MAINNET = False -ACTIVE_NET = TESTNET - # CORE DATA_DIR = 'rinkeby' ENABLE_TALKBACK = 1 -# CONCENT +# ETH + +# todo FIXME before 0.21 release +# https://github.com/golemfactory/golem/issues/4254 +# this class and actually, `golem.config` in general +# need to be refactored to remove reliance on system environment variables -os.environ[CONCENT_ENVIRONMENT_VARIABLE] = os.environ.get( - CONCENT_ENVIRONMENT_VARIABLE, 'test' -) +class EthereumConfig: # pylint:disable=too-many-instance-attributes + def __init__(self): + self.IS_MAINNET = False + self.ACTIVE_NET = TESTNET + self.NODE_LIST = [ + 'https://rinkeby.golem.network:55555', + 'http://188.165.227.180:55555', + 'http://94.23.17.170:55555', + 'http://94.23.57.58:55555', + ] -# ETH + self.FALLBACK_NODE_LIST: List[str] = [ + ] -class EthereumConfig: - NODE_LIST = [ - 'https://rinkeby.golem.network:55555', - 'http://188.165.227.180:55555', - 'http://94.23.17.170:55555', - 'http://94.23.57.58:55555', - ] + self.CHAIN = RINKEBY + self.FAUCET_ENABLED = True - FALLBACK_NODE_LIST: List[str] = [ - ] + self.CONTRACT_ADDRESSES = { + contracts.GNT: '0x924442A66cFd812308791872C4B242440c108E19', + contracts.GNTB: '0x123438d379BAbD07134d1d4d7dFa0BCbd56ca3F3', + contracts.Faucet: '0x77b6145E853dfA80E8755a4e824c4F510ac6692e', + } - CHAIN = RINKEBY - FAUCET_ENABLED = True + os.environ[CONCENT_ENVIRONMENT_VARIABLE] = os.environ.get( + CONCENT_ENVIRONMENT_VARIABLE, 'test' + ) - CONTRACT_ADDRESSES = { - contracts.GNT: '0x924442A66cFd812308791872C4B242440c108E19', - contracts.GNTB: '0x123438d379BAbD07134d1d4d7dFa0BCbd56ca3F3', - contracts.Faucet: '0x77b6145E853dfA80E8755a4e824c4F510ac6692e', - } + self.CONCENT_VARIANT = CONCENT_CHOICES[ + os.environ.get(CONCENT_ENVIRONMENT_VARIABLE, 'disabled') + ] - deposit_contract_address = CONCENT_CHOICES[ - os.environ[CONCENT_ENVIRONMENT_VARIABLE] - ].get('deposit_contract_address') + self.deposit_contract_address = \ + self.CONCENT_VARIANT.get('deposit_contract_address') - if deposit_contract_address: - CONTRACT_ADDRESSES[contracts.GNTDeposit] = deposit_contract_address + if self.deposit_contract_address: + self.CONTRACT_ADDRESSES[contracts.GNTDeposit] = \ + self.deposit_contract_address - WITHDRAWALS_ENABLED = False + self.WITHDRAWALS_ENABLED = False # P2P diff --git a/golem/core/deferred.py b/golem/core/deferred.py index eb49fcefee..85bec59c1a 100644 --- a/golem/core/deferred.py +++ b/golem/core/deferred.py @@ -1,12 +1,34 @@ from queue import Queue, Empty +from typing import Any, Callable, Dict, List, Optional, Tuple, Union -from twisted.internet.defer import Deferred, TimeoutError +from twisted.internet import defer from twisted.internet.task import deferLater +from twisted.internet.threads import deferToThread from twisted.python.failure import Failure +class DeferredSeq: + def __init__(self) -> None: + self._seq: List[Tuple[Callable, Tuple, Dict]] = [] + + def push(self, fn: Callable, *args, **kwargs) -> 'DeferredSeq': + self._seq.append((fn, args, kwargs)) + return self + + def execute(self) -> defer.Deferred: + return deferToThread(lambda: sync_wait(self._execute(), timeout=None)) + + @defer.inlineCallbacks + def _execute(self) -> Any: + result = None + for entry in self._seq: + fn, args, kwargs = entry + result = yield defer.maybeDeferred(fn, *args, **kwargs) + return result + + def chain_function(deferred, fn, *args, **kwargs): - result = Deferred() + result = defer.Deferred() def resolve(_): fn(*args, **kwargs).addCallbacks(result.callback, @@ -17,8 +39,10 @@ def resolve(_): return result -def sync_wait(deferred, timeout=10): - if not isinstance(deferred, Deferred): +def sync_wait(deferred: defer.Deferred, + timeout: Optional[Union[int, float]] = 10.) -> Any: + + if not isinstance(deferred, defer.Deferred): return deferred queue = Queue() @@ -27,13 +51,13 @@ def sync_wait(deferred, timeout=10): try: result = queue.get(True, timeout) except Empty: - raise TimeoutError("Command timed out") + raise defer.TimeoutError("Command timed out") if isinstance(result, Failure): result.raiseException() return result -def call_later(delay: int, callable, *args, **kwargs) -> None: +def call_later(delay: int, fn, *args, **kwargs) -> None: from twisted.internet import reactor - deferLater(reactor, delay, callable, *args, **kwargs) + deferLater(reactor, delay, fn, *args, **kwargs) diff --git a/golem/core/keysauth.py b/golem/core/keysauth.py index 6941675c26..65ca504bf9 100644 --- a/golem/core/keysauth.py +++ b/golem/core/keysauth.py @@ -11,7 +11,7 @@ from eth_utils import encode_hex, decode_hex from golem_messages.cryptography import ECCx, mk_privkey, ecdsa_verify, \ privtopub - +from golem_messages.utils import pubkey_to_address logger = logging.getLogger(__name__) @@ -53,9 +53,8 @@ class WrongPassword(Exception): class KeysAuth: """ Elliptical curves cryptographic authorization manager. Generates - private and public keys based on ECC (curve secp256k1) with specified - difficulty. Private key is stored in file. When this file not exist, is - broken or contain key below requested difficulty new key is generated. + private and public keys based on ECC (curve secp256k1). Private key is + stored in file. When this file not exist or is broken new key is generated. """ KEYS_SUBDIR = 'keys' @@ -64,28 +63,28 @@ class KeysAuth: key_id: str = "" ecc: ECCx = None - def __init__(self, datadir: str, private_key_name: str, password: str, - difficulty: int = 0) -> None: + def __init__( + self, + datadir: str, + private_key_name: str, + password: str, + ) -> None: """ Create new ECC keys authorization manager, load or create keys. :param datadir where to store files :param private_key_name: name of the file containing private key :param password: user password to protect private key - :param difficulty: - desired key difficulty level. It's a number of leading zeros in - binary representation of public key. Value in range <0, 255>. - 0 accepts all keys, 255 is nearly impossible. """ prv, pub = KeysAuth._load_or_generate_keys( - datadir, private_key_name, password, difficulty) + datadir, private_key_name, password) self._private_key = prv self.ecc = ECCx(prv) self.public_key = pub self.key_id = encode_hex(pub)[2:] - self.difficulty = KeysAuth.get_difficulty(self.key_id) + self.eth_addr = pubkey_to_address(pub) @staticmethod def key_exists(datadir: str, private_key_name: str) -> bool: @@ -94,15 +93,17 @@ def key_exists(datadir: str, private_key_name: str) -> bool: return os.path.isfile(priv_key_path) @staticmethod - def _load_or_generate_keys(datadir: str, filename: str, password: str, - difficulty: int) -> Tuple[bytes, bytes]: + def _load_or_generate_keys( + datadir: str, + filename: str, + password: str, + ) -> Tuple[bytes, bytes]: keys_dir = KeysAuth._get_or_create_keys_dir(datadir) priv_key_path = os.path.join(keys_dir, filename) loaded_keys = KeysAuth._load_and_check_keys( priv_key_path, password, - difficulty, ) if loaded_keys: @@ -110,7 +111,7 @@ def _load_or_generate_keys(datadir: str, filename: str, password: str, priv_key, pub_key = loaded_keys else: logger.debug('No keys found, generating new one') - priv_key, pub_key = KeysAuth._generate_keys(difficulty) + priv_key, pub_key = KeysAuth._generate_keys() logger.debug('Generation completed, saving keys') KeysAuth._save_private_key(priv_key, priv_key_path, password) logger.debug('Keys stored succesfully') @@ -125,9 +126,10 @@ def _get_or_create_keys_dir(datadir: str) -> str: return keys_dir @staticmethod - def _load_and_check_keys(priv_key_path: str, - password: str, - difficulty: int) -> Optional[Tuple[bytes, bytes]]: + def _load_and_check_keys( + priv_key_path: str, + password: str, + ) -> Optional[Tuple[bytes, bytes]]: try: with open(priv_key_path, 'r') as f: keystore = f.read() @@ -142,29 +144,13 @@ def _load_and_check_keys(priv_key_path: str, pub_key = privtopub(priv_key) - if not KeysAuth.is_pubkey_difficult(pub_key, difficulty): - raise Exception("Loaded key is not difficult enough") - return priv_key, pub_key @staticmethod - def _generate_keys(difficulty: int) -> Tuple[bytes, bytes]: - from twisted.internet import reactor - reactor_started = reactor.running + def _generate_keys() -> Tuple[bytes, bytes]: logger.info("Generating new key pair") - started = time.time() - while True: - priv_key = mk_privkey(str(get_random_float())) - pub_key = privtopub(priv_key) - if KeysAuth.is_pubkey_difficult(pub_key, difficulty): - break - - # lets be responsive to reactor stop (eg. ^C hit by user) - if reactor_started and not reactor.running: - logger.warning("reactor stopped, aborting key generation ..") - raise Exception("aborting key generation") - - logger.info("Keys generated in %.2fs", time.time() - started) + priv_key = mk_privkey(str(get_random_float())) + pub_key = privtopub(priv_key) return priv_key, pub_key @staticmethod @@ -177,29 +163,6 @@ def _save_private_key(key, key_path, password: str): with open(key_path, 'w') as f: f.write(json.dumps(keystore)) - @staticmethod - def _count_max_hash(difficulty: int) -> int: - return 2 << (256 - difficulty - 1) - - @staticmethod - def is_pubkey_difficult(pub_key: Union[bytes, str], - difficulty: int) -> bool: - if isinstance(pub_key, str): - pub_key = decode_hex(pub_key) - return sha2(pub_key) < KeysAuth._count_max_hash(difficulty) - - def is_difficult(self, difficulty: int) -> bool: - return self.is_pubkey_difficult(self.public_key, difficulty) - - @staticmethod - def get_difficulty(key_id: str) -> int: - """ - Calculate given key difficulty. - This is more expensive to calculate than is_difficult, so use - the latter if possible. - """ - return int(math.floor(256 - math.log2(sha2(decode_hex(key_id))))) - def sign(self, data: bytes) -> bytes: """ Sign given data with ECDSA; diff --git a/golem/core/processmonitor.py b/golem/core/processmonitor.py index c8fe8a68cf..ddbb234167 100644 --- a/golem/core/processmonitor.py +++ b/golem/core/processmonitor.py @@ -1,4 +1,5 @@ import logging +import psutil import subprocess import time from multiprocessing import Process @@ -71,18 +72,24 @@ def kill_processes(self, *_): @classmethod def kill_process(cls, process): if cls.is_process_alive(process): + process_info = psutil.Process(process.pid) + children = process_info.children(recursive=True) try: + for c in children: + c.terminate() + if c.wait(timeout=60) is not None: + c.kill() + process.terminate() if isinstance(process, (psutil.Popen, subprocess.Popen)): process.communicate() elif isinstance(process, Process): process.join() - except Exception as exc: - logger.error("Error terminating process %d: %r", process, exc) + logger.error("Error terminating process %s: %r", process, exc) else: - logger.warning("Subprocess %d terminated", cls._pid(process)) + logger.warning("Subprocess %s terminated", cls._pid(process)) @staticmethod def _pid(process): diff --git a/golem/core/simpleserializer.py b/golem/core/simpleserializer.py index ddd3144806..933825b0c0 100644 --- a/golem/core/simpleserializer.py +++ b/golem/core/simpleserializer.py @@ -3,8 +3,8 @@ import logging import sys import types -from abc import ABCMeta, abstractmethod -from typing import Optional +from abc import ABC, abstractmethod +from enum import Enum from golem_messages import datastructures @@ -15,6 +15,7 @@ class DictCoder: cls_key = 'py/object' + enum_key = 'py/enum' deep_serialization = True builtin_types = [i for i in types.__dict__.values() if isinstance(i, type)] @@ -49,12 +50,30 @@ def obj_from_dict(cls, dictionary): obj = sub_cls.__new__(sub_cls) for k, v in list(dictionary.items()): - if cls._is_class(v): - setattr(obj, k, cls.obj_from_dict(v)) - else: - setattr(obj, k, cls._from_dict_traverse_obj(v)) + setattr(obj, k, cls._from_dict_traverse_obj(v)) return obj + @classmethod + def _enum_to_dict(cls, obj: Enum): + result = dict() + result[cls.enum_key] = "{}.{}".format( + cls.module_and_class(obj), obj.name) + return result + + @classmethod + def _enum_from_dict(cls, dictionary): + path = dictionary[cls.enum_key] + idx1 = path.rfind('.') + idx2 = path.rfind('.', 0, idx1) + + module_name = path[:idx2] + cls_name = path[idx2+1:idx1] + enum_name = path[idx1+1:] + + module = sys.modules[module_name] + cls = getattr(module, cls_name) + return getattr(cls, enum_name) + @classmethod def _to_dict_traverse_dict(cls, dictionary, typed=True): result = dict() @@ -82,6 +101,8 @@ def _to_dict_traverse_obj(cls, obj, typed=True): ) elif isinstance(obj, datastructures.Container): return obj.to_dict() + elif isinstance(obj, Enum): + return cls._enum_to_dict(obj) elif cls.deep_serialization: if hasattr(obj, '__dict__') and not cls._is_builtin(obj): return cls.obj_to_dict(obj, typed) @@ -97,8 +118,10 @@ def _from_dict_traverse_dict(cls, dictionary): @classmethod def _from_dict_traverse_obj(cls, obj): if isinstance(obj, dict): - if cls._is_class(obj): + if cls.cls_key in obj: return cls.obj_from_dict(obj) + if cls.enum_key in obj: + return cls._enum_from_dict(obj) return cls._from_dict_traverse_dict(obj) elif isinstance(obj, str): return to_unicode(obj) @@ -106,10 +129,6 @@ def _from_dict_traverse_obj(cls, obj): return obj.__class__([cls._from_dict_traverse_obj(o) for o in obj]) return obj - @classmethod - def _is_class(cls, obj): - return isinstance(obj, dict) and cls.cls_key in obj - @classmethod def _is_builtin(cls, obj): # pylint: disable=unidiomatic-typecheck @@ -147,12 +166,15 @@ def load(dictionary, as_class=None): return DictCoder.from_dict(dictionary, as_class=as_class) -class DictSerializable(metaclass=ABCMeta): +class DictSerializable(ABC): + @abstractmethod def to_dict(self) -> dict: - "Converts the object to a dict containing only primitive types" + """ Convert the object to a dict containing only primitive types. """ + raise NotImplementedError @staticmethod @abstractmethod - def from_dict(data: Optional[dict]) -> 'DictSerializable': - "Converts the object to a dict containing only primitive types" + def from_dict(data: dict) -> 'DictSerializable': + """ Construct object from a dict containing only primitive types. """ + raise NotImplementedError diff --git a/golem/core/variables.py b/golem/core/variables.py index f37342fd0e..db2706f3bd 100644 --- a/golem/core/variables.py +++ b/golem/core/variables.py @@ -44,7 +44,7 @@ 'pubkey': b'b\x9b>\xf3\xb3\xefW\x92\x93\xfeIW\xd1\n\xf0j\x91\t\xdf\x95\x84\x81b6C\xe8\xe0\xdb\\.P\x00;rZM\xafQI\xf7G\x95\xe3\xe3.h\x19\xf1\x0f\xfa\x8c\xed\x12:\x88\x8aK\x00C9 \xf0~P', # noqa pylint: disable=line-too-long 'certificate': str(CONCENT_CERTIFICATES_DIR / 'staging.crt'), 'deposit_contract_address': - '0xA172A4B929Ae9589E3228F723CB99508b8c0709a', + '0x6486b37b9BaF682B4Eb5Ad05A5757734eD67629f', }, 'test': { 'url': 'https://test.concent.golem.network', @@ -63,7 +63,6 @@ # Number of task headers transmitted per message TASK_HEADERS_LIMIT = 20 -KEY_DIFFICULTY = 14 # Maximum acceptable difference between node time and monitor time (seconds) MAX_TIME_DIFF = 10 @@ -132,4 +131,4 @@ def patch_protocol_id(ctx=None, param=None, value=None): # TASK DEFINITION PICKLED VERSION # ################# -PICKLED_VERSION = 2 +PICKLED_VERSION = 3 diff --git a/golem/database/database.py b/golem/database/database.py index 593ddb53d8..de5cf648f3 100644 --- a/golem/database/database.py +++ b/golem/database/database.py @@ -55,8 +55,7 @@ def execute_sql(self, sql, params=None, require_commit=True): class Database: - - SCHEMA_VERSION = 25 + SCHEMA_VERSION = 33 def __init__(self, # noqa pylint: disable=too-many-arguments db: peewee.Database, diff --git a/golem/database/schemas/007_schema.py b/golem/database/schemas/007_schema.py index 8b8b602e1c..4333032e77 100644 --- a/golem/database/schemas/007_schema.py +++ b/golem/database/schemas/007_schema.py @@ -29,7 +29,7 @@ import datetime as dt import peewee as pw -from golem.model import Actor, PaymentStatus +from golem.model import Actor SCHEMA_VERSION = 7 @@ -166,11 +166,11 @@ class Payment(pw.Model): subtask = pw.CharField(max_length=255, primary_key=True) created_date = pw.DateTimeField(default=dt.datetime.now) modified_date = pw.DateTimeField(default=dt.datetime.now) - status = pw.EnumField(PaymentStatus, default=PaymentStatus.awaiting, + status = pw.IntegerField(default=1, index=True) payee = pw.RawCharField() value = pw.HexIntegerField() - details = pw.PaymentDetailsField() + details = pw.TextField() processed_ts = pw.IntegerField(null=True) class Meta: diff --git a/golem/database/schemas/013_schema.py b/golem/database/schemas/013_schema.py index 22ee9779d8..151b22154b 100644 --- a/golem/database/schemas/013_schema.py +++ b/golem/database/schemas/013_schema.py @@ -1,8 +1,6 @@ # pylint: disable=no-member import peewee as pw -from golem.model import PaymentStatus - SCHEMA_VERSION = 13 @@ -14,18 +12,10 @@ def migrate(migrator, _database, **_kwargs): local_role=pw.ActorField(), remote_role=pw.ActorField()) - migrator.change_fields('payment', status=pw.PaymentStatusField( - default=PaymentStatus.awaiting, index=True - )) - def rollback(migrator, _database, **_kwargs): """Write your rollback migrations here.""" - migrator.change_fields('payment', status=pw.EnumField( - default=PaymentStatus.awaiting, index=True - )) - migrator.change_fields('networkmessage', local_role=pw.EnumField(), remote_role=pw.EnumField()) diff --git a/golem/database/schemas/018_schema.py b/golem/database/schemas/018_schema.py index 9ebca24758..9c07afd57e 100644 --- a/golem/database/schemas/018_schema.py +++ b/golem/database/schemas/018_schema.py @@ -1,7 +1,7 @@ # pylint: disable=no-member # pylint: disable=unused-argument import peewee as pw -from golem.utils import pubkeytoaddr +from golem_messages.utils import pubkey_to_address SCHEMA_VERSION = 18 @@ -19,7 +19,7 @@ def _fill_payer_address(database): break for entry in entries: sender_node, subtask = entry - payer_address = pubkeytoaddr(sender_node)[2:] + payer_address = pubkey_to_address(sender_node)[2:] database.execute_sql( "UPDATE income SET payer_address = ?" " WHERE sender_node = ? AND subtask = ?", diff --git a/golem/database/schemas/026_create_docker_whitelist.py b/golem/database/schemas/026_create_docker_whitelist.py new file mode 100644 index 0000000000..c8be2ae34e --- /dev/null +++ b/golem/database/schemas/026_create_docker_whitelist.py @@ -0,0 +1,22 @@ +# pylint: disable=no-member +# pylint: disable=unused-argument + +import peewee as pw + +SCHEMA_VERSION = 26 + + +def migrate(migrator, database, fake=False, **kwargs): + @migrator.create_model # pylint: disable=unused-variable + class DockerWhitelist(pw.Model): + repository = pw.CharField(primary_key=True) + + class Meta: + db_table = "dockerwhitelist" + + migrator.sql("INSERT INTO dockerwhitelist(repository) " + "VALUES ('golemfactory')") + + +def rollback(migrator, database, fake=False, **kwargs): + migrator.remove_model("dockerwhitelist") diff --git a/golem/database/schemas/027_create_wallet_operation.py b/golem/database/schemas/027_create_wallet_operation.py new file mode 100644 index 0000000000..939a0cc8e1 --- /dev/null +++ b/golem/database/schemas/027_create_wallet_operation.py @@ -0,0 +1,29 @@ +# pylint: disable=no-member +# pylint: disable=unused-argument +import datetime + +import peewee as pw + +SCHEMA_VERSION = 27 + + +def migrate(migrator, database, fake=False, **kwargs): + @migrator.create_model # pylint: disable=unused-variable + class WalletOperation(pw.Model): + tx_hash = pw.CharField() + direction = pw.CharField() + status = pw.CharField() + sender_address = pw.CharField() + recipient_address = pw.CharField() + amount = pw.CharField() + currency = pw.CharField() + gas_cost = pw.CharField() + created_date = pw.DateTimeField(default=datetime.datetime.now) + modified_date = pw.DateTimeField(default=datetime.datetime.now) + + class Meta: + db_table = "walletoperation" + + +def rollback(migrator, database, fake=False, **kwargs): + migrator.remove_model("walletoperation") diff --git a/golem/database/schemas/028_create_task_payment.py b/golem/database/schemas/028_create_task_payment.py new file mode 100644 index 0000000000..5e7c6ac648 --- /dev/null +++ b/golem/database/schemas/028_create_task_payment.py @@ -0,0 +1,28 @@ +# pylint: disable=no-member +# pylint: disable=unused-argument +import datetime + +import peewee as pw + +SCHEMA_VERSION = 28 + + +def migrate(migrator, database, fake=False, **kwargs): + @migrator.create_model # pylint: disable=unused-variable + class TaskPayment(pw.Model): + wallet_operation_id = pw.IntegerField() + node = pw.CharField() + task = pw.CharField() + subtask = pw.CharField() + expected_amount = pw.CharField() + accepted_ts = pw.DateTimeField() + settled_ts = pw.DateTimeField() + created_date = pw.DateTimeField(default=datetime.datetime.now) + modified_date = pw.DateTimeField(default=datetime.datetime.now) + + class Meta: + db_table = "taskpayment" + + +def rollback(migrator, database, fake=False, **kwargs): + migrator.remove_model("taskpayment") diff --git a/golem/database/schemas/029_wallet_operation_type.py b/golem/database/schemas/029_wallet_operation_type.py new file mode 100644 index 0000000000..2cbd1770ba --- /dev/null +++ b/golem/database/schemas/029_wallet_operation_type.py @@ -0,0 +1,16 @@ +# pylint: disable=no-member +# pylint: disable=unused-argument +import peewee as pw + +SCHEMA_VERSION = 29 + + +def migrate(migrator, database, fake=False, **kwargs): + migrator.add_fields( + 'walletoperation', + operation_type=pw.CharField(default='task_payment'), + ) + + +def rollback(migrator, database, fake=False, **kwargs): + migrator.remove_fields('walletoperation', 'task_payment') diff --git a/golem/database/schemas/030_wallet_operation_alter.py b/golem/database/schemas/030_wallet_operation_alter.py new file mode 100644 index 0000000000..cabd61282e --- /dev/null +++ b/golem/database/schemas/030_wallet_operation_alter.py @@ -0,0 +1,51 @@ +# pylint: disable=no-member +# pylint: disable=unused-argument +import peewee as pw + +SCHEMA_VERSION = 30 + + +def _copy_tx_hash(database): + database.execute_sql( + 'UPDATE walletoperation SET null_tx_hash=tx_hash', + ) + + +def migrate(migrator, database, fake=False, **kwargs): + # migrator.drop_not_null('walletoperation', 'tx_hash') + # Due to very limited ALTER TABLE functionality in sqlite + # we'll do it this way. + migrator.add_fields( + 'walletoperation', + null_tx_hash=pw.CharField(null=True), + ) + migrator.python(_copy_tx_hash, database) + migrator.remove_fields('walletoperation', 'tx_hash') + migrator.rename_field('walletoperation', 'null_tx_hash', 'tx_hash') + # End of DROP NOT NULL + migrator.remove_fields('taskpayment', 'accepted_ts', 'settled_ts') + migrator.add_fields( + 'taskpayment', + accepted_ts=pw.IntegerField(null=True, index=True), + settled_ts=pw.IntegerField(null=True), + ) + + +def rollback(migrator, database, fake=False, **kwargs): + # migrator.add_not_null('walletoperation', 'tx_hash') + migrator.add_fields( + 'walletoperation', + not_null_tx_hash=pw.CharField(null=True), + ) + database.execute_sql( + 'UPDATE walletoperation SET not_null_tx_hash=tx_hash', + ) + migrator.remove_fields('walletoperation', 'tx_hash') + migrator.rename_field('walletoperation', 'not_null_tx_hash', 'tx_hash') + + migrator.remove_fields('taskpayment', 'accepted_ts', 'settled_ts') + migrator.add_fields( + 'taskpayment', + accepted_ts=pw.DateTimeField(), + settled_ts=pw.DateTimeField(), + ) diff --git a/golem/database/schemas/031_migrate_payment_to_task_payment.py b/golem/database/schemas/031_migrate_payment_to_task_payment.py new file mode 100644 index 0000000000..7eaa46b4af --- /dev/null +++ b/golem/database/schemas/031_migrate_payment_to_task_payment.py @@ -0,0 +1,74 @@ +# pylint: disable=no-member,unused-argument +import json +import logging + +SCHEMA_VERSION = 31 + +logger = logging.getLogger('golem.database') + + +STATUS_MAPPING = { + 1: 'awaiting', + 2: 'sent', + 3: 'confirmed', + 4: 'overdue', +} + + +def migrate_payment(database, db_row): + details = json.loads(db_row['details']) + status = STATUS_MAPPING[db_row['status']] + cursor = database.execute_sql( + "INSERT INTO walletoperation" + " (tx_hash, direction, operation_type, status, sender_address," + " recipient_address, amount, currency, gas_cost," + " created_date, modified_date)" + " VALUES (?, 'outgoing', 'task_payment', ?, '', ?, ?, 'GNT', ?," + " ?, datetime('now'))", + ( + f"0x{details['tx']}", + status, + f'0x{db_row["payee"]}', + db_row['value'], + details['fee'], + db_row['created_date'], + ), + ) + wallet_operation_id = cursor.lastrowid + cursor.execute( + "INSERT INTO taskpayment" + " (wallet_operation_id, node, task, subtask," + " expected_amount, created_date, modified_date)" + " VALUES (?, ?, '', ?, ?, ?, datetime('now'))", + ( + wallet_operation_id, + details['node_info']['key'], + db_row['subtask'], + db_row['value'], + db_row['created_date'], + ), + ) + + +def migrate(migrator, database, fake=False, **kwargs): + cursor = database.execute_sql( + 'SELECT details, status, payee, value, subtask, created_date' + ' FROM payment' + ) + for db_row in cursor.fetchall(): + dict_row = { + 'details': db_row[0], + 'status': db_row[1], + 'payee': db_row[2], + 'value': db_row[3], + 'subtask': db_row[4], + 'created_date': db_row[5], + } + try: + migrate_payment(database, dict_row) + except Exception: # pylint: disable=broad-except + logger.error("Migration problem. db_row=%s", db_row, exc_info=True) + + +def rollback(migrator, database, fake=False, **kwargs): + pass diff --git a/golem/database/schemas/032_migrate_income_to_task_payment.py b/golem/database/schemas/032_migrate_income_to_task_payment.py new file mode 100644 index 0000000000..4812fcfd44 --- /dev/null +++ b/golem/database/schemas/032_migrate_income_to_task_payment.py @@ -0,0 +1,79 @@ +# pylint: disable=no-member,unused-argument +import logging + +SCHEMA_VERSION = 31 + +logger = logging.getLogger('golem.database') + + +def migrate_income(database, db_row): + if int(db_row['value'], 16) - int(db_row['value_received'], 16) == 0: + status = 'confirmed' + elif db_row['overdue']: + status = 'overdue' + else: + status = 'awaiting' + cursor = database.execute_sql( + "INSERT INTO walletoperation" + " (tx_hash, direction, operation_type, status, sender_address," + " recipient_address, amount, currency, gas_cost," + " created_date, modified_date)" + " VALUES (?, 'incoming', 'task_payment', ?, '', ?, ?, 'GNT', 0," + " ?, datetime('now'))", + ( + f"0x{db_row['transaction']}", + status, + f'0x{db_row["payer_address"]}', + db_row['value_received'], + db_row['created_date'], + ), + ) + wallet_operation_id = cursor.lastrowid + cursor.execute( + "INSERT INTO taskpayment" + " (wallet_operation_id, node, task, subtask," + " expected_amount, created_date, modified_date," + " accepted_ts, settled_ts)" + " VALUES (?, ?, '', ?, ?, ?, datetime('now'), " + " ?, ?)", + ( + wallet_operation_id, + f"0x{db_row['sender_node']}", + db_row['subtask'], + db_row['value'], + db_row['created_date'], + db_row['accepted_ts'], + db_row['settled_ts'], + ), + ) + + +def migrate(migrator, database, fake=False, **kwargs): + cursor = database.execute_sql( + 'SELECT "transaction", payer_address, value, value_received, subtask,' + ' created_date,' + ' accepted_ts, settled_ts,' + ' overdue, sender_node' + ' FROM income' + ) + for db_row in cursor.fetchall(): + dict_row = { + 'transaction': db_row[0], + 'payer_address': db_row[1], + 'value': db_row[2], + 'value_received': db_row[3], + 'subtask': db_row[4], + 'created_date': db_row[5], + 'accepted_ts': db_row[6], + 'settled_ts': db_row[7], + 'overdue': db_row[8], + 'sender_node': db_row[9], + } + try: + migrate_income(database, dict_row) + except Exception: # pylint: disable=broad-except + logger.error("Migration problem. db_row=%s", db_row, exc_info=True) + + +def rollback(migrator, database, fake=False, **kwargs): + pass diff --git a/golem/database/schemas/033_deposit_payment_to_wallet_operation.py b/golem/database/schemas/033_deposit_payment_to_wallet_operation.py new file mode 100644 index 0000000000..1aae584128 --- /dev/null +++ b/golem/database/schemas/033_deposit_payment_to_wallet_operation.py @@ -0,0 +1,72 @@ +# pylint: disable=no-member,unused-argument +import datetime +import logging + +import peewee as pw + +SCHEMA_VERSION = 33 + +logger = logging.getLogger('golem.database') + + +STATUS_MAPPING = { + 1: 'awaiting', + 2: 'sent', + 3: 'confirmed', + 4: 'overdue', +} + + +def migrate_dp(database, db_row): + status = STATUS_MAPPING[db_row['status']] + database.execute_sql( + "INSERT INTO walletoperation" + " (tx_hash, direction, operation_type, status, sender_address," + " recipient_address, amount, currency, gas_cost," + " created_date, modified_date)" + " VALUES (?, 'outgoing', 'deposit_transfer', ?, '', '', ?, 'GNT', ?," + " ?, datetime('now'))", + ( + db_row['tx'], + status, + db_row['value'], + db_row['fee'], + db_row['created_date'], + ), + ) + + +def migrate(migrator, database, fake=False, **kwargs): + cursor = database.execute_sql( + 'SELECT tx, value, status, fee,' + ' created_date' + ' FROM depositpayment' + ) + for db_row in cursor.fetchall(): + dict_row = { + 'tx': db_row[0], + 'value': db_row[1], + 'status': db_row[2], + 'fee': db_row[3], + 'created_date': db_row[4], + } + try: + migrate_dp(database, dict_row) + except Exception: # pylint: disable=broad-except + logger.error("Migration problem. db_row=%s", db_row, exc_info=True) + + migrator.remove_model('depositpayment') + + +def rollback(migrator, database, fake=False, **kwargs): + @migrator.create_model # pylint: disable=unused-variable + class DepositPayment(pw.Model): + value = pw.CharField() + status = pw.IntegerField() + fee = pw.CharField(null=True) + tx = pw.CharField(max_length=66, primary_key=True) + created_date = pw.DateTimeField(default=datetime.datetime.now) + modified_date = pw.DateTimeField(default=datetime.datetime.now) + + class Meta: + db_table = "depositpayment" diff --git a/golem/docker/commands/docker.py b/golem/docker/commands/docker.py index d31f8f31f8..5de48e0a8d 100644 --- a/golem/docker/commands/docker.py +++ b/golem/docker/commands/docker.py @@ -1,4 +1,5 @@ import logging +import shutil import subprocess import time from typing import List, Optional, Dict, Union, Callable, Tuple @@ -25,12 +26,16 @@ class DockerCommandHandler: build=['docker', 'build'], tag=['docker', 'tag'], pull=['docker', 'pull'], - version=['docker', '-v'], + version=['docker', '--version'], help=['docker', '--help'], images=['docker', 'images', '-q'], info=['docker', 'info'], ) + @staticmethod + def docker_available() -> bool: + return bool(shutil.which('docker')) + @classmethod def run(cls, command_name: str, diff --git a/golem/docker/hypervisor/__init__.py b/golem/docker/hypervisor/__init__.py index 35930a8184..6f3e66ad4b 100644 --- a/golem/docker/hypervisor/__init__.py +++ b/golem/docker/hypervisor/__init__.py @@ -1,6 +1,6 @@ import logging import subprocess -from abc import ABCMeta +from abc import ABC, abstractmethod from contextlib import contextmanager from pathlib import Path from typing import Dict, Optional, Iterable @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__) -class Hypervisor(metaclass=ABCMeta): +class Hypervisor(ABC): POWER_UP_DOWN_TIMEOUT = 30 * 1000 # milliseconds SAVE_STATE_TIMEOUT = 120 * 1000 # milliseconds @@ -31,8 +31,9 @@ def __init__(self, self._work_dir: Optional[Path] = None @classmethod + @abstractmethod def is_available(cls) -> bool: - return True + raise NotImplementedError def setup(self) -> None: if not self.vm_running(): @@ -118,32 +119,56 @@ def restore_vm(self, vm_name: Optional[str] = None) -> None: logger.info("Docker: restoring machine state not implemented") self.start_vm(vm_name) + @abstractmethod def create(self, vm_name: Optional[str] = None, **params) -> bool: raise NotImplementedError - def _failed_to_create(self, vm_name: Optional[str] = None): - raise NotImplementedError - + @abstractmethod def constrain(self, name: Optional[str] = None, **params) -> None: raise NotImplementedError + @abstractmethod def constraints(self, name: Optional[str] = None) -> Dict: raise NotImplementedError @contextmanager + @report_calls(Component.hypervisor, 'vm.reconfig') + def reconfig_ctx(self, name: Optional[str] = None): + """ Put machine in appropriate state for configuration change """ + name = name or self._vm_name + if self.vm_running(): + with self.restart_ctx(name) as res: + yield res + else: + yield name + + @contextmanager + @report_calls(Component.hypervisor, 'vm.restart') def restart_ctx(self, name: Optional[str] = None): - raise NotImplementedError + """ Force machine restart """ + name = name or self._vm_name + if self.vm_running(): + self.stop_vm() + yield name + self.start_vm() @contextmanager + @report_calls(Component.hypervisor, 'vm.recover') def recover_ctx(self, name: Optional[str] = None): - raise NotImplementedError + """ Attempt to recover from invalid machine state + By default just restarts the machine """ + name = name or self._vm_name + with self.restart_ctx(name) as res: + yield res def update_work_dir(self, work_dir: Path) -> None: self._work_dir = work_dir - @staticmethod - def uses_volumes() -> bool: - return False - def create_volumes(self, binds: Iterable[DockerBind]) -> dict: - raise NotImplementedError + return { + bind.source_as_posix: { + 'bind': bind.target, + 'mode': bind.mode + } + for bind in binds + } diff --git a/golem/docker/hypervisor/docker_machine.py b/golem/docker/hypervisor/docker_machine.py index 9398ac0f46..53b9f34760 100644 --- a/golem/docker/hypervisor/docker_machine.py +++ b/golem/docker/hypervisor/docker_machine.py @@ -66,22 +66,11 @@ def create(self, vm_name: Optional[str] = None, **params) -> bool: f'stdout="{out}"') return False - @contextmanager - @report_calls(Component.hypervisor, 'vm.recover') - def recover_ctx(self, name: Optional[str] = None): - name = name or self._vm_name - with self.restart_ctx(name) as _name: - yield _name - self._set_env() - @contextmanager @report_calls(Component.hypervisor, 'vm.restart') def restart_ctx(self, name: Optional[str] = None): - name = name or self._vm_name - if self.vm_running(name): - self.stop_vm(name) - yield name - self.start_vm(name) + with super().restart_ctx(name) as res: + yield res self._set_env() @property diff --git a/golem/docker/hypervisor/dummy.py b/golem/docker/hypervisor/dummy.py new file mode 100644 index 0000000000..5b18a39bcd --- /dev/null +++ b/golem/docker/hypervisor/dummy.py @@ -0,0 +1,40 @@ +from typing import Optional, Dict + +from golem.docker.hypervisor import Hypervisor + + +class DummyHypervisor(Hypervisor): + """ + A simple class which implements Hypervisor interface and effectively does + nothing. It is meant to be used in environments where Docker can be used + without a hypervisor. It simplifies code by avoiding conditional statements + which check is hypervisor is needed. + """ + + @classmethod + def is_available(cls) -> bool: + return True + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + def remove(self, name: Optional[str] = None) -> bool: + return True + + def vm_running(self, name: Optional[str] = None) -> bool: + return True + + def start_vm(self, name: Optional[str] = None) -> None: + pass + + def stop_vm(self, name: Optional[str] = None) -> bool: + return True + + def create(self, vm_name: Optional[str] = None, **params) -> bool: + return True + + def constrain(self, name: Optional[str] = None, **params) -> None: + pass + + def constraints(self, name: Optional[str] = None) -> Dict: + return {} diff --git a/golem/docker/hypervisor/hyperv.py b/golem/docker/hypervisor/hyperv.py index 087ef2ae8e..0bb7660dd0 100644 --- a/golem/docker/hypervisor/hyperv.py +++ b/golem/docker/hypervisor/hyperv.py @@ -1,10 +1,11 @@ +from contextlib import contextmanager from enum import Enum import logging import os from pathlib import Path import subprocess import time -from typing import Any, ClassVar, Dict, Iterable, List, Optional +from typing import Any, ClassVar, Dict, Iterable, List, Optional, Union from os_win.constants import HOST_SHUTDOWN_ACTION_SAVE, \ VM_SNAPSHOT_TYPE_DISABLED, HYPERV_VM_STATE_SUSPENDED, \ @@ -29,33 +30,33 @@ logger = logging.getLogger(__name__) -class events(Enum): +class Events(Enum): SMB = 'smb_blocked' MEM = 'lowered_memory' DISK = 'low_diskspace' MESSAGES = { - events.SMB: 'Port {SMB_PORT} unreachable. Please check firewall settings.', - events.MEM: 'Not enough free RAM to start the VM, ' + Events.SMB: 'Port {SMB_PORT} unreachable. Please check firewall settings.', + Events.MEM: 'Not enough free RAM to start the VM, ' 'lowering memory to {mem_mb} MB', - events.DISK: 'Not enough disk space. Creating VM with min memory', + Events.DISK: 'Not enough disk space. Creating VM with min memory', } -EVENTS = { - events.SMB: { +EVENTS: Dict[Events, Dict[str, Union[str, Optional[Dict]]]] = { + Events.SMB: { 'component': Component.hypervisor, 'method': 'setup', 'stage': Stage.exception, 'data': None, }, - events.MEM: { + Events.MEM: { 'component': Component.hypervisor, 'method': 'start_vm', 'stage': Stage.warning, 'data': None, }, - events.DISK: { + Events.DISK: { 'component': Component.hypervisor, 'method': 'start_vm', 'stage': Stage.warning, @@ -118,7 +119,7 @@ def _check_smb_port(self) -> None: # We use splitlines() because output may contain multiple lines with # debug information if output is None or ok_str not in output.splitlines(): - self._log_and_publish_event(events.SMB, SMB_PORT=self.SMB_PORT) + self._log_and_publish_event(Events.SMB, SMB_PORT=self.SMB_PORT) @report_calls(Component.hypervisor, 'vm.save') def save_vm(self, vm_name: Optional[str] = None) -> None: @@ -172,7 +173,7 @@ def start_vm(self, name: Optional[str] = None) -> None: max_memory = self._memory_cap(constr[mem_key]) constr[mem_key] = hardware.cap_memory(constr[mem_key], max_memory, unit=hardware.MemSize.mebi) - self._log_and_publish_event(events.MEM, mem_mb=constr[mem_key]) + self._log_and_publish_event(Events.MEM, mem_mb=constr[mem_key]) # Always constrain to set the appropriate shutdown action self.constrain(name, **constr) @@ -307,6 +308,25 @@ def update_work_dir(self, work_dir: Path) -> None: # Ensure that working directory is shared via SMB smbshare.create_share(self.DOCKER_USER, work_dir) + @contextmanager + @report_calls(Component.hypervisor, 'vm.reconfig') + def reconfig_ctx(self, name: Optional[str] = None): + name = name or self._vm_name + + # VM running -> restart + if self.vm_running(): + with self.restart_ctx(name) as res: + yield res + + # VM suspended -> remove saved state + elif self._vm_utils.get_vm_state(name) == HYPERV_VM_STATE_SUSPENDED: + self._vm_utils.set_vm_state(name, HYPERV_VM_STATE_DISABLED) + yield name + + # VM disabled -> do nothing + else: + yield name + @classmethod def _get_vswitch_name(cls) -> str: return run_powershell( @@ -323,10 +343,6 @@ def _get_hostname_for_sharing(cls) -> str: raise RuntimeError('COMPUTERNAME environment variable not set') return hostname - @staticmethod - def uses_volumes() -> bool: - return True - def create_volumes(self, binds: Iterable[DockerBind]) -> dict: hostname = self._get_hostname_for_sharing() return { @@ -383,14 +399,16 @@ def _get_max_memory(self, constr: Optional[dict] = None) -> int: return hardware.pad_memory(int(0.9 * max_mem_in_mb)) @staticmethod - def _log_and_publish_event(name, **kwargs) -> None: - message = MESSAGES[name].format(**kwargs) - event = EVENTS[name].copy() - event['data'] = message + def _log_and_publish_event(event_type: Events, **kwargs) -> None: + event = EVENTS[event_type].copy() + data = next(iter(kwargs.values())) + message = MESSAGES[event_type].format(**kwargs) if event['stage'] == Stage.warning: + event['data'] = {"status": event_type.value, "value": data} logger.warning(message) else: + event['data'] = message logger.error(message) publish_event(event) diff --git a/golem/docker/hypervisor/virtualbox.py b/golem/docker/hypervisor/virtualbox.py index 52336b47b8..a9d7d09439 100644 --- a/golem/docker/hypervisor/virtualbox.py +++ b/golem/docker/hypervisor/virtualbox.py @@ -45,9 +45,14 @@ def __init__(self, self.ISession = ISession self.LockType = LockType + @classmethod + def is_available(cls) -> bool: + # FIXME: Implement an actual check + return True + @contextmanager - @report_calls(Component.hypervisor, 'vm.restart') - def restart_ctx(self, name: Optional[str] = None): + @report_calls(Component.hypervisor, 'vm.reconfig') + def reconfig_ctx(self, name: Optional[str] = None): name = name or self._vm_name immutable_vm = self._machine_from_arg(name) if not immutable_vm: diff --git a/golem/docker/hypervisor/xhyve.py b/golem/docker/hypervisor/xhyve.py index 22f6f1f555..119637d96a 100644 --- a/golem/docker/hypervisor/xhyve.py +++ b/golem/docker/hypervisor/xhyve.py @@ -19,6 +19,11 @@ class XhyveHypervisor(DockerMachineHypervisor): storage='--xhyve-virtio-9p' ) + @classmethod + def is_available(cls) -> bool: + # FIXME: Implement an actual check + return True + # pylint: disable=arguments-differ def _parse_create_params( self, diff --git a/golem/docker/job.py b/golem/docker/job.py index 068e410d84..bbc0ae76b5 100644 --- a/golem/docker/job.py +++ b/golem/docker/job.py @@ -7,7 +7,7 @@ import docker.errors -from golem.core.common import nt_path_to_posix_path, is_osx, is_windows +from golem.core.common import nt_path_to_posix_path, is_windows from golem.docker.image import DockerImage from .client import local_client @@ -126,20 +126,14 @@ def _prepare(self): host_cfg = client.create_host_config(**self.host_config) - # FIXME: Make the entrypoint.sh behaviour consistent between Windows - # and other OSes. See issue #4102 - if is_windows(): - command = self.entrypoint - else: - command = [self.entrypoint] - self.container = client.create_container( image=self.image.name, volumes=self.volumes, host_config=host_cfg, - command=command, + command=self.entrypoint, working_dir=self.WORK_DIR, environment=self.environment, + user=None if is_windows() else os.getuid(), ) self.container_id = self.container["Id"] if self.container_id is None: @@ -294,12 +288,3 @@ def get_status(self): inspect = client.inspect_container(self.container_id) return inspect["State"]["Status"] return self.state - - @staticmethod - def get_environment() -> dict: - if is_windows(): - return {} - if is_osx(): - return dict(OSX_USER=1) - - return dict(LOCAL_USER_ID=os.getuid()) diff --git a/golem/docker/manager.py b/golem/docker/manager.py index 853f38e9d0..6c9568b7e4 100644 --- a/golem/docker/manager.py +++ b/golem/docker/manager.py @@ -45,6 +45,8 @@ def check_environment(self): try: if not is_linux(): self.hypervisor = self._select_hypervisor() + logger.info("Docker-hypervisor selected. type=%r", + type(self.hypervisor)) self.hypervisor.setup() except Exception as e: # pylint: disable=broad-except logger.error( @@ -141,7 +143,7 @@ def locked_config(self): def get_host_config_for_task(self, binds: Iterable[DockerBind]) -> dict: host_config = dict(self._container_host_config) - if self.hypervisor and self.hypervisor.uses_volumes(): + if self.hypervisor: host_config['binds'] = self.hypervisor.create_volumes(binds) else: host_config['binds'] = { @@ -177,7 +179,7 @@ def constrain(self, restart_vm: bool = True, **params) -> bool: if restart_vm: logger.info("Docker: applying configuration: %r", diff) try: - with self.hypervisor.restart_ctx() as vm: + with self.hypervisor.reconfig_ctx() as vm: self.hypervisor.constrain(vm, **diff) except Exception as e: logger.error("Docker: error updating configuration: %r", e) diff --git a/golem/docker/task_thread.py b/golem/docker/task_thread.py index f892cdb815..1a3ee9bc9a 100644 --- a/golem/docker/task_thread.py +++ b/golem/docker/task_thread.py @@ -157,12 +157,10 @@ def _run_docker_job(self) -> Optional[int]: binds = self._get_default_binds() volumes = list(bind.target for bind in binds) - environment = DockerJob.get_environment() - - environment.update( + environment = dict( WORK_DIR=DockerJob.WORK_DIR, RESOURCES_DIR=DockerJob.RESOURCES_DIR, - OUTPUT_DIR=DockerJob.OUTPUT_DIR + OUTPUT_DIR=DockerJob.OUTPUT_DIR, ) assert self.image is not None diff --git a/golem/environments/environment.py b/golem/environments/environment.py index 66b3ee81a6..af307943b1 100644 --- a/golem/environments/environment.py +++ b/golem/environments/environment.py @@ -3,10 +3,9 @@ from os import path -from apps.rendering.benchmark.minilight.src.minilight import make_perf_test - from golem.core.common import get_golem_path from golem.environments.minperformancemultiplier import MinPerformanceMultiplier +from golem.envs.docker.benchmark.minilight import make_perf_test from golem.model import Performance @@ -37,6 +36,13 @@ def ok(cls) -> 'SupportStatus': def err(cls, desc) -> 'SupportStatus': return cls(False, desc) + @property + def err_reason(self): + try: + return list(self.desc.keys())[0] + except (IndexError, AttributeError): + return None + def __repr__(self) -> str: return '' % \ ('ok' if self._ok else 'err', self.desc) @@ -113,9 +119,7 @@ def get_min_accepted_performance(cls) -> float: def run_default_benchmark(cls, save=False): logger = logging.getLogger('golem.task.benchmarkmanager') logger.info('Running benchmark for %s', cls.get_id()) - test_file = path.join(get_golem_path(), 'apps', 'rendering', - 'benchmark', 'minilight', 'cornellbox.ml.txt') - performance = make_perf_test(test_file) + performance = make_perf_test() logger.info('%s performance is %.2f', cls.get_id(), performance) if save: Performance.update_or_create(cls.get_id(), performance) diff --git a/golem/envs/__init__.py b/golem/envs/__init__.py new file mode 100644 index 0000000000..445acb701f --- /dev/null +++ b/golem/envs/__init__.py @@ -0,0 +1,526 @@ +from abc import ABC, abstractmethod +from copy import deepcopy +from enum import Enum +from logging import Logger, getLogger +from threading import RLock +from pathlib import Path + +from typing import Any, Callable, Dict, List, Optional, NamedTuple, Union, \ + Sequence, Iterable, ContextManager, Set + +from twisted.internet.defer import Deferred +from twisted.internet.threads import deferToThread +from twisted.python.failure import Failure + +from golem.core.simpleserializer import DictSerializable + +CounterId = str +CounterUsage = Any + +EnvId = str + + +class RuntimeEventType(Enum): + PREPARED = 1 + STARTED = 2 + STOPPED = 3 + TORN_DOWN = 4 + ERROR_OCCURRED = 5 + + +class RuntimeEvent(NamedTuple): + type: RuntimeEventType + details: Optional[Dict[str, Any]] = None + + +RuntimeEventListener = Callable[[RuntimeEvent], Any] + + +class EnvEventType(Enum): + ENABLED = 1 + DISABLED = 2 + PREREQUISITES_INSTALLED = 3 + CONFIG_UPDATED = 4 + ERROR_OCCURRED = 5 + + def __str__(self) -> str: + return self.name + + +class EnvEvent(NamedTuple): + type: EnvEventType + env_id: EnvId + details: Optional[Dict[str, Any]] = None + + +EnvEventListener = Callable[[EnvEvent], Any] + + +class EnvConfig(DictSerializable, ABC): + """ Environment-wide configuration. Specifies e.g. available resources. """ + + +class Prerequisites(DictSerializable, ABC): + """ + Environment-specific requirements for computing a task. Distributed with the + task header. Providers are expected to prepare (download, install, etc.) + prerequisites in advance not to waste computation time. + """ + + +class Payload(DictSerializable, ABC): + """ + A definition for Runtime. Environment-specific description of computation to + be run. Received when provider is assigned a subtask. + """ + + +class EnvSupportStatus(NamedTuple): + """ Is the environment supported? If not, why? """ + supported: bool + nonsupport_reason: Optional[str] = None + + +class RuntimeStatus(Enum): + CREATED = 0 + PREPARING = 1 + PREPARED = 2 + STARTING = 3 + RUNNING = 4 + STOPPED = 5 + CLEANING_UP = 6 + TORN_DOWN = 7 + FAILURE = 8 + + def __str__(self) -> str: + return self.name + + +class RuntimeInput(ContextManager['RuntimeInput'], ABC): + """ A handle for writing to standard input stream of a running Runtime. + Input could be either raw (bytes) or encoded (str). Could be used as a + context manager to call .close() automatically. """ + + def __init__(self, encoding: Optional[str] = None) -> None: + self._encoding = encoding + + def _encode(self, line: Union[str, bytes]) -> bytes: + """ Encode given data (if needed). If the Input is encoded it expects + the argument to be str, otherwise bytes is expected. """ + if self._encoding: + assert isinstance(line, str) + return line.encode(self._encoding) + assert isinstance(line, bytes) + return line + + @abstractmethod + def write(self, data: Union[str, bytes]) -> None: + """ Write data to the stream. Raw input would accept only str while + encoded input only bytes. An attempt to write to a closed input + would rise an error. """ + raise NotImplementedError + + @abstractmethod + def close(self): + """ Close the input and send EOF to the Runtime. Calling this method on + a closed input won't do anything. + NOTE: If there are many open input handles for a single Runtime + then closing one of them will effectively close all the other. """ + + def __enter__(self) -> 'RuntimeInput': + return self + + def __exit__(self, *_, **__) -> None: + self.close() + + +class RuntimeOutput(Iterable[Union[str, bytes]], ABC): + """ A handle for reading output (either stdout or stderr) from a running + Runtime. Yielded items are output lines. Output could be either raw + (bytes) or decoded (str). """ + + def __init__(self, encoding: Optional[str] = None) -> None: + self._encoding = encoding + + def _decode(self, line: bytes) -> Union[str, bytes]: + if self._encoding: + return line.decode(self._encoding) + return line + + +class Runtime(ABC): + """ A runnable object representing some particular computation. Tied to a + particular Environment that was used to create this object. """ + + def __init__(self, logger: Optional[Logger] = None) -> None: + self._logger = logger or getLogger(__name__) + self._status = RuntimeStatus.CREATED + self._status_lock = RLock() + self._event_listeners: \ + Dict[RuntimeEventType, Set[RuntimeEventListener]] = {} + + @staticmethod + def _assert_status( + actual: RuntimeStatus, + expected: Union[RuntimeStatus, Sequence[RuntimeStatus]]) -> None: + """ Assert that actual status is one of the expected. """ + + if isinstance(expected, RuntimeStatus): + expected = [expected] + + if actual not in expected: + exp_str = " or ".join(map(str, expected)) + raise ValueError( + f"Invalid status: {actual}. Expected: {exp_str}") + + def _change_status( + self, + from_status: Union[RuntimeStatus, Sequence[RuntimeStatus]], + to_status: RuntimeStatus) -> None: + """ Assert that current Runtime status is the given one and change to + another one. Using lock to ensure atomicity. """ + + with self._status_lock: + self._assert_status(self._status, from_status) + self._status = to_status + + def _set_status(self, status) -> None: + with self._status_lock: + self._status = status + + def _emit_event( + self, + event_type: RuntimeEventType, + details: Optional[Dict[str, Any]] = None + ) -> None: + """ Create an event with the given type and details and send a copy to + every listener registered for this type of events. """ + + event = RuntimeEvent( + type=event_type, + details=details + ) + self._logger.debug("Emit event: %r", event) + + def _handler_error_callback(failure): + self._logger.error( + "Error occurred in event handler.", exc_info=failure.value) + + for listener in self._event_listeners.get(event_type, ()): + deferred = deferToThread(listener, deepcopy(event)) + deferred.addErrback(_handler_error_callback) + + def _prepared(self, *_) -> None: + """ Acknowledge that Runtime has been prepared. Log message, set status + and emit event. Arguments are ignored (for callback use). """ + self._logger.info("Runtime prepared.") + self._set_status(RuntimeStatus.PREPARED) + self._emit_event(RuntimeEventType.PREPARED) + + def _started(self, *_) -> None: + """ Acknowledge that Runtime has been started. Log message, set status + and emit event. Arguments are ignored (for callback use). """ + self._logger.info("Runtime started.") + self._set_status(RuntimeStatus.RUNNING) + self._emit_event(RuntimeEventType.STARTED) + + def _stopped(self, *_) -> None: + """ Acknowledge that Runtime has been stopped. Log message, set status + and emit event. Arguments are ignored (for callback use). """ + self._logger.info("Runtime stopped.") + self._set_status(RuntimeStatus.STOPPED) + self._emit_event(RuntimeEventType.STOPPED) + + def _torn_down(self, *_) -> None: + """ Acknowledge that Runtime has been torn down. Log message, set status + and emit event. Arguments are ignored (for callback use). """ + self._logger.info("Runtime torn down.") + self._set_status(RuntimeStatus.TORN_DOWN) + self._emit_event(RuntimeEventType.TORN_DOWN) + + def _error_occurred( + self, + error: Optional[Exception], + message: str, + set_status: bool = True + ) -> None: + """ Acknowledge that an error occurred in runtime. Log message and emit + event. If set_status is True also set status to 'FAILURE'. """ + self._logger.error(message, exc_info=error) + if set_status: + self._set_status(RuntimeStatus.FAILURE) + self._emit_event( + RuntimeEventType.ERROR_OCCURRED, { + 'error': error, + 'message': message + }) + + def _error_callback(self, message: str) -> Callable[[Failure], Failure]: + """ Get an error callback accepting Twisted's Failure object that will + call _error_occurred(). """ + def _callback(failure): + self._error_occurred(failure.value, message) + return failure + return _callback + + @abstractmethod + def prepare(self) -> Deferred: + """ Prepare the Runtime to be started. Assumes current status is + 'CREATED'. """ + raise NotImplementedError + + @abstractmethod + def clean_up(self) -> Deferred: + """ Clean up after the Runtime has finished running. Assumes current + status is 'STOPPED' or 'FAILURE'. In the latter case it is not + guaranteed that the cleanup will be successful. """ + raise NotImplementedError + + @abstractmethod + def start(self) -> Deferred: + """ Start the computation. Assumes current status is 'PREPARED'. """ + raise NotImplementedError + + @abstractmethod + def wait_until_stopped(self) -> Deferred: + """ Can be called after calling `start` to wait until the runtime has + stopped """ + raise NotImplementedError + + @abstractmethod + def stop(self) -> Deferred: + """ Interrupt the computation. Assumes current status is 'RUNNING'. """ + raise NotImplementedError + + def status(self) -> RuntimeStatus: + """ Get the current status of the Runtime. """ + with self._status_lock: + return self._status + + @abstractmethod + def stdin(self, encoding: Optional[str] = None) -> RuntimeInput: + """ Get STDIN stream of the Runtime. If encoding is None the returned + stream will be raw (accepting bytes), otherwise it will be encoded + (accepting str). Assumes current status is 'PREPARED', 'STARTING', + or 'RUNNING'. """ + raise NotImplementedError + + @abstractmethod + def stdout(self, encoding: Optional[str] = None) -> RuntimeOutput: + """ Get STDOUT stream of the Runtime. If encoding is None the returned + stream will be raw (bytes), otherwise it will be decoded (str). + Assumes current status is one of the following: 'PREPARED', + 'STARTING', 'RUNNING', 'STOPPED', or 'FAILURE' (however, in the + last case output might not be available). """ + raise NotImplementedError + + @abstractmethod + def stderr(self, encoding: Optional[str] = None) -> RuntimeOutput: + """ Get STDERR stream of the Runtime. If encoding is None the returned + stream will be raw (bytes), otherwise it will be decoded (str). + Assumes current status is 'RUNNING', 'STOPPED', or 'FAILURE' + (however, in the last case output might not be available). """ + raise NotImplementedError + + @abstractmethod + def usage_counters(self) -> Dict[CounterId, CounterUsage]: + """ For each usage counter supported by the Environment (e.g. clock + time) get current usage by this Runtime. """ + raise NotImplementedError + + def listen(self, event_type: RuntimeEventType, + listener: RuntimeEventListener) -> None: + """ Register a listener for a given type of Runtime events. """ + self._event_listeners.setdefault(event_type, set()).add(listener) + + @abstractmethod + def call(self, alias: str, *args, **kwargs) -> Deferred: + """ Send RPC call to the Runtime. """ + raise NotImplementedError + + +class EnvMetadata(NamedTuple): + id: EnvId + description: str + supported_counters: List[CounterId] + custom_metadata: Dict[str, Any] + + +class EnvStatus(Enum): + DISABLED = 0 + PREPARING = 1 + ENABLED = 2 + CLEANING_UP = 3 + ERROR = 4 + + def __str__(self) -> str: + return self.name + + +class Environment(ABC): + """ An Environment capable of running computations. It is responsible for + creating Runtimes. """ + + def __init__(self, logger: Optional[Logger] = None) -> None: + self._status = EnvStatus.DISABLED + self._logger = logger or getLogger(__name__) + self._event_listeners: Dict[EnvEventType, Set[EnvEventListener]] = {} + + def _emit_event( + self, + event_type: EnvEventType, + details: Optional[Dict[str, Any]] = None + ) -> None: + """ Create an event with the given type and details and send a copy to + every listener registered for this type of events. """ + + event = EnvEvent( + env_id=self.metadata().id, + type=event_type, + details=details + ) + self._logger.debug("Emit event: %r", event) + + def _handler_error_callback(failure): + self._logger.error( + "Error occurred in event handler.", exc_info=failure.value) + + for listener in self._event_listeners.get(event_type, ()): + deferred = deferToThread(listener, deepcopy(event)) + deferred.addErrback(_handler_error_callback) + + def _env_enabled(self) -> None: + """ Acknowledge that Runtime has been enabled. Log message, set status + and emit event. Arguments are ignored (for callback use). """ + self._logger.info("Environment enabled.") + self._status = EnvStatus.ENABLED + self._emit_event(EnvEventType.ENABLED) + + def _env_disabled(self) -> None: + """ Acknowledge that Runtime has been disabled. Log message, set status + and emit event. Arguments are ignored (for callback use). """ + self._logger.info("Environment disabled.") + self._status = EnvStatus.DISABLED + self._emit_event(EnvEventType.DISABLED) + + def _config_updated(self, config: EnvConfig) -> None: + """ Acknowledge that Runtime's config has been updated. Log message and + emit event. The updated config is included in event's details. """ + self._logger.info("Configuration updated.") + self._emit_event(EnvEventType.CONFIG_UPDATED, {'config': config}) + + def _prerequisites_installed(self, prerequisites: Prerequisites) -> None: + """ Acknowledge that Prerequisites have been installed. Log message and + emit event. The installed prerequisites are included in event's + details. """ + self._logger.info("Prerequisites installed.") + self._emit_event( + EnvEventType.PREREQUISITES_INSTALLED, + {'prerequisites': prerequisites}) + + def _error_occurred( + self, + error: Optional[Exception], + message: str, + set_status: bool = True + ) -> None: + """ Acknowledge that an error occurred in runtime. Log message and emit + event. If set_status is True also set status to 'FAILURE'. """ + self._logger.error(message, exc_info=error) + if set_status: + self._status = EnvStatus.ERROR + self._emit_event( + EnvEventType.ERROR_OCCURRED, { + 'error': error, + 'message': message + }) + + @classmethod + @abstractmethod + def supported(cls) -> EnvSupportStatus: + """ Is the Environment supported on this machine? """ + raise NotImplementedError + + def status(self) -> EnvStatus: + """ Get current status of the Environment. """ + return self._status + + @abstractmethod + def prepare(self) -> Deferred: + """ Activate the Environment. Assumes current status is 'DISABLED'. """ + raise NotImplementedError + + @abstractmethod + def clean_up(self) -> Deferred: + """ Deactivate the Environment. Assumes current status is 'ENABLED' or + 'ERROR'. """ + raise NotImplementedError + + @abstractmethod + def run_benchmark(self) -> Deferred: + """ Get the general performace score for this environment. """ + raise NotImplementedError + + @classmethod + @abstractmethod + def metadata(cls) -> EnvMetadata: + """ Get Environment metadata. """ + raise NotImplementedError + + @classmethod + @abstractmethod + def parse_prerequisites(cls, prerequisites_dict: Dict[str, Any]) \ + -> Prerequisites: + """ Build Prerequisites struct from supplied dictionary. Returned value + is of appropriate type for calling install_prerequisites(). """ + raise NotImplementedError + + @abstractmethod + def install_prerequisites(self, prerequisites: Prerequisites) -> Deferred: + """ Prepare Prerequisites for running a computation. Assumes current + status is 'ENABLED'. + Returns boolean indicating whether installation was successful. """ + raise NotImplementedError + + @classmethod + @abstractmethod + def parse_config(cls, config_dict: Dict[str, Any]) -> EnvConfig: + """ Build config struct from supplied dictionary. Returned value + is of appropriate type for calling update_config(). """ + raise NotImplementedError + + @abstractmethod + def config(self) -> EnvConfig: + """ Get current configuration of the Environment. """ + raise NotImplementedError + + @abstractmethod + def update_config(self, config: EnvConfig) -> None: + """ Update configuration. Assumes current status is 'DISABLED'. """ + raise NotImplementedError + + def listen(self, event_type: EnvEventType, listener: EnvEventListener) \ + -> None: + """ Register a listener for a given type of Environment events. """ + self._event_listeners.setdefault(event_type, set()).add(listener) + + @classmethod + @abstractmethod + def parse_payload(cls, payload_dict: Dict[str, Any]) -> Payload: + """ Build Payload struct from supplied dictionary. Returned value + is of appropriate type for calling runtime(). """ + raise NotImplementedError + + @abstractmethod + def runtime( + self, + payload: Payload, + shared_dir: Optional[Path] = None, + config: Optional[EnvConfig] = None + ) -> Runtime: + """ Create a Runtime from the given Payload. Optionally, share the + specified directory with the created Runtime. Optionally, override + current config with the supplied one (it is however not guaranteed + that all config parameters could be overridden). Assumes current + status is 'ENABLED'. """ + raise NotImplementedError diff --git a/golem/envs/docker/__init__.py b/golem/envs/docker/__init__.py new file mode 100644 index 0000000000..acdbf4479c --- /dev/null +++ b/golem/envs/docker/__init__.py @@ -0,0 +1,41 @@ +from typing import NamedTuple, Optional, Dict, Any + +from golem.envs import Payload, Prerequisites + + +class DockerPayloadData(NamedTuple): + image: str + tag: str + env: Dict[str, str] + command: Optional[str] = None + user: Optional[str] = None + work_dir: Optional[str] = None + + +class DockerPayload(DockerPayloadData, Payload): + """ This exists because NamedTuple must be single superclass """ + + def to_dict(self) -> Dict[str, Any]: + return self._asdict() + + @staticmethod + def from_dict(data: Dict[str, Any]) -> 'DockerPayload': + data = data.copy() + env = data.pop('env', {}) + return DockerPayload(env=env, **data) + + +class DockerPrerequisitesData(NamedTuple): + image: str + tag: str + + +class DockerPrerequisites(DockerPrerequisitesData, Prerequisites): + """ This exists because NamedTuple must be single superclass """ + + def to_dict(self) -> Dict[str, Any]: + return self._asdict() + + @staticmethod + def from_dict(data: Dict[str, Any]) -> 'DockerPrerequisites': + return DockerPrerequisites(**data) diff --git a/golem/envs/docker/benchmark/Dockerfile b/golem/envs/docker/benchmark/Dockerfile new file mode 100644 index 0000000000..ce24f2bc82 --- /dev/null +++ b/golem/envs/docker/benchmark/Dockerfile @@ -0,0 +1,8 @@ +FROM golemfactory/base:1.5 + +MAINTAINER Golem Tech + +COPY minilight /golem/minilight +COPY entrypoint.py /golem/ + +ENTRYPOINT ["python3", "/golem/entrypoint.py"] diff --git a/golem/envs/docker/benchmark/entrypoint.py b/golem/envs/docker/benchmark/entrypoint.py new file mode 100644 index 0000000000..db92e7b8dd --- /dev/null +++ b/golem/envs/docker/benchmark/entrypoint.py @@ -0,0 +1,6 @@ +from minilight import make_perf_test + + +if __name__ == '__main__': + score = make_perf_test() + print(score) diff --git a/golem/envs/docker/benchmark/minilight/__init__.py b/golem/envs/docker/benchmark/minilight/__init__.py new file mode 100644 index 0000000000..1958db68e5 --- /dev/null +++ b/golem/envs/docker/benchmark/minilight/__init__.py @@ -0,0 +1,9 @@ +from pathlib import Path + +from .src.minilight import make_perf_test as make_perf_test_impl + +TESTFILE = Path(__file__).parent / 'cornellbox.ml.txt' + + +def make_perf_test() -> float: + return make_perf_test_impl(str(TESTFILE)) diff --git a/apps/rendering/benchmark/minilight/cornellbox.ml.txt b/golem/envs/docker/benchmark/minilight/cornellbox.ml.txt similarity index 100% rename from apps/rendering/benchmark/minilight/cornellbox.ml.txt rename to golem/envs/docker/benchmark/minilight/cornellbox.ml.txt diff --git a/apps/rendering/benchmark/minilight/src/__init__.py b/golem/envs/docker/benchmark/minilight/src/__init__.py similarity index 100% rename from apps/rendering/benchmark/minilight/src/__init__.py rename to golem/envs/docker/benchmark/minilight/src/__init__.py diff --git a/apps/rendering/benchmark/minilight/src/camera.py b/golem/envs/docker/benchmark/minilight/src/camera.py similarity index 100% rename from apps/rendering/benchmark/minilight/src/camera.py rename to golem/envs/docker/benchmark/minilight/src/camera.py diff --git a/apps/rendering/benchmark/minilight/src/image.py b/golem/envs/docker/benchmark/minilight/src/image.py similarity index 100% rename from apps/rendering/benchmark/minilight/src/image.py rename to golem/envs/docker/benchmark/minilight/src/image.py diff --git a/apps/rendering/benchmark/minilight/src/img.py b/golem/envs/docker/benchmark/minilight/src/img.py similarity index 100% rename from apps/rendering/benchmark/minilight/src/img.py rename to golem/envs/docker/benchmark/minilight/src/img.py diff --git a/apps/rendering/benchmark/minilight/src/maxilight.py b/golem/envs/docker/benchmark/minilight/src/maxilight.py similarity index 100% rename from apps/rendering/benchmark/minilight/src/maxilight.py rename to golem/envs/docker/benchmark/minilight/src/maxilight.py diff --git a/apps/rendering/benchmark/minilight/src/minilight.py b/golem/envs/docker/benchmark/minilight/src/minilight.py similarity index 100% rename from apps/rendering/benchmark/minilight/src/minilight.py rename to golem/envs/docker/benchmark/minilight/src/minilight.py diff --git a/apps/rendering/benchmark/minilight/src/randommini.py b/golem/envs/docker/benchmark/minilight/src/randommini.py similarity index 100% rename from apps/rendering/benchmark/minilight/src/randommini.py rename to golem/envs/docker/benchmark/minilight/src/randommini.py diff --git a/apps/rendering/benchmark/minilight/src/raytracer.py b/golem/envs/docker/benchmark/minilight/src/raytracer.py similarity index 100% rename from apps/rendering/benchmark/minilight/src/raytracer.py rename to golem/envs/docker/benchmark/minilight/src/raytracer.py diff --git a/apps/rendering/benchmark/minilight/src/rendertask.py b/golem/envs/docker/benchmark/minilight/src/rendertask.py similarity index 100% rename from apps/rendering/benchmark/minilight/src/rendertask.py rename to golem/envs/docker/benchmark/minilight/src/rendertask.py diff --git a/apps/rendering/benchmark/minilight/src/rendertaskcreator.py b/golem/envs/docker/benchmark/minilight/src/rendertaskcreator.py similarity index 100% rename from apps/rendering/benchmark/minilight/src/rendertaskcreator.py rename to golem/envs/docker/benchmark/minilight/src/rendertaskcreator.py diff --git a/apps/rendering/benchmark/minilight/src/renderworker.py b/golem/envs/docker/benchmark/minilight/src/renderworker.py similarity index 100% rename from apps/rendering/benchmark/minilight/src/renderworker.py rename to golem/envs/docker/benchmark/minilight/src/renderworker.py diff --git a/apps/rendering/benchmark/minilight/src/scene.py b/golem/envs/docker/benchmark/minilight/src/scene.py similarity index 100% rename from apps/rendering/benchmark/minilight/src/scene.py rename to golem/envs/docker/benchmark/minilight/src/scene.py diff --git a/apps/rendering/benchmark/minilight/src/spatialindex.py b/golem/envs/docker/benchmark/minilight/src/spatialindex.py similarity index 100% rename from apps/rendering/benchmark/minilight/src/spatialindex.py rename to golem/envs/docker/benchmark/minilight/src/spatialindex.py diff --git a/apps/rendering/benchmark/minilight/src/surfacepoint.py b/golem/envs/docker/benchmark/minilight/src/surfacepoint.py similarity index 100% rename from apps/rendering/benchmark/minilight/src/surfacepoint.py rename to golem/envs/docker/benchmark/minilight/src/surfacepoint.py diff --git a/apps/rendering/benchmark/minilight/src/task_data_0.py b/golem/envs/docker/benchmark/minilight/src/task_data_0.py similarity index 100% rename from apps/rendering/benchmark/minilight/src/task_data_0.py rename to golem/envs/docker/benchmark/minilight/src/task_data_0.py diff --git a/apps/rendering/benchmark/minilight/src/taskablelight.py b/golem/envs/docker/benchmark/minilight/src/taskablelight.py similarity index 100% rename from apps/rendering/benchmark/minilight/src/taskablelight.py rename to golem/envs/docker/benchmark/minilight/src/taskablelight.py diff --git a/apps/rendering/benchmark/minilight/src/taskablerenderer.py b/golem/envs/docker/benchmark/minilight/src/taskablerenderer.py similarity index 100% rename from apps/rendering/benchmark/minilight/src/taskablerenderer.py rename to golem/envs/docker/benchmark/minilight/src/taskablerenderer.py diff --git a/apps/rendering/benchmark/minilight/src/triangle.py b/golem/envs/docker/benchmark/minilight/src/triangle.py similarity index 100% rename from apps/rendering/benchmark/minilight/src/triangle.py rename to golem/envs/docker/benchmark/minilight/src/triangle.py diff --git a/apps/rendering/benchmark/minilight/src/vector3f.py b/golem/envs/docker/benchmark/minilight/src/vector3f.py similarity index 100% rename from apps/rendering/benchmark/minilight/src/vector3f.py rename to golem/envs/docker/benchmark/minilight/src/vector3f.py diff --git a/golem/envs/docker/cpu.py b/golem/envs/docker/cpu.py new file mode 100644 index 0000000000..0d9994f8e5 --- /dev/null +++ b/golem/envs/docker/cpu.py @@ -0,0 +1,705 @@ +import logging +import os +from pathlib import Path +from socket import socket, SocketIO, SHUT_WR +from threading import Thread, Lock +from time import sleep +from typing import Optional, Any, Dict, List, Type, ClassVar, \ + NamedTuple, Tuple, Iterator, Union, Iterable + +from docker.errors import APIError +from twisted.internet.defer import Deferred, inlineCallbacks +from twisted.internet.threads import deferToThread +from urllib3.contrib.pyopenssl import WrappedSocket + +from golem import hardware +from golem.core.common import is_linux, is_windows, is_osx +from golem.docker.client import local_client +from golem.docker.config import CONSTRAINT_KEYS +from golem.docker.hypervisor import Hypervisor +from golem.docker.hypervisor.docker_for_mac import DockerForMac +from golem.docker.hypervisor.dummy import DummyHypervisor +from golem.docker.hypervisor.hyperv import HyperVHypervisor +from golem.docker.hypervisor.virtualbox import VirtualBoxHypervisor +from golem.docker.hypervisor.xhyve import XhyveHypervisor +from golem.docker.task_thread import DockerBind +from golem.envs import Environment, EnvSupportStatus, Payload, EnvConfig, \ + Runtime, EnvMetadata, EnvStatus, CounterId, CounterUsage, RuntimeStatus, \ + EnvId, Prerequisites, RuntimeOutput, RuntimeInput +from golem.envs.docker import DockerPayload, DockerPrerequisites +from golem.envs.docker.whitelist import Whitelist + +logger = logging.getLogger(__name__) + +# Keys used by hypervisors for memory and CPU constraints +mem = CONSTRAINT_KEYS['mem'] +cpu = CONSTRAINT_KEYS['cpu'] + + +class DockerCPUConfigData(NamedTuple): + work_dir: Path + memory_mb: int = 1024 + cpu_count: int = 1 + + +class DockerCPUConfig(DockerCPUConfigData, EnvConfig): + """ This exists because NamedTuple must be single superclass """ + + def to_dict(self) -> Dict[str, Any]: + dict_ = self._asdict() + dict_['work_dir'] = str(dict_['work_dir']) + return dict_ + + @staticmethod + def from_dict(dict_: Dict[str, Any]) -> 'DockerCPUConfig': + work_dir = Path(dict_.pop('work_dir')) + return DockerCPUConfig(work_dir=work_dir, **dict_) + + +class DockerOutput(RuntimeOutput): + + def __init__( + self, raw_output: Iterable[bytes], encoding: Optional[str] = None + ) -> None: + super().__init__(encoding=encoding) + self._raw_output = raw_output + + def __iter__(self) -> Iterator[Union[str, bytes]]: + buffer = b"" + + for chunk in self._raw_output: + buffer += chunk + lines = buffer.split(b"\n") + buffer = lines.pop() + + for line in lines: + yield self._decode(line + b"\n") # Keep the newline character + + if buffer: + yield self._decode(buffer) + + +class InputSocket: + """ Wrapper class providing uniform interface for different types of + sockets. It is necessary due to poor design of attach_socket(). + Stdin socket is thread-safe (all operations use lock). """ + + def __init__(self, sock: Union[WrappedSocket, SocketIO]) -> None: + if isinstance(sock, WrappedSocket): + self._sock: Union[WrappedSocket, socket] = sock + elif isinstance(sock, SocketIO): + self._sock = sock._sock + else: + raise TypeError(f"Invalid socket class: {sock.__class__}") + self._lock = Lock() + self._closed = False + + def write(self, data: bytes) -> None: + with self._lock: + if self._closed: + raise RuntimeError("Socket closed") + self._sock.sendall(data) + + def close(self) -> None: + with self._lock: + if self._closed: + return + if isinstance(self._sock, socket): + self._sock.shutdown(SHUT_WR) + else: + self._sock.shutdown() + self._sock.close() + self._closed = True + + def closed(self) -> bool: + return self._closed + + +class DockerInput(RuntimeInput): + + def __init__(self, sock: InputSocket, encoding: Optional[str] = None) \ + -> None: + super().__init__(encoding=encoding) + self._sock = sock + + def write(self, data: Union[str, bytes]) -> None: + encoded = self._encode(data) + self._sock.write(encoded) + + def close(self): + self._sock.close() + + +class DockerCPURuntime(Runtime): + + CONTAINER_RUNNING: ClassVar[List[str]] = ["running"] + CONTAINER_STOPPED: ClassVar[List[str]] = ["exited", "dead"] + + STATUS_UPDATE_INTERVAL = 1.0 # seconds + + def __init__( + self, + payload: DockerPayload, + host_config: Dict[str, Any], + volumes: Optional[List[str]] + ) -> None: + super().__init__(logger=logger) + + image = f"{payload.image}:{payload.tag}" + client = local_client() + + self._status_update_thread: Optional[Thread] = None + self._container_id: Optional[str] = None + self._stdin_socket: Optional[InputSocket] = None + self._container_config = client.create_container_config( + image=image, + volumes=volumes, + command=payload.command, + user=payload.user, + environment=payload.env, + working_dir=payload.work_dir, + host_config=host_config, + stdin_open=True + ) + + def _inspect_container(self) -> Tuple[str, int]: + """ Inspect Docker container associated with this runtime. Returns + (status, exit_code) tuple. """ + assert self._container_id is not None + client = local_client() + inspection = client.inspect_container(self._container_id) + state = inspection["State"] + return state["Status"], state["ExitCode"] + + def _update_status(self) -> None: + """ Check status of the container and update the Runtime's status + accordingly. Assumes the container has been started and not + removed. Uses lock for status read & write. """ + + with self._status_lock: + if self._status != RuntimeStatus.RUNNING: + return + + logger.debug("Updating runtime status...") + + try: + container_status, exit_code = self._inspect_container() + except (APIError, KeyError) as e: + self._error_occurred(e, "Error inspecting container.") + return + + if container_status in self.CONTAINER_RUNNING: + logger.debug("Container still running, no status update.") + + elif container_status in self.CONTAINER_STOPPED: + if exit_code == 0: + self._stopped() + else: + self._error_occurred( + None, f"Container stopped with exit code {exit_code}.") + + else: + self._error_occurred( + None, f"Unexpected container status: '{container_status}'.") + + def _update_status_loop(self) -> None: + """ Periodically call _update_status(). Stop when the container is no + longer running. """ + while self.status() == RuntimeStatus.RUNNING: + self._update_status() + sleep(self.STATUS_UPDATE_INTERVAL) + + logger.info("Runtime is no longer running. " + "Stopping status update thread.") + + def prepare(self) -> Deferred: + self._change_status( + from_status=RuntimeStatus.CREATED, + to_status=RuntimeStatus.PREPARING) + logger.info("Preparing runtime...") + + def _prepare(): + client = local_client() + result = client.create_container_from_config(self._container_config) + + container_id = result.get("Id") + assert isinstance(container_id, str), "Invalid container ID" + self._container_id = container_id + + for warning in result.get("Warnings") or []: + logger.warning("Container creation warning: %s", warning) + + sock = client.attach_socket( + container_id, params={'stdin': True, 'stream': True} + ) + self._stdin_socket = InputSocket(sock) + + deferred_prepare = deferToThread(_prepare) + deferred_prepare.addCallback(self._prepared) + deferred_prepare.addErrback(self._error_callback( + "Creating container failed.")) + return deferred_prepare + + def clean_up(self) -> Deferred: + self._change_status( + from_status=[RuntimeStatus.FAILURE, RuntimeStatus.STOPPED], + to_status=RuntimeStatus.CLEANING_UP) + logger.info("Cleaning up runtime...") + + def _clean_up(): + client = local_client() + client.remove_container(self._container_id) + + # Close STDIN in case it wasn't closed on stop() + def _close_stdin(res): + if self._stdin_socket is not None: + self._stdin_socket.close() + return res + + deferred_cleanup = deferToThread(_clean_up) + deferred_cleanup.addCallback(self._torn_down) + deferred_cleanup.addErrback(self._error_callback( + f"Failed to remove container '{self._container_id}'.")) + deferred_cleanup.addBoth(_close_stdin) + return deferred_cleanup + + def start(self) -> Deferred: + self._change_status( + from_status=RuntimeStatus.PREPARED, + to_status=RuntimeStatus.STARTING) + logger.info("Starting container '%s'...", self._container_id) + + def _start(): + client = local_client() + client.start(self._container_id) + + def _spawn_status_update_thread(_): + logger.debug("Spawning status update thread...") + self._status_update_thread = Thread(target=self._update_status_loop) + self._status_update_thread.start() + logger.debug("Status update thread spawned.") + + deferred_start = deferToThread(_start) + deferred_start.addCallback(self._started) + deferred_start.addCallback(_spawn_status_update_thread) + deferred_start.addErrback(self._error_callback( + f"Starting container '{self._container_id}' failed.")) + return deferred_start + + def wait_until_stopped(self) -> Deferred: + def _wait_until_stopped(): + while self.status() == RuntimeStatus.RUNNING: + sleep(1) + return deferToThread(_wait_until_stopped) + + def stop(self) -> Deferred: + with self._status_lock: + self._assert_status(self._status, RuntimeStatus.RUNNING) + logger.info("Stopping container '%s'...", self._container_id) + + def _stop(): + client = local_client() + client.stop(self._container_id) + + def _join_status_update_thread(res): + logger.debug("Joining status update thread...") + self._status_update_thread.join(self.STATUS_UPDATE_INTERVAL * 2) + if self._status_update_thread.is_alive(): + logger.warning("Failed to join status update thread.") + else: + logger.debug("Status update thread joined.") + return res + + def _close_stdin(res): + if self._stdin_socket is not None: + self._stdin_socket.close() + return res + + deferred_stop = deferToThread(_stop) + deferred_stop.addCallback(self._stopped) + deferred_stop.addErrback(self._error_callback( + f"Stopping container '{self._container_id}' failed.")) + deferred_stop.addBoth(_join_status_update_thread) + deferred_stop.addBoth(_close_stdin) + return deferred_stop + + def stdin(self, encoding: Optional[str] = None) -> RuntimeInput: + self._assert_status( + self.status(), [ + RuntimeStatus.PREPARED, + RuntimeStatus.STARTING, + RuntimeStatus.RUNNING + ]) + assert self._stdin_socket is not None + return DockerInput(sock=self._stdin_socket, encoding=encoding) + + def _get_raw_output(self, stdout=False, stderr=False, stream=True) \ + -> Iterable[bytes]: + """ Attach to the output (STDOUT or STDERR) of a container. If stream + is True the returned value is an iterator that advances when + something is printed by the container. Otherwise, it is a list + containing single `bytes` object with all output data. An empty + list is returned if error occurs. """ + + assert stdout or stderr + assert self._container_id is not None + logger.debug( + "Attaching to output of container '%s'...", self._container_id) + client = local_client() + + try: + raw_output = client.attach( + container=self._container_id, + stdout=stdout, stderr=stderr, logs=True, stream=stream) + logger.debug("Successfully attached to output.") + # If not using stream the output is a single `bytes` object + return raw_output if stream else [raw_output] + except APIError as e: + self._error_occurred( + e, "Error attaching to container's output.", set_status=False) + return [] + + def _get_output(self, encoding: Optional[str] = None, **kwargs) \ + -> RuntimeOutput: + """ Get output (STDERR or STDOUT) of this Runtime. """ + + stream_available = [ + RuntimeStatus.PREPARED, + RuntimeStatus.STARTING, + RuntimeStatus.RUNNING + ] + self._assert_status( + self.status(), stream_available + [ + RuntimeStatus.STOPPED, + RuntimeStatus.FAILURE + ]) + + raw_output: Iterable[bytes] = [] + + if self.status() in stream_available: + raw_output = self._get_raw_output(stream=True, **kwargs) + + # If container is no longer running the stream will not work (it just + # hangs forever). So we have to get all the output 'offline'. + # Status update is needed because the container may have stopped + # between checking and attaching to the output. + self._update_status() + if self.status() not in stream_available: + logger.debug("Container no longer running. Getting offline output.") + raw_output = self._get_raw_output(stream=False, **kwargs) + + return DockerOutput(raw_output, encoding=encoding) + + def stdout(self, encoding: Optional[str] = None) -> RuntimeOutput: + return self._get_output(stdout=True, encoding=encoding) + + def stderr(self, encoding: Optional[str] = None) -> RuntimeOutput: + return self._get_output(stderr=True, encoding=encoding) + + def usage_counters(self) -> Dict[CounterId, CounterUsage]: + raise NotImplementedError + + def call(self, alias: str, *args, **kwargs) -> Deferred: + raise NotImplementedError + + +class DockerCPUEnvironment(Environment): + + ENV_ID: ClassVar[EnvId] = 'docker_cpu' + ENV_DESCRIPTION: ClassVar[str] = 'Docker environment using CPU' + + MIN_MEMORY_MB: ClassVar[int] = 1024 + MIN_CPU_COUNT: ClassVar[int] = 1 + + SHARED_DIR_PATH: ClassVar[str] = '/golem/work' + + NETWORK_MODE: ClassVar[str] = 'none' + DNS_SERVERS: ClassVar[List[str]] = [] + DNS_SEARCH_DOMAINS: ClassVar[List[str]] = [] + DROPPED_KERNEL_CAPABILITIES: ClassVar[List[str]] = [ + 'audit_control', + 'audit_write', + 'mac_admin', + 'mac_override', + 'mknod', + 'net_admin', + 'net_bind_service', + 'net_raw', + 'setfcap', + 'setpcap', + 'sys_admin', + 'sys_boot', + 'sys_chroot', + 'sys_module', + 'sys_nice', + 'sys_pacct', + 'sys_resource', + 'sys_time', + 'sys_tty_config' + ] + + BENCHMARK_IMAGE = 'golemfactory/cpu_benchmark:1.0' + + @classmethod + def supported(cls) -> EnvSupportStatus: + logger.info('Checking environment support status...') + if cls._get_hypervisor_class() is None: + return EnvSupportStatus(False, "No supported hypervisor found") + logger.info('Environment supported.') + return EnvSupportStatus(True) + + @classmethod + def _get_hypervisor_class(cls) -> Optional[Type[Hypervisor]]: + if is_linux(): + return DummyHypervisor + if is_windows(): + if HyperVHypervisor.is_available(): + return HyperVHypervisor + if VirtualBoxHypervisor.is_available(): + return VirtualBoxHypervisor + if is_osx(): + if DockerForMac.is_available(): + return DockerForMac + if XhyveHypervisor.is_available(): + return XhyveHypervisor + return None + + def __init__(self, config: DockerCPUConfig) -> None: + super().__init__(logger=logger) + self._validate_config(config) + self._config = config + + hypervisor_cls = self._get_hypervisor_class() + if hypervisor_cls is None: + raise EnvironmentError("No supported hypervisor found") + self._hypervisor = hypervisor_cls.instance(self._get_hypervisor_config) + + def _get_hypervisor_config(self) -> Dict[str, int]: + return { + mem: self._config.memory_mb, + cpu: self._config.cpu_count + } + + def prepare(self) -> Deferred: + if self._status != EnvStatus.DISABLED: + raise ValueError(f"Cannot prepare because environment is in " + f"invalid state: '{self._status}'") + self._status = EnvStatus.PREPARING + logger.info("Preparing environment...") + + def _prepare(): + try: + self._hypervisor.setup() + except Exception as e: + self._error_occurred(e, "Preparing environment failed.") + raise + self._env_enabled() + + return deferToThread(_prepare) + + def clean_up(self) -> Deferred: + if self._status not in [EnvStatus.ENABLED, EnvStatus.ERROR]: + raise ValueError(f"Cannot clean up because environment is in " + f"invalid state: '{self._status}'") + self._status = EnvStatus.CLEANING_UP + logger.info("Cleaning up environment...") + + def _clean_up(): + try: + self._hypervisor.quit() + except Exception as e: + self._error_occurred(e, "Cleaning up environment failed.") + raise + self._env_disabled() + + return deferToThread(_clean_up) + + @inlineCallbacks + def run_benchmark(self) -> Deferred: + image, tag = self.BENCHMARK_IMAGE.split(':') + yield self.install_prerequisites(DockerPrerequisites( + image=image, + tag=tag, + )) + payload = DockerPayload( + image=image, + tag=tag, + user=None if is_windows() else str(os.getuid()), + env={}, + ) + runtime = self.runtime(payload) + yield runtime.prepare() + yield runtime.start() + yield runtime.wait_until_stopped() + _, exit_code = runtime._inspect_container() + try: + if exit_code: + raise Exception( + f'Benchmark run failed with exit code {exit_code}') + # Benchmark is supposed to output a single line containing + # a float value, but sometimes stdout is empty for a while after + # stopping the container + stdout = list(runtime.stdout('utf-8')) + while not stdout: + sleep(0.5) + stdout = list(runtime.stdout('utf-8')) + return float(stdout[0]) + finally: + yield runtime.clean_up() + + @classmethod + def metadata(cls) -> EnvMetadata: + return EnvMetadata( + id=cls.ENV_ID, + description=cls.ENV_DESCRIPTION, + supported_counters=[], # TODO: Specify usage counters + custom_metadata={} + ) + + @classmethod + def parse_prerequisites(cls, prerequisites_dict: Dict[str, Any]) \ + -> DockerPrerequisites: + return DockerPrerequisites(**prerequisites_dict) + + def install_prerequisites(self, prerequisites: Prerequisites) -> Deferred: + assert isinstance(prerequisites, DockerPrerequisites) + if self._status != EnvStatus.ENABLED: + raise ValueError(f"Cannot prepare prerequisites because environment" + f"is in invalid state: '{self._status}'") + logger.info("Preparing prerequisites...") + + def _prepare(): + if not Whitelist.is_whitelisted(prerequisites.image): + logger.info( + "Docker image '%s' is not whitelisted.", + prerequisites.image, + ) + return False + try: + client = local_client() + client.pull( + prerequisites.image, + tag=prerequisites.tag + ) + except Exception as e: + self._error_occurred( + e, "Preparing prerequisites failed.", set_status=False) + raise + self._prerequisites_installed(prerequisites) + return True + + return deferToThread(_prepare) + + @classmethod + def parse_config(cls, config_dict: Dict[str, Any]) -> DockerCPUConfig: + return DockerCPUConfig(**config_dict) + + def config(self) -> DockerCPUConfig: + return DockerCPUConfig(*self._config) + + def update_config(self, config: EnvConfig) -> None: + assert isinstance(config, DockerCPUConfig) + if self._status != EnvStatus.DISABLED: + raise ValueError( + "Config can be updated only when the environment is disabled") + logger.info("Updating environment configuration...") + + self._validate_config(config) + if config.work_dir != self._config.work_dir: + self._update_work_dir(config.work_dir) + self._constrain_hypervisor(config) + self._config = DockerCPUConfig(*config) + self._config_updated(config) + + @classmethod + def _validate_config(cls, config: DockerCPUConfig) -> None: + logger.info("Validating configuration...") + if not config.work_dir.is_dir(): + raise ValueError(f"Invalid working directory: '{config.work_dir}'") + if config.memory_mb < cls.MIN_MEMORY_MB: + raise ValueError(f"Not enough memory: {config.memory_mb} MB") + if config.cpu_count < cls.MIN_CPU_COUNT: + raise ValueError(f"Not enough CPUs: {config.cpu_count}") + logger.info("Configuration positively validated.") + + def _update_work_dir(self, work_dir: Path) -> None: + logger.info("Updating hypervisor's working directory...") + try: + self._hypervisor.update_work_dir(work_dir) + except Exception as e: + self._error_occurred(e, "Updating working directory failed.") + raise + logger.info("Working directory successfully updated.") + + def _constrain_hypervisor(self, config: DockerCPUConfig) -> None: + current = self._hypervisor.constraints() + target = { + mem: config.memory_mb, + cpu: config.cpu_count + } + + if target == current: + logger.info("No need to reconfigure hypervisor.") + return + + logger.info("Hypervisor configuration differs. " + "Reconfiguring hypervisor...") + try: + with self._hypervisor.reconfig_ctx(): + self._hypervisor.constrain(**target) + except Exception as e: + self._error_occurred(e, "Reconfiguring hypervisor failed.") + raise + logger.info("Hypervisor successfully reconfigured.") + + @classmethod + def parse_payload(cls, payload_dict: Dict[str, Any]) -> DockerPayload: + return DockerPayload.from_dict(payload_dict) + + def runtime( + self, + payload: Payload, + shared_dir: Optional[Path] = None, + config: Optional[EnvConfig] = None + ) -> DockerCPURuntime: + assert isinstance(payload, DockerPayload) + if not Whitelist.is_whitelisted(payload.image): + raise RuntimeError(f"Image '{payload.image}' is not whitelisted.") + + if config is not None: + assert isinstance(config, DockerCPUConfig) + else: + config = self.config() + + host_config = self._create_host_config(config, shared_dir) + volumes = [self.SHARED_DIR_PATH] if shared_dir else None + return DockerCPURuntime(payload, host_config, volumes) + + def _create_host_config( + self, config: DockerCPUConfig, shared_dir: Optional[Path]) \ + -> Dict[str, Any]: + + cpus = hardware.cpus()[:config.cpu_count] + cpuset_cpus = ','.join(map(str, cpus)) + mem_limit = f'{config.memory_mb}m' # 'm' is for megabytes + + if shared_dir is not None: + binds = self._hypervisor.create_volumes([DockerBind( + source=shared_dir, + target=self.SHARED_DIR_PATH, + mode='rw' + )]) + else: + binds = None + + client = local_client() + return client.create_host_config( + cpuset_cpus=cpuset_cpus, + mem_limit=mem_limit, + binds=binds, + privileged=False, + network_mode=self.NETWORK_MODE, + dns=self.DNS_SERVERS, + dns_search=self.DNS_SEARCH_DOMAINS, + cap_drop=self.DROPPED_KERNEL_CAPABILITIES + ) diff --git a/golem/envs/docker/non_hypervised.py b/golem/envs/docker/non_hypervised.py new file mode 100644 index 0000000000..a76ebf1543 --- /dev/null +++ b/golem/envs/docker/non_hypervised.py @@ -0,0 +1,14 @@ +from golem.docker.hypervisor.dummy import DummyHypervisor +from golem.envs.docker.cpu import DockerCPUEnvironment + + +class NonHypervisedDockerCPUEnvironment(DockerCPUEnvironment): + """ This is a temporary class that never uses a hypervisor. It just assumes + that Docker VM is properly configured if needed. The purpose of this + class is to use Docker CPU Environment alongside with DockerManager. """ + + # TODO: Remove when DockerManager is removed + + @classmethod + def _get_hypervisor_class(cls): + return DummyHypervisor diff --git a/golem/envs/docker/whitelist.py b/golem/envs/docker/whitelist.py new file mode 100644 index 0000000000..7ca31d04a5 --- /dev/null +++ b/golem/envs/docker/whitelist.py @@ -0,0 +1,34 @@ +import golem.model + + +class Whitelist: + @classmethod + def add(cls, repository: str) -> bool: + """ + Returns False if the entry was already on the whitelist. + """ + if cls.is_whitelisted(repository): + return False + golem.model.DockerWhitelist.create(repository=repository) + return True + + @classmethod + def remove(cls, repository: str) -> bool: + """ + Return False is the entry was not present on the whitelist. + """ + if not cls.is_whitelisted(repository): + return False + golem.model.DockerWhitelist.delete().where( + golem.model.DockerWhitelist.repository == repository, + ).execute() + return True + + @staticmethod + def is_whitelisted(image_name: str) -> bool: + repository = image_name.split('/')[0] + query = \ + golem.model.DockerWhitelist.select().where( + golem.model.DockerWhitelist.repository == repository, + ) + return query.exists() diff --git a/golem/envs/manager.py b/golem/envs/manager.py new file mode 100644 index 0000000000..85ab305362 --- /dev/null +++ b/golem/envs/manager.py @@ -0,0 +1,46 @@ +from typing import Dict, List + +from golem.envs import EnvId, Environment + + +class EnvironmentManager: + """ Manager class for all Environments. """ + + def __init__(self): + self._envs: Dict[EnvId, Environment] = {} + self._state: Dict[EnvId, bool] = {} + + def register_env(self, env: Environment) -> None: + """ Register an Environment (i.e. make it visible to manager). """ + env_id = env.metadata().id + if env_id not in self._envs: + self._envs[env_id] = env + self._state[env_id] = False + + def state(self) -> Dict[EnvId, bool]: + """ Get the state (enabled or not) for all registered Environments. """ + return dict(self._state) + + def set_state(self, state: Dict[EnvId, bool]) -> None: + """ Set the state (enabled or not) for all registered Environments. """ + for env_id, enabled in state.items(): + self.set_enabled(env_id, enabled) + + def enabled(self, env_id: EnvId) -> bool: + """ Get the state (enabled or not) for an Environment. """ + return self._state[env_id] + + def set_enabled(self, env_id: EnvId, enabled: bool) -> None: + """ Set the state (enabled or not) for an Environment. This does *not* + include actually activating or deactivating the Environment. """ + if env_id in self._state: + self._state[env_id] = enabled + + def environments(self) -> List[Environment]: + """ Get all registered Environments. """ + return list(self._envs.values()) + + def environment(self, env_id: EnvId) -> Environment: + """ Get Environment with the given ID. Assumes such Environment is + registered. """ + return self._envs[env_id] diff --git a/golem/ethereum/incomeskeeper.py b/golem/ethereum/incomeskeeper.py index eda772e6c5..9c58b1a15e 100644 --- a/golem/ethereum/incomeskeeper.py +++ b/golem/ethereum/incomeskeeper.py @@ -1,13 +1,12 @@ # -*- coding: utf-8 -*- -from datetime import datetime, timedelta import logging import time from ethereum.utils import denoms from pydispatch import dispatcher +from golem import model from golem.core.variables import PAYMENT_DEADLINE -from golem.model import Income logger = logging.getLogger(__name__) @@ -16,20 +15,22 @@ class IncomesKeeper: """Keeps information about payments received from other nodes """ + @staticmethod def received_batch_transfer( - self, tx_hash: str, sender: str, amount: int, closure_time: int) -> None: - expected = Income.select().where( - Income.payer_address == sender, - Income.accepted_ts > 0, - Income.accepted_ts <= closure_time, - Income.transaction.is_null(), - Income.settled_ts.is_null()) - - expected_value = sum([e.value_expected for e in expected]) + + expected = model.TaskPayment.incomes().where( + model.WalletOperation.sender_address == sender, + model.TaskPayment.accepted_ts > 0, + model.TaskPayment.accepted_ts <= closure_time, + model.WalletOperation.tx_hash.is_null(), + model.TaskPayment.settled_ts.is_null(), + ) + + expected_value = sum([e.missing_amount for e in expected]) if expected_value == 0: # Probably already handled event return @@ -43,18 +44,18 @@ def received_batch_transfer( amount_left = amount for e in expected: - received = min(amount_left, e.value_expected) - e.value_received += received + received = min(amount_left, e.expected_amount) + e.wallet_operation.amount += received amount_left -= received - e.transaction = tx_hash[2:] - e.save() + e.wallet_operation.tx_hash = tx_hash + e.wallet_operation.save() - if e.value_expected == 0: + if e.missing_amount == 0: dispatcher.send( signal='golem.income', event='confirmed', - node_id=e.sender_node, - amount=e.value_received, + node_id=e.wallet_operation.sender_address, + amount=e.wallet_operation.amount, ) def received_forced_payment( @@ -75,12 +76,14 @@ def received_forced_payment( ) @staticmethod - def expect( + def expect( # pylint: disable=too-many-arguments sender_node: str, + task_id: str, subtask_id: str, payer_address: str, + my_address: str, value: int, - accepted_ts: int) -> Income: + accepted_ts: int) -> model.TaskPayment: logger.info( "Expected income - sender_node: %s, subtask: %s, " "payer: %s, value: %f", @@ -89,25 +92,30 @@ def expect( payer_address, value / denoms.ether, ) - income, inserted = Income.get_or_create( - sender_node=sender_node, + income = model.TaskPayment.create( + wallet_operation=model.WalletOperation.create( + direction=model.WalletOperation.DIRECTION.incoming, + operation_type=model.WalletOperation.TYPE.task_payment, + status=model.WalletOperation.STATUS.awaiting, + sender_address=payer_address, + recipient_address=my_address, + amount=0, + currency=model.WalletOperation.CURRENCY.GNT, + gas_cost=0, + ), + node=sender_node, + task=task_id, subtask=subtask_id, - defaults={ - 'payer_address': payer_address, - 'value': value, - 'accepted_ts': accepted_ts, - }, + expected_amount=value, + accepted_ts=accepted_ts, ) - if inserted: - dispatcher.send( - signal='golem.income', - event='created', - subtask_id=subtask_id, - amount=value - ) - elif not income.accepted_ts: - income.accepted_ts = accepted_ts - income.save() + dispatcher.send( + signal='golem.income', + event='created', + subtask_id=subtask_id, + amount=value + ) + return income @staticmethod @@ -116,10 +124,10 @@ def settled( subtask_id: str, settled_ts: int) -> None: try: - income = Income.get(sender_node=sender_node, subtask=subtask_id) - except Income.DoesNotExist: + income = model.TaskPayment.get(node=sender_node, subtask=subtask_id) + except model.TaskPayment.DoesNotExist: logger.error( - "Income.DoesNotExist subtask_id: %r", subtask_id) + "TaskPayment.DoesNotExist subtask_id: %r", subtask_id) return income.settled_ts = settled_ts @@ -131,23 +139,29 @@ def received_forced_subtask_payment( sender_addr: str, subtask_id: str, value: int) -> None: - Income.create( - sender_node="", + model.TaskPayment.create( + wallet_operation=model.WalletOperation.create( + tx_hash=tx_hash, + direction=model.WalletOperation.DIRECTION.incoming, + operation_type=model.WalletOperation.TYPE.deposit_payment, + status=model.WalletOperation.STATUS.confirmed, + sender_address=sender_addr, + recipient_address="", + amount=value, + currency=model.WalletOperation.CURRENCY.GNT, + gas_cost=0, + ), + node="", + task="", subtask=subtask_id, - payer_address=sender_addr, - value=value, - transaction=tx_hash[2:], + expected_amount=value, ) - def get_list_of_all_incomes(self): + @staticmethod + def get_list_of_all_incomes(): # TODO: pagination. issue #2402 - return Income.select( - Income.created_date, - Income.sender_node, - Income.subtask, - Income.transaction, - Income.value - ).order_by(Income.created_date.desc()) + return model.TaskPayment.incomes( + ).order_by(model.TaskPayment.created_date.desc()) @staticmethod def update_overdue_incomes() -> None: @@ -157,22 +171,24 @@ def update_overdue_incomes() -> None: """ accepted_ts_deadline = int(time.time()) - PAYMENT_DEADLINE - incomes = list(Income.select().where( - Income.overdue == False, # noqa pylint: disable=singleton-comparison - Income.transaction.is_null(True), - Income.accepted_ts < accepted_ts_deadline, + incomes = list(model.TaskPayment.incomes().where( + model.WalletOperation.status != + model.WalletOperation.STATUS.overdue, + model.WalletOperation.tx_hash.is_null(True), + model.TaskPayment.accepted_ts < accepted_ts_deadline, )) if not incomes: return for income in incomes: - income.overdue = True - income.save() + income.wallet_operation.status = \ + model.WalletOperation.STATUS.overdue + income.wallet_operation.save() dispatcher.send( signal='golem.income', event='overdue_single', - node_id=income.sender_node, + node_id=income.node, ) dispatcher.send( diff --git a/golem/ethereum/paymentprocessor.py b/golem/ethereum/paymentprocessor.py index 10bf5d100f..afa587a451 100644 --- a/golem/ethereum/paymentprocessor.py +++ b/golem/ethereum/paymentprocessor.py @@ -1,21 +1,21 @@ -import calendar import datetime import logging -import time from collections import defaultdict -from typing import List +from typing import ( + List, +) +from ethereum.utils import denoms from pydispatch import dispatcher from sortedcontainers import SortedListWithKey -from eth_utils import decode_hex, encode_hex -from ethereum.utils import denoms from twisted.internet import threads import golem_sci +from golem import model from golem.core.variables import PAYMENT_DEADLINE -from golem.model import Payment, PaymentStatus +PAYMENT_DEADLINE_TD = datetime.timedelta(seconds=PAYMENT_DEADLINE) log = logging.getLogger(__name__) @@ -23,18 +23,16 @@ PAYMENT_MAX_DELAY = PAYMENT_DEADLINE - 30 * 60 -def get_timestamp() -> int: - """This is platform independent timestamp, needed for payments logic""" - return calendar.timegm(time.gmtime()) - - -def _make_batch_payments(payments: List[Payment]) -> List[golem_sci.Payment]: +def _make_batch_payments( + payments: List[model.TaskPayment] +) -> List[golem_sci.Payment]: payees: defaultdict = defaultdict(lambda: 0) for p in payments: - payees[p.payee] += p.value + payees[p.wallet_operation.recipient_address] += \ + p.wallet_operation.amount res = [] for payee, amount in payees.items(): - res.append(golem_sci.Payment(encode_hex(payee), amount)) + res.append(golem_sci.Payment(payee, amount)) return res @@ -46,9 +44,11 @@ class PaymentProcessor: def __init__(self, sci) -> None: self._sci = sci self._gntb_reserved = 0 - self._awaiting = SortedListWithKey(key=lambda p: p.processed_ts) + self._awaiting = SortedListWithKey(key=lambda p: p.created_date) self.load_from_db() - self.last_print_time = 0 + self.last_print_time = datetime.datetime.min.replace( + tzinfo=datetime.timezone.utc, + ) @property def recipients_count(self) -> int: @@ -60,14 +60,16 @@ def reserved_gntb(self) -> int: def load_from_db(self): sent = {} - for sent_payment in Payment \ - .select() \ - .where(Payment.status == PaymentStatus.sent): - tx_hash = '0x' + sent_payment.details.tx - if tx_hash not in sent: - sent[tx_hash] = [] - sent[tx_hash].append(sent_payment) - self._gntb_reserved += sent_payment.value + for sent_payment in model.TaskPayment \ + .payments() \ + .where( + model.WalletOperation.status == \ + model.WalletOperation.STATUS.sent, + ): + if sent_payment.wallet_operation.tx_hash not in sent: + sent[sent_payment.wallet_operation.tx_hash] = [] + sent[sent_payment.wallet_operation.tx_hash].append(sent_payment) + self._gntb_reserved += sent_payment.wallet_operation.amount for tx_hash, payments in sent.items(): self._sci.on_transaction_confirmed( tx_hash, @@ -75,24 +77,34 @@ def load_from_db(self): self._on_batch_confirmed, p, r), ) - for awaiting_payment in Payment \ - .select() \ - .where(Payment.status == PaymentStatus.awaiting): + for awaiting_payment in model.TaskPayment \ + .payments() \ + .where( + model.WalletOperation.status.in_([ + model.WalletOperation.STATUS.awaiting, + model.WalletOperation.STATUS.overdue, + ]), + ): log.info( - "Restoring awaiting payment for subtask %s to %s of %.3f GNTB", + "Restoring awaiting payment for subtask %s to %s of %.6f GNTB", awaiting_payment.subtask, - encode_hex(awaiting_payment.payee), - awaiting_payment.value / denoms.ether, + awaiting_payment.wallet_operation.recipient_address, + awaiting_payment.wallet_operation.amount / denoms.ether, ) self._awaiting.add(awaiting_payment) - self._gntb_reserved += awaiting_payment.value + self._gntb_reserved += awaiting_payment.wallet_operation.amount - def _on_batch_confirmed(self, payments: List[Payment], receipt) -> None: + def _on_batch_confirmed( + self, + payments: List[model.TaskPayment], + receipt + ) -> None: if not receipt.status: log.critical("Failed batch transfer: %s", receipt) for p in payments: - p.status = PaymentStatus.awaiting # type: ignore - p.save() + wallet_operation = p.wallet_operation + wallet_operation.status = model.WalletOperation.STATUS.awaiting + wallet_operation.save() self._awaiting.add(p) return @@ -106,78 +118,99 @@ def _on_batch_confirmed(self, payments: List[Payment], receipt) -> None: fee / denoms.ether, ) for p in payments: - p.status = PaymentStatus.confirmed # type: ignore - p.details.block_number = receipt.block_number - p.details.block_hash = receipt.block_hash[2:] - p.details.fee = fee - p.save() - self._gntb_reserved -= p.value + wallet_operation = p.wallet_operation + wallet_operation.status = model.WalletOperation.STATUS.confirmed + wallet_operation.gas_cost = fee + wallet_operation.save() + self._gntb_reserved -= p.wallet_operation.amount self._payment_confirmed(p, block.timestamp) @staticmethod - def _payment_confirmed(payment: Payment, timestamp: int) -> None: + def _payment_confirmed(payment: model.TaskPayment, timestamp: int) -> None: log.debug( "- %s confirmed fee: %.18f ETH", payment.subtask, - payment.details.fee / denoms.ether + payment.wallet_operation.gas_cost / denoms.ether ) - reference_date = datetime.datetime.fromtimestamp(timestamp) + reference_date = datetime.datetime.fromtimestamp( + timestamp, + tz=datetime.timezone.utc, + ) delay = (reference_date - payment.created_date).seconds dispatcher.send( signal="golem.payment", event="confirmed", subtask_id=payment.subtask, - payee=payment.payee, + payee=payment.wallet_operation.recipient_address, delay=delay, ) - def add(self, subtask_id: str, eth_addr: str, value: int) -> Payment: + def add( # pylint: disable=too-many-arguments + self, + node_id: str, + task_id: str, + subtask_id: str, + eth_addr: str, + value: int, + ) -> model.TaskPayment: log.info( "Adding payment for %s to %s (%.3f GNTB)", subtask_id, eth_addr, value / denoms.ether, ) - payment = Payment.create( + payment = model.TaskPayment.create( + wallet_operation=model.WalletOperation.create( + direction=model.WalletOperation.DIRECTION.outgoing, + operation_type=model.WalletOperation.TYPE.task_payment, + sender_address=self._sci.get_eth_address(), + recipient_address=eth_addr, + currency=model.WalletOperation.CURRENCY.GNT, + amount=value, + status=model.WalletOperation.STATUS.awaiting, + gas_cost=0, + ), + node=node_id, + task=task_id, subtask=subtask_id, - payee=decode_hex(eth_addr), - value=value, - processed_ts=get_timestamp(), + expected_amount=value, ) + self._awaiting.add(payment) self._gntb_reserved += value log.info("Reserved %.3f GNTB", self._gntb_reserved / denoms.ether) - return payment.processed_ts + return payment - def __get_next_batch(self, closure_time: int) -> int: + def __get_next_batch(self, closure_time: datetime.datetime) -> int: gntb_balance = self._sci.get_gntb_balance(self._sci.get_eth_address()) eth_balance = self._sci.get_eth_balance(self._sci.get_eth_address()) gas_price = self._sci.get_current_gas_price() ind = 0 - gas_limit = \ - self._sci.get_latest_block().gas_limit * self.BLOCK_GAS_LIMIT_RATIO + gas_limit = self._sci.get_latest_confirmed_block().gas_limit * \ + self.BLOCK_GAS_LIMIT_RATIO payees = set() + p: model.TaskPayment for p in self._awaiting: - if p.processed_ts > closure_time: + if p.created_date > closure_time: break - gntb_balance -= p.value + gntb_balance -= p.wallet_operation.amount if gntb_balance < 0: log.debug( 'Insufficient GNTB balance.' ' value=%(value).18f, subtask_id=%(subtask)s', { - 'value': p.value / denoms.ether, + 'value': p.wallet_operation.amount / denoms.ether, 'subtask': p.subtask, }, ) break - payees.add(p.payee) + payees.add(p.wallet_operation.recipient_address) gas = len(payees) * self._sci.GAS_PER_PAYMENT + \ self._sci.GAS_BATCH_PAYMENT_BASE if gas > gas_limit: @@ -196,10 +229,10 @@ def __get_next_batch(self, closure_time: int) -> int: ind += 1 - # we need to take either all payments with given processed_ts or none + # we need to take either all payments with given created_date or none if ind < len(self._awaiting): while ind > 0 and self._awaiting[ind - 1]\ - .processed_ts == self._awaiting[ind].processed_ts: + .created_date == self._awaiting[ind].created_date: ind -= 1 return ind @@ -208,24 +241,30 @@ def sendout(self, acceptable_delay: int = PAYMENT_MAX_DELAY): if not self._awaiting: return False - now = get_timestamp() - deadline = self._awaiting[0].processed_ts + acceptable_delay + now = datetime.datetime.now(tz=datetime.timezone.utc) + deadline = self._awaiting[0].created_date +\ + datetime.timedelta(seconds=acceptable_delay) if deadline > now: - if now > self.last_print_time + 300: - log.info("Next sendout at %s", - datetime.datetime.fromtimestamp(deadline)) + if now > self.last_print_time + datetime.timedelta(minutes=5): + log.info("Next sendout at %s", deadline) self.last_print_time = now return False - payments_count = self.__get_next_batch(now - self.CLOSURE_TIME_DELAY) + payments_count = self.__get_next_batch( + now - datetime.timedelta(seconds=self.CLOSURE_TIME_DELAY), + ) if payments_count == 0: return False payments = self._awaiting[:payments_count] - value = sum([p.value for p in payments]) + value = sum([p.wallet_operation.amount for p in payments]) log.info("Batch payments value: %.3f GNTB", value / denoms.ether) - closure_time = payments[-1].processed_ts + closure_time = int( + payments[-1].created_date.replace( + tzinfo=datetime.timezone.utc, + ).timestamp() + ) tx_hash = self._sci.batch_transfer( _make_batch_payments(payments), closure_time, @@ -233,13 +272,14 @@ def sendout(self, acceptable_delay: int = PAYMENT_MAX_DELAY): del self._awaiting[:payments_count] for payment in payments: - payment.status = PaymentStatus.sent - payment.details.tx = tx_hash[2:] - payment.save() + wallet_operation = payment.wallet_operation + wallet_operation.status = model.WalletOperation.STATUS.sent + wallet_operation.tx_hash = tx_hash + wallet_operation.save() log.debug("- {} send to {} ({:.18f} GNTB)".format( payment.subtask, - encode_hex(payment.payee), - payment.value / denoms.ether)) + wallet_operation.recipient_address, + wallet_operation.amount / denoms.ether)) self._sci.on_transaction_confirmed( tx_hash, @@ -248,3 +288,25 @@ def sendout(self, acceptable_delay: int = PAYMENT_MAX_DELAY): ) return True + + def update_overdue(self) -> None: + """Sets overdue status for awaiting payments""" + + created_deadline = datetime.datetime.now( + tz=datetime.timezone.utc + ) - PAYMENT_DEADLINE_TD + counter = 0 + for payment in self._awaiting: + if payment.created_date >= created_deadline: + # All subsequent payments won't be overdue + # because list is sorted. + break + wallet_operation = payment.wallet_operation + if wallet_operation.status is model.WalletOperation.STATUS.overdue: + continue + wallet_operation.status = model.WalletOperation.STATUS.overdue + wallet_operation.save() + log.debug("Marked as overdue. payment=%r", payment) + counter += 1 + if counter: + log.info("Marked %d payments as overdue.", counter) diff --git a/golem/ethereum/paymentskeeper.py b/golem/ethereum/paymentskeeper.py index 77eef11446..50a145fa1e 100644 --- a/golem/ethereum/paymentskeeper.py +++ b/golem/ethereum/paymentskeeper.py @@ -1,11 +1,9 @@ +import datetime import logging -from datetime import datetime, timedelta from typing import Iterable, List, Optional -from eth_utils import encode_hex - +from golem import model from golem.core.common import to_unicode, datetime_to_timestamp_utc -from golem.model import Payment logger = logging.getLogger(__name__) @@ -23,68 +21,41 @@ def get_payment_value(subtask_id: str): @staticmethod def get_payment_for_subtask(subtask_id): try: - return Payment.get(Payment.subtask == subtask_id).value - except Payment.DoesNotExist: + return model.TaskPayment.get( + model.TaskPayment.subtask == subtask_id, + ).wallet_operation.amount + except model.TaskPayment.DoesNotExist: logger.debug("Can't get payment value - payment does not exist") return 0 @staticmethod - def get_subtasks_payments(subtask_ids: Iterable[str]) -> List[Payment]: - return list(Payment.select( - Payment.subtask, - Payment.value, - Payment.details, - Payment.status, - ).where( - Payment.subtask.in_(subtask_ids), - )) - - @staticmethod - def add_payment(subtask_id: str, eth_address: bytes, value: int): - """ Add new payment to the database. - :param payment_info: - """ - Payment.create(subtask=subtask_id, - payee=eth_address, - value=value) - - def change_state(self, subtask_id, state): - """ Change state for all payments for task_id - :param str subtask_id: change state of all payments that should be done for computing this task - :param state: new state - :return: - """ - # FIXME: Remove this method #2457 - query = Payment.update(status=state, modified_date=str(datetime.now())) - query = query.where(Payment.subtask == subtask_id) - query.execute() - - def get_state(self, payment_info): - """ Return state of a payment for given task that should be / was made to given node - :return str|None: return state of payment or none if such payment don't exist in database - """ - # FIXME: Remove this method #2457 - try: - return Payment.get(Payment.subtask == payment_info.subtask_id).status - except Payment.DoesNotExist: - logger.warning("Payment for subtask {} to node {} does not exist" - .format(payment_info.subtask_id, payment_info.computer.key_id)) - return None + def get_subtasks_payments( + subtask_ids: Iterable[str], + ) -> List[model.TaskPayment]: + return list( + model.TaskPayment.payments().where( + model.TaskPayment.subtask.in_(subtask_ids), + ) + ) @staticmethod def get_newest_payment(num: Optional[int] = None, - interval: Optional[timedelta] = None): + interval: Optional[datetime.timedelta] = None): """ Return specific number of recently modified payments :param num: Number of payments to return. Unlimited if None. :param interval: Return payments from last interval of time. Unlimited if None. :return: """ - query = Payment.select().order_by(Payment.modified_date.desc()) + query = model.TaskPayment.payments().order_by( + model.WalletOperation.modified_date.desc(), + ) if interval is not None: - then = datetime.now() - interval - query = query.where(Payment.modified_date >= then) + then = datetime.datetime.now(tz=datetime.timezone.utc) - interval + query = query.where( + model.WalletOperation.modified_date >= then, + ) if num is not None: query = query.limit(num) @@ -100,30 +71,23 @@ def __init__(self) -> None: self.db = PaymentsDatabase() def get_list_of_all_payments(self, num: Optional[int] = None, - interval: Optional[timedelta] = None): + interval: Optional[datetime.timedelta] = None): # This data is used by UI. return [{ "subtask": to_unicode(payment.subtask), - "payee": to_unicode(encode_hex(payment.payee)), - "value": to_unicode(payment.value), - "status": to_unicode(payment.status.name), - "fee": to_unicode(payment.details.fee), - "block_number": to_unicode(payment.details.block_number), - "transaction": to_unicode(payment.details.tx), + "payee": to_unicode(payment.wallet_operation.recipient_address), + "value": to_unicode(payment.wallet_operation.amount), + "status": to_unicode(payment.wallet_operation.status.name), + "fee": to_unicode(payment.wallet_operation.gas_cost), + "block_number": '', + "transaction": to_unicode(payment.wallet_operation.tx_hash), + "node": payment.node, "created": datetime_to_timestamp_utc(payment.created_date), - "modified": datetime_to_timestamp_utc(payment.modified_date) + "modified": datetime_to_timestamp_utc( + payment.wallet_operation.modified_date, + ) } for payment in self.db.get_newest_payment(num, interval)] - def finished_subtasks( - self, - subtask_id: str, - eth_address: bytes, - value: int): - """ Add new information about finished subtask - :param PaymentInfo payment_info: full information about payment for given subtask - """ - self.db.add_payment(subtask_id, eth_address, value) - def get_payment(self, subtask_id): """ Get cost of subtasks defined by @subtask_id @@ -134,5 +98,5 @@ def get_payment(self, subtask_id): def get_subtasks_payments( self, - subtask_ids: Iterable[str]) -> List[Payment]: + subtask_ids: Iterable[str]) -> List[model.TaskPayment]: return self.db.get_subtasks_payments(subtask_ids) diff --git a/golem/ethereum/transactionsystem.py b/golem/ethereum/transactionsystem.py index a2a97a0de0..ea6cbafde2 100644 --- a/golem/ethereum/transactionsystem.py +++ b/golem/ethereum/transactionsystem.py @@ -1,3 +1,4 @@ +import datetime import functools import json import logging @@ -5,7 +6,6 @@ import random import time from enum import Enum -from datetime import datetime, timedelta from pathlib import Path from typing import ( Any, @@ -38,7 +38,6 @@ from golem.ethereum.paymentprocessor import PaymentProcessor from golem.ethereum.incomeskeeper import IncomesKeeper from golem.ethereum.paymentskeeper import PaymentsKeeper -from golem.rpc import utils as rpc_utils from golem.utils import privkeytoaddr from . import exceptions @@ -79,6 +78,11 @@ class ConversionStatus(Enum): UNFINISHED = 3 +class FaucetRequests(Enum): + ETH = 0 + GNT = 1 + + # pylint:disable=too-many-instance-attributes,too-many-public-methods class TransactionSystem(LoopingCallService): """ Transaction system connected with Ethereum """ @@ -108,7 +112,7 @@ def __init__(self, datadir: Path, config) -> None: self._incomes_keeper = IncomesKeeper() self._payment_processor: Optional[PaymentProcessor] = None - self._gnt_faucet_requested = False + self._faucet_requested: Optional[FaucetRequests] = None self._gnt_conversion_status: Tuple[ConversionStatus, Optional[str]] = \ (ConversionStatus.NONE, None) self._concent_withdraw_requested = False @@ -136,9 +140,17 @@ def gas_price_limit(self) -> int: self._sci: SmartContractsInterface return self._sci.GAS_PRICE + @property + def contract_addresses(self): + return self._config.CONTRACT_ADDRESSES + @property def deposit_contract_available(self) -> bool: - return contracts.GNTDeposit in self._config.CONTRACT_ADDRESSES + return contracts.GNTDeposit in self.contract_addresses + + @property + def deposit_contract_address(self) -> Optional[str]: + return self.contract_addresses.get(contracts.GNTDeposit, None) def backwards_compatibility_tx_storage(self, old_datadir: Path) -> None: if self.running: @@ -303,11 +315,11 @@ def _subscribe_to_events(self) -> None: @sci_required() def _save_subscription_block_number(self) -> None: self._sci: SmartContractsInterface - block_number = self._sci.get_block_number() - self._sci.REQUIRED_CONFS + block_number = self._sci.get_latest_confirmed_block_number() kv, _ = model.GenericKeyValue.get_or_create( key=self.BLOCK_NUMBER_DB_KEY, ) - kv.value = block_number - 1 + kv.value = block_number + 1 kv.save() def stop(self): @@ -316,32 +328,55 @@ def stop(self): self._sci.stop() super().stop() - def add_payment_info( + def add_payment_info( # pylint: disable=too-many-arguments self, + node_id: str, + task_id: str, subtask_id: str, value: int, - eth_address: str) -> int: + eth_address: str) -> model.TaskPayment: if not self._payment_processor: raise Exception('Start was not called') - return self._payment_processor.add(subtask_id, eth_address, value) + return self._payment_processor.add( + node_id=node_id, + task_id=task_id, + subtask_id=subtask_id, + eth_addr=eth_address, + value=value, + ) @sci_required() - def get_payment_address(self): + def get_payment_address(self) -> str: """ Human readable Ethereum address for incoming payments.""" self._sci: SmartContractsInterface return self._sci.get_eth_address() - def get_payments_list(self, num: Optional[int] = None, - interval: Optional[timedelta] = None): - """ Return list of all planned and made payments - :return list: list of dictionaries describing payments - """ + def get_payments_list( + self, + num: Optional[int] = None, + interval: Optional[datetime.timedelta] = None, + ) -> List[Dict[str, Any]]: + # + # @todo https://github.com/golemfactory/golem/issues/3971 + # @todo https://github.com/golemfactory/golem/issues/3970 + + # because of crossbar's 1MB limitation on output, we need to limit + # the amount of data returned from the endpoint here + # + # the real answer is pagination... until then, we're imposing + # an artificial limit + + num = num or 1024 return self._payments_keeper.get_list_of_all_payments(num, interval) @classmethod - def get_deposit_payments_list(cls, limit: int = 1000, offset: int = 0) \ - -> List[model.DepositPayment]: - query = model.DepositPayment.select() \ + def get_deposit_payments_list(cls, limit=1000, offset=0)\ + -> List[model.WalletOperation]: + query = model.WalletOperation.deposit_transfers() \ + .where( + model.WalletOperation.direction + == model.WalletOperation.DIRECTION.outgoing, + ) \ .order_by('id') \ .limit(limit) \ .offset(offset) @@ -349,13 +384,10 @@ def get_deposit_payments_list(cls, limit: int = 1000, offset: int = 0) \ def get_subtasks_payments( self, - subtask_ids: Iterable[str]) -> List[model.Payment]: + subtask_ids: Iterable[str]) -> List[model.TaskPayment]: return self._payments_keeper.get_subtasks_payments(subtask_ids) def get_incomes_list(self): - """ Return list of all expected and received incomes - :return list: list of dictionaries describing incomes - """ return self._incomes_keeper.get_list_of_all_incomes() def get_available_eth(self) -> int: @@ -393,7 +425,7 @@ def get_balance(self) -> Dict[str, Any]: 'gnt_nonconverted': self._gnt_balance, 'eth_available': self.get_available_eth(), 'eth_locked': self.get_locked_eth(), - 'block_number': self._sci.get_block_number(), + 'block_number': self._sci.get_latest_confirmed_block_number(), 'gnt_update_time': self._last_gnt_update, 'eth_update_time': self._last_eth_update, } @@ -457,19 +489,23 @@ def unlock_funds_for_payments(self, price: int, num: int) -> None: self._payments_locked -= num # pylint: disable=too-many-arguments + @sci_required() def expect_income( self, sender_node: str, + task_id: str, subtask_id: str, payer_address: str, value: int, accepted_ts: int) -> None: self._incomes_keeper.expect( - sender_node, - subtask_id, - payer_address, - value, - accepted_ts, + sender_node=sender_node, + task_id=task_id, + subtask_id=subtask_id, + payer_address=payer_address, + my_address=self._sci.get_eth_address(), # type: ignore + value=value, + accepted_ts=accepted_ts, ) def settle_income( @@ -486,7 +522,7 @@ def eth_for_batch_payment(self, num_payments: int) -> int: self._payment_processor.recipients_count required = self._current_eth_per_payment() * num_payments + \ self._eth_base_for_batch_payment() - return required - self.get_locked_eth() + return max(0, required - self.get_locked_eth()) @sci_required() def _eth_base_for_batch_payment(self) -> int: @@ -553,6 +589,7 @@ def withdraw( available=self.get_available_eth(), currency=currency, ) + # TODO Create WalletOperation #4172 return self._sci.transfer_eth( destination, amount - gas_eth, @@ -566,6 +603,7 @@ def withdraw( available=self.get_available_gnt(), currency=currency, ) + # TODO Create WalletOperation #4172 tx_hash = self._sci.convert_gntb_to_gnt( destination, amount, @@ -576,6 +614,7 @@ def on_receipt(receipt) -> None: self._gntb_withdrawn -= amount if not receipt.status: log.error("Failed GNTB withdrawal: %r", receipt) + # TODO Update WalletOperation #4172 self._sci.on_transaction_confirmed(tx_hash, on_receipt) self._gntb_withdrawn += amount return tx_hash @@ -669,10 +708,16 @@ def concent_deposit( max_possible_amount / denoms.ether, tx_hash, ) - dpayment = model.DepositPayment.create( - status=model.PaymentStatus.sent, - value=max_possible_amount, - tx=tx_hash, + dpayment = model.WalletOperation.create( + tx_hash=tx_hash, + direction=model.WalletOperation.DIRECTION.outgoing, + operation_type=model.WalletOperation.TYPE.deposit_transfer, + status=model.WalletOperation.STATUS.sent, + sender_address=self.get_payment_address() or '', + recipient_address=self.deposit_contract_address, + amount=max_possible_amount, + currency=model.WalletOperation.CURRENCY.GNT, + gas_cost=0, ) log.debug('DEPOSIT PAYMENT %s', dpayment) @@ -693,20 +738,19 @@ def concent_deposit( tx_gas_price = self._sci.get_transaction_gas_price( receipt.tx_hash, ) - dpayment.fee = receipt.gas_used * tx_gas_price - dpayment.status = model.PaymentStatus.confirmed + dpayment.gas_cost = receipt.gas_used * tx_gas_price + dpayment.status = \ + model.WalletOperation.STATUS.confirmed dpayment.save() - return dpayment.tx + return dpayment.tx_hash - @rpc_utils.expose('pay.deposit.relock') @gnt_deposit_required() @sci_required() - def concent_relock(self): + def concent_relock(self) -> None: if self.concent_balance() == 0: return self._sci.lock_deposit() - @rpc_utils.expose('pay.deposit.unlock') @gnt_deposit_required() @sci_required() def concent_unlock(self): @@ -752,22 +796,26 @@ def _get_funds_from_faucet(self) -> None: if not self._config.FAUCET_ENABLED: return if self._eth_balance < 0.005 * denoms.ether: - log.info("Requesting tETH from faucet") - tETH_faucet_donate(self._sci.get_eth_address()) + if self._faucet_requested != FaucetRequests.ETH: + log.info("Requesting tETH from faucet") + if tETH_faucet_donate(self._sci.get_eth_address()): + self._faucet_requested = FaucetRequests.ETH return + if self._faucet_requested == FaucetRequests.ETH: + self._faucet_requested = None if self._gnt_balance + self._gntb_balance < 100 * denoms.ether: - if not self._gnt_faucet_requested: + if self._faucet_requested != FaucetRequests.GNT: log.info("Requesting GNT from faucet") self._sci.request_gnt_from_faucet() - self._gnt_faucet_requested = True - else: - self._gnt_faucet_requested = False + self._faucet_requested = FaucetRequests.GNT + return + self._faucet_requested = None @sci_required() def _refresh_balances(self) -> None: self._sci: SmartContractsInterface - now = time.mktime(datetime.today().timetuple()) + now = time.mktime(datetime.datetime.today().timetuple()) addr = self._sci.get_eth_address() # Sometimes web3 may throw but it's fine here, we'll just update the @@ -875,4 +923,5 @@ def _run(self) -> None: self._get_funds_from_faucet() self._try_convert_gnt() self._payment_processor.sendout() + self._payment_processor.update_overdue() self._incomes_keeper.update_overdue_incomes() diff --git a/golem/interface/cli.py b/golem/interface/cli.py index 7c5437eeaf..95334aa367 100644 --- a/golem/interface/cli.py +++ b/golem/interface/cli.py @@ -50,7 +50,7 @@ def disable_withdraw( """ new_children = deepcopy(children) from golem.config.active import EthereumConfig - if not EthereumConfig.WITHDRAWALS_ENABLED: + if not EthereumConfig().WITHDRAWALS_ENABLED: if 'withdraw' in new_children: new_children.pop('withdraw') return new_children diff --git a/golem/interface/client/account.py b/golem/interface/client/account.py index c322add524..2a914a2742 100644 --- a/golem/interface/client/account.py +++ b/golem/interface/client/account.py @@ -1,7 +1,11 @@ import datetime import getpass import sys -from typing import Dict, Any +from typing import ( + Any, + Dict, + TYPE_CHECKING, +) from decimal import Decimal from ethereum.utils import denoms @@ -11,13 +15,17 @@ from golem.core.deferred import sync_wait from golem.interface.command import Argument, command, group +if TYPE_CHECKING: + # pylint: disable=unused-import + from golem.rpc.session import ClientProxy + MIN_LENGTH = 5 MIN_SCORE = 2 @group(help="Manage account") class Account: - client = None + client: 'ClientProxy' amount_arg = Argument('amount', help='Amount to withdraw, eg 1.45') address_arg = Argument('destination', help='Address to send the funds to') @@ -37,7 +45,8 @@ def info(self) -> Dict[str, Any]: # pylint: disable=no-self-use computing_trust = sync_wait(client.get_computing_trust(node_key)) requesting_trust = sync_wait(client.get_requesting_trust(node_key)) - payment_address = sync_wait(client.get_payment_address()) + # pylint: disable=protected-access + payment_address = sync_wait(client._call('pay.ident')) balance = sync_wait(client.get_balance()) diff --git a/golem/interface/client/payments.py b/golem/interface/client/payments.py index 1439e77d51..c6dd040210 100644 --- a/golem/interface/client/payments.py +++ b/golem/interface/client/payments.py @@ -1,3 +1,4 @@ +# pylint: disable=protected-access from ethereum.utils import denoms from golem.core.common import to_unicode, short_node_id @@ -60,7 +61,7 @@ def filter_by_status(results, status): @command(arguments=(sort_incomes, status_filter, full_table), help="Display incomes", root=True) def incomes(sort, status, full=False): - deferred = incomes.client.get_incomes_list() + deferred = incomes.client._call('pay.incomes') result = sync_wait(deferred) or [] values = [] @@ -83,7 +84,7 @@ def incomes(sort, status, full=False): help="Display payments", root=True) def payments(sort, status, full=False): - deferred = payments.client.get_payments_list() + deferred = payments.client._call('pay.payments') result = sync_wait(deferred) or [] values = [] @@ -119,7 +120,7 @@ def payments(sort, status, full=False): ) def deposit_payments(sort, status): - deferred = payments.client.get_deposit_payments_list() + deferred = payments.client._call('pay.deposit_payments') result = sync_wait(deferred) or [] values = [] diff --git a/golem/interface/client/tasks.py b/golem/interface/client/tasks.py index 44f8cf6cbe..5433ba2187 100644 --- a/golem/interface/client/tasks.py +++ b/golem/interface/client/tasks.py @@ -23,7 +23,7 @@ class Tasks: task_table_headers = ['id', 'ETA', 'subtasks_count', 'status', 'completion'] - subtask_table_headers = ['node', 'id', 'ETA', 'status', 'completion'] + subtask_table_headers = ['node', 'id', 'status', 'completion'] unsupport_reasons_table_headers = ['reason', 'no of tasks', 'avg for all tasks'] @@ -118,7 +118,6 @@ def subtasks(self, id, sort): values.append([ subtask['node_name'], subtask['subtask_id'], - Tasks.__format_seconds(subtask['time_remaining']), subtask['status'], Tasks.__progress_str(subtask['progress']) ]) @@ -138,7 +137,7 @@ def restart(self, id, force: bool = False): help="Restart given subtasks from a task") def restart_subtasks(self, id, subtask_ids, force: bool): deferred = Tasks.client._call( # pylint: disable=protected-access - 'comp.task.restart_subtasks', + 'comp.task.subtasks.restart', id, subtask_ids, force=force, diff --git a/golem/manager/nodestatesnapshot.py b/golem/manager/nodestatesnapshot.py index c4799286b8..5b314323ed 100644 --- a/golem/manager/nodestatesnapshot.py +++ b/golem/manager/nodestatesnapshot.py @@ -12,12 +12,23 @@ def __init__( seconds_to_timeout: float, running_time_seconds: float, # extra_data: - outfilebasename: str, - output_format: str, - scene_file: str, - frames: List[int], - start_task: int, - total_tasks: int, + + # TODO before release 0.21 + # + # refactor this state snapshot so that it's independent from + # any particular application type + # + # + ensure that Electron front-end doesn't depend on this data + # being present here and/or having specific format + # + # https://github.com/golemfactory/golem/issues/4318 + + outfilebasename: str = None, + output_format: str = None, + scene_file: str = None, + frames: List[int] = None, + start_task: int = None, + total_tasks: int = None, # if there's something more in extra_data, just ignore it **_kwargs ) -> None: diff --git a/golem/model.py b/golem/model.py index 05571527a0..5cb517d5e5 100644 --- a/golem/model.py +++ b/golem/model.py @@ -11,7 +11,6 @@ from ethereum.utils import denoms import golem_messages from golem_messages import datastructures as msg_dt -from golem_messages import exceptions as msg_exceptions from golem_messages import message from golem_messages.datastructures import p2p as dt_p2p from peewee import ( @@ -22,6 +21,7 @@ DateTimeField, Field, FloatField, + ForeignKeyField, IntegerField, Model, SmallIntegerField, @@ -44,12 +44,35 @@ ('journal_mode', 'WAL'))) +# Use proxy function to always use current .utcnow() (allows mocking) +def default_now(): + return datetime.datetime.now(tz=datetime.timezone.utc) + + +# Bug in peewee_migrate 0.14.0 induces setting __self__ +# noqa SEE: https://github.com/klen/peewee_migrate/blob/c55cb8c3664c3d59e6df3da7126b3ddae3fb7b39/peewee_migrate/auto.py#L64 # pylint: disable=line-too-long +default_now.__self__ = datetime.datetime # type: ignore + + +class UTCDateTimeField(DateTimeField): + formats = DateTimeField.formats + [ + '%Y-%m-%d %H:%M:%S+00:00', + '%Y-%m-%d %H:%M:%S.%f+00:00', + ] + + def python_value(self, value): + value = super().python_value(value) + if value is None: + return None + return value.replace(tzinfo=datetime.timezone.utc) + + class BaseModel(Model): class Meta: database = db - created_date = DateTimeField(default=datetime.datetime.now) - modified_date = DateTimeField(default=datetime.datetime.now) + created_date = UTCDateTimeField(default=default_now) + modified_date = UTCDateTimeField(default=default_now) def refresh(self): """ @@ -103,6 +126,8 @@ def __init__(self, *args, **kwargs): def db_value(self, value: str): value = super().db_value(value) + if value is None: + return None current_len = len(value) if len(value) != self.EXPECTED_LENGTH: raise ValueError( @@ -166,8 +191,13 @@ def __init__(self, enum_type, *args, **kwargs): class StringEnumField(EnumFieldBase, CharField): """ Database field that maps enum types to strings.""" - def __init__(self, enum_type, *args, max_length=255, **kwargs): - super().__init__(max_length, *args, **kwargs) + def __init__(self, *args, max_length=255, enum_type=None, **kwargs): + # Because of peewee_migrate limitations + # we have to provide default enum_type + # (peewee_migrate only understands max_length in CharField + # subclasses) + # noqa SEE: https://github.com/klen/peewee_migrate/blob/c55cb8c3664c3d59e6df3da7126b3ddae3fb7b39/peewee_migrate/auto.py#L41 pylint: disable=line-too-long + super().__init__(*args, max_length=max_length, **kwargs) self.enum_type = enum_type @@ -197,78 +227,11 @@ def python_value(self, value: str) -> DictSerializable: return self.objtype.from_dict(json.loads(value)) -class PaymentStatus(enum.Enum): - """ The status of a payment. """ - awaiting = 1 # Created but not introduced to the payment network. - sent = 2 # Sent to the payment network. - confirmed = 3 # Confirmed on the payment network. - - # Workarounds for peewee_migration - - def __repr__(self): - return '{}.{}'.format(self.__class__.__name__, self.name) - - @property - def __self__(self): - return self - - -class PaymentDetails(DictSerializable): - def __init__(self, - node_info: Optional[dt_p2p.Node] = None, - fee: Optional[int] = None, - block_hash: Optional[str] = None, - block_number: Optional[int] = None, - check: Optional[bool] = None, - tx: Optional[str] = None) -> None: - self.node_info = node_info - self.fee = fee - self.block_hash = block_hash - self.block_number = block_number - self.check = check - self.tx = tx - - def to_dict(self) -> dict: - d = self.__dict__.copy() - if self.node_info: - d['node_info'] = self.node_info.to_dict() - return d - - @staticmethod - def from_dict(data: dict) -> 'PaymentDetails': - det = PaymentDetails() - det.__dict__.update(data) - if data['node_info']: - try: - det.node_info = dt_p2p.Node(**data['node_info']) - except msg_exceptions.FieldError: - det.node_info = None - return det - - def __eq__(self, other: object) -> bool: - if not isinstance(other, PaymentDetails): - raise TypeError( - "Mismatched types: expected PaymentDetails, got {}".format( - type(other))) - return self.__dict__ == other.__dict__ - - class NodeField(DictSerializableJSONField): """ Database field that stores a Node in JSON format. """ objtype = dt_p2p.Node -class PaymentDetailsField(DictSerializableJSONField): - """ Database field that stores a PaymentDetails in JSON format. """ - objtype = PaymentDetails - - -class PaymentStatusField(EnumField): - """ Database field that stores PaymentStatusField objects as integers. """ - def __init__(self, *args, **kwargs): - super().__init__(PaymentStatus, *args, **kwargs) - - class VersionField(CharField): """Semantic version field""" @@ -283,81 +246,95 @@ def python_value(self, value): return None -class Payment(BaseModel): - """ Represents payments that nodes on this machine make to other nodes - """ - subtask = CharField(primary_key=True) - status = PaymentStatusField(index=True, default=PaymentStatus.awaiting) - payee = RawCharField() - value = HexIntegerField() - details = PaymentDetailsField() - processed_ts = IntegerField(null=True) +class WalletOperation(BaseModel): + class STATUS(msg_dt.StringEnum): + awaiting = enum.auto() + sent = enum.auto() + confirmed = enum.auto() + overdue = enum.auto() + + class DIRECTION(msg_dt.StringEnum): + incoming = enum.auto() + outgoing = enum.auto() + + class TYPE(msg_dt.StringEnum): + transfer = enum.auto() # topup & withdraw + deposit_transfer = enum.auto() # deposit topup & withdraw + task_payment = enum.auto() + deposit_payment = enum.auto() # forced payments + + class CURRENCY(msg_dt.StringEnum): + ETH = enum.auto() + GNT = enum.auto() + + tx_hash = BlockchainTransactionField(null=True) + direction = StringEnumField(enum_type=DIRECTION) + operation_type = StringEnumField(enum_type=TYPE) + status = StringEnumField(enum_type=STATUS) + sender_address = CharField() + recipient_address = CharField() + amount = HexIntegerField() + currency = StringEnumField(enum_type=CURRENCY) + gas_cost = HexIntegerField() - def __init__(self, *args, **kwargs): - super(Payment, self).__init__(*args, **kwargs) - # For convenience always have .details as a dictionary - if self.details is None: - self.details = PaymentDetails() - - def __repr__(self) -> str: - tx = self.details.tx - bn = self.details.block_number - return ""\ - .format( - self.subtask, - float(self.value) / denoms.ether, - self.status, - tx, - bn, - self.processed_ts - ) - - -class DepositPayment(BaseModel): - tx = BlockchainTransactionField(primary_key=True) - value = HexIntegerField() - status = PaymentStatusField(index=True, default=PaymentStatus.awaiting) - fee = HexIntegerField(null=True) - - class Meta: - database = db + def __str__(self): + return ( + f"WalletOperation. tx_hash={self.tx_hash}," + f" direction={self.direction}, type={self.operation_type}," + f" amount={self.amount/denoms.ether}{self.currency}" + ) - def __repr__(self): - return ""\ - .format( - value=self.value, - status=self.status, - tx=self.tx, + @classmethod + def deposit_transfers(cls): + return cls.select() \ + .where( + WalletOperation.operation_type + == WalletOperation.TYPE.deposit_transfer, ) -class Income(BaseModel): - sender_node = CharField() +class TaskPayment(BaseModel): + wallet_operation = ForeignKeyField(WalletOperation, unique=True) + node = CharField() + task = CharField() subtask = CharField() - payer_address = CharField() - value = HexIntegerField() - value_received = HexIntegerField(default=0) + expected_amount = HexIntegerField() accepted_ts = IntegerField(null=True) - transaction = CharField(null=True) - overdue = BooleanField(default=False) settled_ts = IntegerField(null=True) # set if settled by the Concent - class Meta: - database = db - primary_key = CompositeKey('sender_node', 'subtask') + def __str__(self): + return ( + f"TaskPayment. accepted_ts={self.accepted_ts}," + f" task={self.task}, subtask={self.subtask}," + f" node={self.node}, wo={self.wallet_operation}" + ) - def __repr__(self): - return ""\ - .format( - self.subtask, - self.value, - self.accepted_ts, - self.transaction, + @classmethod + def incomes(cls): + return cls.select() \ + .join(WalletOperation) \ + .where( + WalletOperation.operation_type + == WalletOperation.TYPE.task_payment, + WalletOperation.direction + == WalletOperation.DIRECTION.incoming, + ) + + @classmethod + def payments(cls): + return cls.select() \ + .join(WalletOperation) \ + .where( + WalletOperation.operation_type + == WalletOperation.TYPE.task_payment, + WalletOperation.direction + == WalletOperation.DIRECTION.outgoing, ) @property - def value_expected(self): - return self.value - self.value_received + def missing_amount(self): + # pylint: disable=no-member + return self.expected_amount - self.wallet_operation.amount ################## # RANKING MODELS # @@ -507,6 +484,10 @@ def update_or_create(cls, env_id, performance): perf.save() +class DockerWhitelist(BaseModel): + repository = CharField(primary_key=True) + + ################## # MESSAGE MODELS # ################## @@ -521,7 +502,7 @@ class Actor(enum.Enum): class ActorField(StringEnumField): """ Database field that stores Actor objects as strings. """ def __init__(self, *args, **kwargs): - super().__init__(Actor, *args, **kwargs) + super().__init__(*args, enum_type=Actor, **kwargs) class NetworkMessage(BaseModel): diff --git a/golem/monitor/model/nodemetadatamodel.py b/golem/monitor/model/nodemetadatamodel.py index 215424093f..3bd03c3751 100644 --- a/golem/monitor/model/nodemetadatamodel.py +++ b/golem/monitor/model/nodemetadatamodel.py @@ -1,4 +1,4 @@ -from golem.config.active import ACTIVE_NET +from golem.config.active import EthereumConfig from golem.monitor.serialization import defaultserializer from .modelbase import BasicModel @@ -16,7 +16,7 @@ def __init__(self, client, os_info, ver): "ClientConfigDescriptor", client.config_desc) self.version = ver - self.net = ACTIVE_NET + self.net = EthereumConfig().ACTIVE_NET class NodeInfoModel(BasicModel): diff --git a/golem/network/concent/client.py b/golem/network/concent/client.py index 8b36456c46..345ce59f85 100644 --- a/golem/network/concent/client.py +++ b/golem/network/concent/client.py @@ -25,6 +25,11 @@ from . import soft_switch from .helpers import ssl_kwargs + +if typing.TYPE_CHECKING: + # pylint: disable=unused-import + from golem import model + logger = logging.getLogger(__name__) @@ -409,8 +414,9 @@ def income_listener(self, event, **kwargs): from golem.network import history sra_l = [] for income in kwargs['incomes']: + income: 'model.TaskPayment' sra = history.get( - node_id=income.sender_node, + node_id=income.node, subtask_id=income.subtask, message_class_name='SubtaskResultsAccepted', ) @@ -418,9 +424,11 @@ def income_listener(self, event, **kwargs): logger.debug( '[CONCENT] SRA missing subtask_id=%r node_id=%r', income.subtask, - income.sender_node, + income.node, ) continue + if not sra.report_computed_task.task_to_compute.concent_enabled: + continue sra_l.append(sra) if not sra_l: return diff --git a/golem/network/concent/received_handler.py b/golem/network/concent/received_handler.py index dcdd361e21..64a10165d4 100644 --- a/golem/network/concent/received_handler.py +++ b/golem/network/concent/received_handler.py @@ -228,7 +228,7 @@ def on_force_report_computed_task(self, msg, **_): def on_force_subtask_results(self, msg, **_): """I'm a Requestor - Concent sends his own ForceSubtaskResults with AckReportComputedTask + Concent sends its own ForceSubtaskResults with AckReportComputedTask provided by a provider. """ sra = history.get( @@ -244,13 +244,28 @@ def on_force_subtask_results(self, msg, **_): task_id=msg.task_id ) if not (sra or srr): - # I can't remember verification results, - # so try again and hope for the best - self._after_ack_report_computed_task( - report_computed_task=msg.ack_report_computed_task - .report_computed_task, + fgtrf = history.get( + message_class_name='ForceGetTaskResultFailed', + node_id=msg.provider_id, + subtask_id=msg.subtask_id, + task_id=msg.task_id ) - return + if fgtrf: + srr = message.tasks.SubtaskResultsRejected( + report_computed_task=( + msg.ack_report_computed_task.report_computed_task), + force_get_task_result_failed=fgtrf, + reason=(message.tasks.SubtaskResultsRejected.REASON + .ForcedResourcesFailure), + ) + else: + # I can't remember verification results and I have no proof of + # failure from Concent, so try again and hope for the best + self._after_ack_report_computed_task( + report_computed_task=msg.ack_report_computed_task + .report_computed_task, + ) + return response_msg = message.concents.ForceSubtaskResultsResponse( subtask_results_accepted=sra, @@ -305,6 +320,7 @@ def on_force_subtask_results_response(self, msg): sub_msg = msg.subtask_results_accepted self.task_server.subtask_accepted( sender_node_id=msg.requestor_id, + task_id=msg.task_id, subtask_id=msg.subtask_id, payer_address=ttc.requestor_ethereum_address, value=ttc.price, diff --git a/golem/network/concent/resources/ssl/certs/staging.crt b/golem/network/concent/resources/ssl/certs/staging.crt index 8dafaee100..e173ff4902 100644 --- a/golem/network/concent/resources/ssl/certs/staging.crt +++ b/golem/network/concent/resources/ssl/certs/staging.crt @@ -1,24 +1,24 @@ -----BEGIN CERTIFICATE----- -MIID/TCCAuWgAwIBAgIJAKjBujqVTJYJMA0GCSqGSIb3DQEBCwUAMIGRMQswCQYD -VQQGEwJQTDETMBEGA1UECAwKU29tZS1TdGF0ZTEOMAwGA1UECgwFR29sZW0xEDAO -BgNVBAsMB0NvbmNlbnQxJjAkBgNVBAMMHXN0YWdpbmcuY29uY2VudC5nb2xlbS5u -ZXR3b3JrMSMwIQYJKoZIhvcNAQkBFhRjb250YWN0QGNvZGVwb2V0cy5pdDAeFw0x -ODA1MjQxMDE4NTZaFw0xOTA1MjQxMDE4NTZaMIGRMQswCQYDVQQGEwJQTDETMBEG -A1UECAwKU29tZS1TdGF0ZTEOMAwGA1UECgwFR29sZW0xEDAOBgNVBAsMB0NvbmNl -bnQxJjAkBgNVBAMMHXN0YWdpbmcuY29uY2VudC5nb2xlbS5uZXR3b3JrMSMwIQYJ -KoZIhvcNAQkBFhRjb250YWN0QGNvZGVwb2V0cy5pdDCCASIwDQYJKoZIhvcNAQEB -BQADggEPADCCAQoCggEBALUHpfkEziqa3mIRuB+lNAu7RZrWeVu1rYumjwe4VUmx -0NSP7aUJ5XSlyDRJMlHi/LyRUoXNI55eV7ljArlG4PD8iVZH5rVJJ4YmOntlqT2Y -x9WHVG3OgJ+xN5LQ4i4Uq/gX2Q53xbTDIifZDmsoRk2Xn7RQ/pnf744rOJj9+oVS -LdJNNGZzwf1TDeQqi2mRGW7DhpDIFo/RvG8FVVNSlpXWGPO7AkqxZrKbi9z5E7hV -RPvfO6TKXFxmaMLRW7h9fXMQfN2m8Z2j3ha8wQKJy9Ay4mllZiqPqqDS+/0nXpaC -EU/k0BkEshzT812qPruM3TbHKHgh8kLBu1D4eN2ZMH0CAwEAAaNWMFQwCQYDVR0T -BAIwADAoBgNVHREEITAfgh1zdGFnaW5nLmNvbmNlbnQuZ29sZW0ubmV0d29yazAd -BgNVHQ4EFgQU+43LHFvaHtjedDXkhN7N5DHM+/AwDQYJKoZIhvcNAQELBQADggEB -AG6lOQ6rFr9ZaZAXKjbjOxySn57Rev5zu66U1LxKi7fTPoPfd/wSBXYrjqRnaZL+ -zgrlQb6Ygpws+imLrGp4e4Sugt+kZHhE4glWqhYiWgvV0WvV7RVSxiJ7vb8OcDer -vZM1eLT1vb17MBzAQoOcxLoqBcNdRQEXKXQkxper7qUa53/HKQYSUlJKc/VMMx8K -wmsivcHVPvKhHf5l77VDsqgZ9huvkq1mT2vy3/coxQqj41pCfL0sFO+nh/rlwa5C -zx7CA7i9FS5QVOf08VLthwrf+rrwNn1qzhdTBsWJMX3QzMCxVwT+fNxPcwTn5MfF -yOtB7QIdoNSXEySO0OAgeoM= +MIIECDCCAvCgAwIBAgIUYp+h/xbECNcTuVyxQ2CXXrYzIuUwDQYJKoZIhvcNAQEL +BQAwgZExCzAJBgNVBAYTAlBMMRMwEQYDVQQIDApTb21lLVN0YXRlMQ4wDAYDVQQK +DAVHb2xlbTEQMA4GA1UECwwHQ29uY2VudDEmMCQGA1UEAwwdc3RhZ2luZy5jb25j +ZW50LmdvbGVtLm5ldHdvcmsxIzAhBgkqhkiG9w0BCQEWFGNvbnRhY3RAY29kZXBv +ZXRzLml0MB4XDTE5MDUyMDEwMTYyMFoXDTIwMDUxOTEwMTYyMFowgZExCzAJBgNV +BAYTAlBMMRMwEQYDVQQIDApTb21lLVN0YXRlMQ4wDAYDVQQKDAVHb2xlbTEQMA4G +A1UECwwHQ29uY2VudDEmMCQGA1UEAwwdc3RhZ2luZy5jb25jZW50LmdvbGVtLm5l +dHdvcmsxIzAhBgkqhkiG9w0BCQEWFGNvbnRhY3RAY29kZXBvZXRzLml0MIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA29P2ZObny42JszOn9v6/aiNgUnl4 +i71FiyEUxizw5Ieau7iaCgiqtiv0RuGJWiHVPOWCcW88DNoqRtT4/UMfEhw+27bC +PkgABcvhNJvz+Zsus8utKoXx8WZHvWRJKhXnQlKdFCN9pAYyc32dMRs/3r/AEoHf +s2WjX4wBZNvtXaT+5ACJwPbHi7kmYte1EIJ1B5E3WVVe7Zb52c1LrvfcXNmTbpzh +nFgjT+ECU8RKNioYElFHXFLYoDY7ahPZuXakHACU1pfW/y4NMfMIaOB0hb0JDlKx +Gfu1MVk5GFPR//hWmU7Y/ngXrVutfABWayPd4kHUcBTSqR4zy1ieNRkRvwIDAQAB +o1YwVDAJBgNVHRMEAjAAMCgGA1UdEQQhMB+CHXN0YWdpbmcuY29uY2VudC5nb2xl +bS5uZXR3b3JrMB0GA1UdDgQWBBTjSOcaDbGzU926JgJYGoDauUKfJTANBgkqhkiG +9w0BAQsFAAOCAQEAUyRsnzYQ/wLbfKhBMgLH3HCSmpWdc/jell0KW0NqOO2eqHrM +OOXHWbOcneI3iwGxvkvFrXEKRrFzlsU9NN9QQQjMdL1BucKE9JV5weFIXJBtUNDc +n7CsbYid7XSOabXa3EjLLYGy1/B8MEErb56324qUU4YerOkE531CZd+V7BBt8Yjl +LGhw6UAWXzhzEHADAuMyIaEDA8ON/SmVqCwTqtu2mwZi4ZD7bxAe5zAiaBMZbuC9 +cSCc96/xD8zEU8N/xJd27RA04UKnjKPHZas/LI77QiN6QQIRXoOgNUEPschTNGfW +Bca8VP+3+JXM5zxPzB+3bCEF0TZx2IvkpvvKng== -----END CERTIFICATE----- diff --git a/golem/network/concent/resources/ssl/certs/test.crt b/golem/network/concent/resources/ssl/certs/test.crt index 7f3a25c5b0..5251480128 100644 --- a/golem/network/concent/resources/ssl/certs/test.crt +++ b/golem/network/concent/resources/ssl/certs/test.crt @@ -1,24 +1,24 @@ -----BEGIN CERTIFICATE----- -MIID9DCCAtygAwIBAgIJALA6TWzpnSmuMA0GCSqGSIb3DQEBCwUAMIGOMQswCQYD -VQQGEwJQTDETMBEGA1UECAwKU29tZS1TdGF0ZTEOMAwGA1UECgwFR29sZW0xEDAO -BgNVBAsMB0NvbmNlbnQxIzAhBgNVBAMMGnRlc3QuY29uY2VudC5nb2xlbS5uZXR3 -b3JrMSMwIQYJKoZIhvcNAQkBFhRjb250YWN0QGNvZGVwb2V0cy5pdDAeFw0xODA1 -MjQxMDE2NTlaFw0xOTA1MjQxMDE2NTlaMIGOMQswCQYDVQQGEwJQTDETMBEGA1UE -CAwKU29tZS1TdGF0ZTEOMAwGA1UECgwFR29sZW0xEDAOBgNVBAsMB0NvbmNlbnQx -IzAhBgNVBAMMGnRlc3QuY29uY2VudC5nb2xlbS5uZXR3b3JrMSMwIQYJKoZIhvcN -AQkBFhRjb250YWN0QGNvZGVwb2V0cy5pdDCCASIwDQYJKoZIhvcNAQEBBQADggEP -ADCCAQoCggEBAMMHOFLYAgrircuvjk0z4D2nZwE7ZezgSy6N61Zf0YjY08rzSxOl -QrUZ8+5fJz/X4CizNuTx5laA928D6uBi2JOcQN11gzWhLLfn+7GM0+AAF9qH2RKU -DJM+YTlS+A3v1xpyXrBui6kqzpwN2kVzvBwnEDsQA9DSxP6hFbrKHcJIVkx08vfL -edUZP7ZJ5+aIvlxZl6eVmUzG1T/9zZs+X3v8qqr6GYDxOe03uVuiQCi9/QxuvCoT -IEJjsLsiGSarMwkr8UxPKWijPr/GHRD/vq2UHVj5a61VFRR5w+t79osHCa77fOEP -7YUzCeNx3+4MBDBnRy8L/Wjvy8jAyR3zkfUCAwEAAaNTMFEwCQYDVR0TBAIwADAl -BgNVHREEHjAcghp0ZXN0LmNvbmNlbnQuZ29sZW0ubmV0d29yazAdBgNVHQ4EFgQU -6FmbjhAA/aUjMDPJ3jB1NHICYtgwDQYJKoZIhvcNAQELBQADggEBAA4fMJCwCopu -EBWPph6TsmF/TMARmn0Q3zO5AH5UskQ2lHjgXLDuSdOe1XPa9SYv5hd0roKBUD60 -I9aOGr8dzq5OFtzfESZ5z05gRueLFZdRGxlXd9kY93ztimGc5txjMZyeIkLSvMuM -O97HSBM//Rz2x5S6cawwj+J0dSs7+Kbfzmc+p2qFDVWIWih5SN62D6E73xRLG6rb -kHXh+c7Z8At2Hfe6iDmXxzI24ybQBSOgoIQZHCayfDqpswpi99IzZlrxZMT5BKu3 -cuPqnXebuiWo567lvcQDgxTwwftIEaLGzeTuA+sIN2Kz89cM0mAj9fxbb9OMn6je -H9LepNSbGak= +MIID/zCCAuegAwIBAgIUTuA3ximoLQXv7N0RykTJGrQeTxkwDQYJKoZIhvcNAQEL +BQAwgY4xCzAJBgNVBAYTAlBMMRMwEQYDVQQIDApTb21lLVN0YXRlMQ4wDAYDVQQK +DAVHb2xlbTEQMA4GA1UECwwHQ29uY2VudDEjMCEGA1UEAwwadGVzdC5jb25jZW50 +LmdvbGVtLm5ldHdvcmsxIzAhBgkqhkiG9w0BCQEWFGNvbnRhY3RAY29kZXBvZXRz +Lml0MB4XDTE5MDUyMDEwMTI1OFoXDTIwMDUxOTEwMTI1OFowgY4xCzAJBgNVBAYT +AlBMMRMwEQYDVQQIDApTb21lLVN0YXRlMQ4wDAYDVQQKDAVHb2xlbTEQMA4GA1UE +CwwHQ29uY2VudDEjMCEGA1UEAwwadGVzdC5jb25jZW50LmdvbGVtLm5ldHdvcmsx +IzAhBgkqhkiG9w0BCQEWFGNvbnRhY3RAY29kZXBvZXRzLml0MIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxwokK8rkGYIoO/Naxa3LSBEX4UrE0FM8199e +T90EkfW02cJeLIlVWzNr9mxRMU1+Co2YBS4Ql/TBILiJ19XHrXL3RbDva5PsxV1U +wWMjmAHXxFBJ52ymElrFc5iJt+qMFOCcERJzukcmrPcvztUdJ464O2ledBjnLPhu +IdzpPLOCQ8TK8RrtwC4QUPVGDqiKw231g1dEgwnyBFP5uRCHiiT9yjoc/1PHQkkl +piDTPTJiudDpAUlLMdcZS2WhBY9SdRg5ft8xi2ETyKPfU4XMLoVZaKC19kZm0+Q1 +/+3A4Dt9diYGaRuOQSLECe8rRh2Y2vd0dBDJ4psn8++0mS/gNQIDAQABo1MwUTAJ +BgNVHRMEAjAAMCUGA1UdEQQeMByCGnRlc3QuY29uY2VudC5nb2xlbS5uZXR3b3Jr +MB0GA1UdDgQWBBRGKiYz6YGug7/B1Dd2mineLGUqZDANBgkqhkiG9w0BAQsFAAOC +AQEAOHnm5ObJzGaTFD3CUkZo3Gp/sIqgNmUUXI9llm5m+51BC7/kx3aznA7F+fyJ +L8A/OSTn1SFwTREEiqHkpxl17GT8Y5ghoB8b8RTIWaZ/20PJLV77TH/vEGNC0VMq +cE9Nl+R8XBWFkyS7rXi+R8xIlpFTSTnuwvgHOY5G1QIyjNWSmT517mG7H/f2TH0i ++kcbbCbcYzhMLWrb7QC36NGD2NkShwefLl1H3jDrAQ1jadO4Z+qKdGI631RYap0y +Bnld6gN/nG5H9tkBsJUZFJGVZeBygrv6Lc87ys+vPcACHQC73PAbnnHc1DFBSSsT ++Cuh82v69IHFk02MCvRX1+RCXQ== -----END CERTIFICATE----- diff --git a/golem/network/history.py b/golem/network/history.py index ba4636df9a..8e15e23c47 100644 --- a/golem/network/history.py +++ b/golem/network/history.py @@ -119,7 +119,13 @@ def get_sync_as_message(cls, *args, **kwargs) -> message.base.Message: if not db_result: raise MessageNotFound() db_msg = db_result[0] - return db_msg.as_message() + try: + return db_msg.as_message() + except AttributeError: + # in case an incompatible message from an earlier version of + # golem-messages is retrieved, just treat it the same + # as if the message was not found + raise MessageNotFound() def add(self, msg_dict: dict) -> None: """ diff --git a/golem/network/hyperdrive/daemon_manager.py b/golem/network/hyperdrive/daemon_manager.py index 3caef7ba07..ffe0986fec 100644 --- a/golem/network/hyperdrive/daemon_manager.py +++ b/golem/network/hyperdrive/daemon_manager.py @@ -18,7 +18,7 @@ logger = logging.getLogger('golem.resources') -GOLEM_HYPERDRIVE_VERSION = '0.2.4' +GOLEM_HYPERDRIVE_VERSION = '0.3.2' GOLEM_HYPERDRIVE_LOGFILE = 'hyperg.log' @@ -144,8 +144,8 @@ def _start(self, *_): def _check_version(self): version = self.version() if version < self._min_version: - raise RuntimeError('HyperG version {} is required' - .format(self._min_version)) + raise RuntimeError('HyperG version >={} is required, you have {}.' + .format(self._min_version, version)) return version @report_calls(Component.hyperdrive, 'instance.check') diff --git a/golem/network/p2p/p2pservice.py b/golem/network/p2p/p2pservice.py index 3f17882876..9154318338 100644 --- a/golem/network/p2p/p2pservice.py +++ b/golem/network/p2p/p2pservice.py @@ -105,7 +105,6 @@ def __init__( self.last_challenge = "" self.base_difficulty = BASE_DIFFICULTY self.connect_to_known_hosts = connect_to_known_hosts - self.key_difficulty = config_desc.key_difficulty # Peers options self.peers = {} # active peers @@ -335,10 +334,9 @@ def add_peer(self, peer: PeerSession): """ key_id = peer.key_id logger.info( - "Adding peer. node=%s, address=%s:%s, key_difficulty=%r", + "Adding peer. node=%s, address=%s:%s", node_info_str(peer.node_name, key_id), peer.address, peer.port, - self.keys_auth.get_difficulty(key_id) ) with self._peer_lock: self.peers[key_id] = peer diff --git a/golem/network/p2p/peersession.py b/golem/network/p2p/peersession.py index e35f67cc0e..f70cf2d2a9 100644 --- a/golem/network/p2p/peersession.py +++ b/golem/network/p2p/peersession.py @@ -286,23 +286,16 @@ def _react_to_hello(self, msg): self.disconnect(message.base.Disconnect.REASON.ProtocolVersion) return - self.node_info = msg.node_info - - if not KeysAuth.is_pubkey_difficult( - self.node_info.key, - self.p2p_service.key_difficulty): - logger.info( - "Key from %r (%s:%d) is not difficult enough (%d < %d).", - self.node_info.node_name, self.address, self.port, - KeysAuth.get_difficulty(self.node_info.key), - self.p2p_service.key_difficulty) - self.disconnect(message.base.Disconnect.REASON.KeyNotDifficult) + if msg.node_info is None: + self.disconnect(message.base.Disconnect.REASON.ProtocolVersion) return - self.node_name = msg.node_name + self.node_info = msg.node_info + + self.node_name = msg.node_info.node_name self.client_ver = msg.client_ver self.listen_port = msg.port - self.key_id = msg.client_key_id + self.key_id = msg.node_info.key self.metadata = msg.metadata solve_challenge = msg.solve_challenge @@ -481,8 +474,6 @@ def __send_hello(self): msg = message.base.Hello( proto_id=variables.PROTOCOL_CONST.ID, port=self.p2p_service.cur_port, - node_name=self.p2p_service.node_name, - client_key_id=self.p2p_service.keys_auth.key_id, node_info=self.p2p_service.node, client_ver=golem.__version__, rand_val=self.rand_val, diff --git a/golem/network/transport/tcpnetwork.py b/golem/network/transport/tcpnetwork.py index 22dbb026b6..74aa674f57 100644 --- a/golem/network/transport/tcpnetwork.py +++ b/golem/network/transport/tcpnetwork.py @@ -127,7 +127,7 @@ def __try_to_connect_to_addresses(self, connect_info: TCPConnectInfo): logger.debug('__try_to_connect_to_addresses(%r) filtered', addresses) if not addresses: - logger.warning("No addresses for connection given") + logger.debug("No addresses for connection given") TCPNetwork.__call_failure_callback(connect_info.failure_callback) return diff --git a/golem/node.py b/golem/node.py index 28527c941c..283bf18e5b 100644 --- a/golem/node.py +++ b/golem/node.py @@ -10,6 +10,7 @@ Dict, List, Optional, + TYPE_CHECKING, TypeVar, ) @@ -24,7 +25,7 @@ from golem.appconfig import AppConfig from golem.client import Client from golem.clientconfigdescriptor import ClientConfigDescriptor -from golem.config.active import IS_MAINNET, EthereumConfig +from golem.config.active import EthereumConfig from golem.core.deferred import chain_function from golem.hardware.presets import HardwarePresets, HardwarePresetsMixin from golem.core.keysauth import KeysAuth, WrongPassword @@ -48,6 +49,11 @@ from golem.tools.uploadcontroller import UploadController from golem.tools.remotefs import RemoteFS +if TYPE_CHECKING: + # pylint:disable=unused-import + from golem.rpc.router import SerializerType + + F = TypeVar('F', bound=Callable[..., Any]) logger = logging.getLogger(__name__) @@ -87,7 +93,8 @@ def __init__(self, # noqa pylint: disable=too-many-arguments use_talkback: bool = False, use_docker_manager: bool = True, geth_address: Optional[str] = None, - password: Optional[str] = None + password: Optional[str] = None, + crossbar_serializer: 'Optional[SerializerType]' = None, ) -> None: # DO NOT MAKE THIS IMPORT GLOBAL @@ -108,11 +115,12 @@ def __init__(self, # noqa pylint: disable=too-many-arguments if use_talkback is None else use_talkback self._keys_auth: Optional[KeysAuth] = None + ethereum_config = EthereumConfig() if geth_address: - EthereumConfig.NODE_LIST = [geth_address] + ethereum_config.NODE_LIST = [geth_address] self._ets = TransactionSystem( Path(datadir) / 'transaction_system', - EthereumConfig, + ethereum_config, ) self._ets.backwards_compatibility_tx_storage(Path(datadir)) self.concent_variant = concent_variant @@ -153,6 +161,8 @@ def __init__(self, # noqa pylint: disable=too-many-arguments if not self.set_password(password): raise Exception("Password incorrect") + self._crossbar_serializer = crossbar_serializer + def start(self) -> None: HardwarePresets.initialize(self._datadir) @@ -214,7 +224,6 @@ def set_password(self, password: str) -> bool: datadir=self._datadir, private_key_name=PRIVATE_KEY, password=password, - difficulty=self._config_desc.key_difficulty, ) # When Golem is ready to use different Ethereum account for # payments and identity this should be called only when @@ -267,13 +276,14 @@ def get_task_results(self, task_id): @rpc_utils.expose('golem.mainnet') @classmethod def is_mainnet(cls) -> bool: - return IS_MAINNET + return EthereumConfig().IS_MAINNET def _start_rpc(self) -> Deferred: self.rpc_router = rpc = CrossbarRouter( host=self._config_desc.rpc_address, port=self._config_desc.rpc_port, datadir=self._datadir, + crossbar_serializer=self._crossbar_serializer, ) self._reactor.addSystemEventTrigger("before", "shutdown", rpc.stop) diff --git a/golem/report.py b/golem/report.py index 7a83363822..04668ec27d 100644 --- a/golem/report.py +++ b/golem/report.py @@ -3,9 +3,8 @@ from typing import Any, ClassVar, Dict, Optional, Tuple -from twisted.internet.defer import Deferred, succeed +from twisted.internet.defer import Deferred, maybeDeferred -from golem.core.common import to_unicode from golem.rpc.session import Publisher from golem.rpc.mapping.rpceventnames import Golem @@ -54,23 +53,18 @@ def publish(cls, component, method, stage, data=None) -> Optional[Deferred]: autobahn.wamp.request.Publication on success or None if session is closing or there was an error """ - cls._last_status[to_unicode(component)] = ( - to_unicode(method), - to_unicode(stage), - data) + cls._update_status(component, method, stage, data) + if cls._rpc_publisher: from twisted.internet import reactor - deferred = Deferred() def _publish(): - publish_deferred: Optional[Deferred] = \ - cls._rpc_publisher.publish( - Golem.evt_golem_status, - cls._last_status) - if publish_deferred is None: - publish_deferred = succeed(None) - publish_deferred.chainDeferred(deferred) + maybeDeferred( + cls._rpc_publisher.publish, + Golem.evt_golem_status, + cls._last_status + ).chainDeferred(deferred) reactor.callFromThread(_publish) return deferred @@ -102,6 +96,12 @@ def _publish_listener(cls, event: str = 'default', **kwargs) -> None: kwargs['stage'], kwargs.get('data')) + @classmethod + def _update_status(cls, component, method, stage, data) -> None: + if data and not isinstance(data, dict): + data = {"status": "message", "value": data} + cls._last_status[component] = (method, stage, data) + @contextmanager def report_call(component, method, stage=None): diff --git a/golem/resource/resourcehandshake.py b/golem/resource/resourcehandshake.py index feed5ffe08..fc34724fd5 100644 --- a/golem/resource/resourcehandshake.py +++ b/golem/resource/resourcehandshake.py @@ -197,7 +197,6 @@ def _handshake_error(self, key_id, error): short_node_id(key_id), error) self._block_peer(key_id) self._finalize_handshake(key_id) - self.task_server.task_computer.session_closed() self.dropped() # ######################## diff --git a/golem/rpc/api/ethereum_.py b/golem/rpc/api/ethereum_.py new file mode 100644 index 0000000000..60b761b02f --- /dev/null +++ b/golem/rpc/api/ethereum_.py @@ -0,0 +1,125 @@ +"""Ethereum related module with procedures exposed by RPC""" + +import datetime +import functools +import typing + +from golem_messages.datastructures import p2p as dt_p2p + +from golem.core import common +from golem.network import nodeskeeper +from golem.rpc import utils as rpc_utils + +if typing.TYPE_CHECKING: + # pylint: disable=unused-import + from golem import model + from golem.ethereum.transactionsystem import TransactionSystem + + +def lru_node_factory(): + # Our version of peewee (2.10.2) doesn't support + # .join(attr='XXX'). So we'll have to join manually + lru_node = functools.lru_cache()(nodeskeeper.get) + + def _inner(node_id): + if node_id is None: + return None + node = lru_node(node_id) + if node is None: + node = dt_p2p.Node(key=node_id) + return node.to_dict() + return _inner + + +class ETSProvider: + """Provides ethereum related remote procedures that require ETS""" + + def __init__(self, ets: 'TransactionSystem'): + self.ets = ets + + @rpc_utils.expose('pay.payments') + def get_payments_list( + self, + num: typing.Optional[int] = None, + last_seconds: typing.Optional[int] = None, + ) -> typing.List[typing.Dict[str, typing.Any]]: + interval = None + if last_seconds is not None: + interval = datetime.timedelta(seconds=last_seconds) + lru_node = lru_node_factory() + payments = self.ets.get_payments_list(num, interval) + for payment in payments: + payment['node'] = lru_node(payment['node']) + return payments + + @rpc_utils.expose('pay.incomes') + def get_incomes_list(self) -> typing.List[typing.Dict[str, typing.Any]]: + incomes = self.ets.get_incomes_list() + + lru_node = lru_node_factory() + + def item(o): + return { + "subtask": common.to_unicode(o.subtask), + "payer": common.to_unicode(o.node), + "value": common.to_unicode(o.wallet_operation.amount), + "status": common.to_unicode(o.wallet_operation.status.name), + "transaction": common.to_unicode(o.wallet_operation.tx_hash), + "created": common.datetime_to_timestamp_utc(o.created_date), + "modified": common.datetime_to_timestamp_utc(o.modified_date), + "node": lru_node(o.node), + } + + return [item(income) for income in incomes] + + @rpc_utils.expose('pay.gas_price') + def get_gas_price(self) -> typing.Dict[str, str]: + return { + "current_gas_price": str(self.ets.gas_price), + "gas_price_limit": str(self.ets.gas_price_limit) + } + + @rpc_utils.expose('pay.ident') + def get_payment_address(self) -> str: + return self.ets.get_payment_address() + + @rpc_utils.expose('pay.deposit_payments') + def get_deposit_payments_list( + self, + limit=1000, + offset=0, + ) -> typing.List[typing.Dict[str, typing.Any]]: + operations: 'typing.List[model.WalletOperation]' = \ + self.ets.get_deposit_payments_list( + limit=limit, + offset=offset, + ) + result = [] + for dpayment in operations: + entry = {} + entry['value'] = common.to_unicode(dpayment.amount) + entry['status'] = common.to_unicode( + dpayment.status.name, + ) + entry['fee'] = common.to_unicode( + dpayment.gas_cost, + ) + entry['transaction'] = common.to_unicode( + dpayment.tx_hash, + ) + entry['created'] = common.datetime_to_timestamp_utc( + dpayment.created_date, + ) + entry['modified'] = common.datetime_to_timestamp_utc( + dpayment.modified_date, + ) + result.append(entry) + return result + + @rpc_utils.expose('pay.deposit.relock') + def concent_relock(self) -> None: + self.ets.concent_relock() + + @rpc_utils.expose('pay.deposit.unlock') + def concent_unlock(self) -> None: + self.ets.concent_unlock() diff --git a/golem/rpc/router.py b/golem/rpc/router.py index a65b204cd6..8375544cf6 100644 --- a/golem/rpc/router.py +++ b/golem/rpc/router.py @@ -1,10 +1,9 @@ +import enum import json import logging import os -from collections import namedtuple from typing import Iterable, Optional -import enum from crossbar.common import checkconfig from twisted.internet.defer import inlineCallbacks @@ -16,16 +15,18 @@ logger = logging.getLogger('golem.rpc.crossbar') -CrossbarRouterOptions = namedtuple( - 'CrossbarRouterOptions', - ['cbdir', 'logdir', 'loglevel', 'argv', 'config'] -) + +@enum.unique +class SerializerType(enum.Enum): + def _generate_next_value_(name, *_): # pylint: disable=no-self-argument + return name + + json = enum.auto() + msgpack = enum.auto() # pylint: disable=too-many-instance-attributes class CrossbarRouter(object): - serializers = ['msgpack'] - @enum.unique class CrossbarRoles(enum.Enum): admin = enum.auto() @@ -37,10 +38,9 @@ def __init__(self, host: Optional[str] = CROSSBAR_HOST, port: Optional[int] = CROSSBAR_PORT, realm: str = CROSSBAR_REALM, - crossbar_log_level: str = 'info', ssl: bool = True, - generate_secrets: bool = False) -> None: - + generate_secrets: bool = False, + crossbar_serializer: Optional[SerializerType] = None) -> None: self.working_dir = os.path.join(datadir, CROSSBAR_DIR) os.makedirs(self.working_dir, exist_ok=True) @@ -53,27 +53,27 @@ def __init__(self, self.address = WebSocketAddress(host, port, realm, ssl) - self.log_level = crossbar_log_level self.node = None self.pubkey = None - self.options = self._build_options() + if crossbar_serializer is None: + crossbar_serializer = SerializerType.msgpack + self.config = self._build_config(self.address, - self.serializers, + [crossbar_serializer.name], self.cert_manager) logger.debug('xbar init with cfg: %s', json.dumps(self.config)) - def start(self, reactor, options=None): + def start(self, reactor): # imports reactor from crossbar.controller.node import Node, default_native_workers - options = options or self.options if self.address.ssl: self.cert_manager.generate_if_needed() - self.node = Node(options.cbdir, reactor=reactor) - self.pubkey = self.node.maybe_generate_key(options.cbdir) + self.node = Node(self.working_dir, reactor=reactor) + self.pubkey = self.node.maybe_generate_key(self.working_dir) workers = default_native_workers() @@ -85,15 +85,6 @@ def start(self, reactor, options=None): def stop(self): yield self.node._controller.shutdown() # noqa # pylint: disable=protected-access - def _build_options(self, argv=None, config=None): - return CrossbarRouterOptions( - cbdir=self.working_dir, - logdir=None, - loglevel=self.log_level, - argv=argv, - config=config - ) - @staticmethod def _users_config(cert_manager: cert.CertificateManager): # configuration for crsb_users with admin priviliges diff --git a/golem/rpc/session.py b/golem/rpc/session.py index 249fdc462b..ff766f3d75 100644 --- a/golem/rpc/session.py +++ b/golem/rpc/session.py @@ -18,7 +18,7 @@ from golem.rpc.common import X509_COMMON_NAME from golem.rpc import utils as rpc_utils -logger = logging.getLogger('golem.rpc') +logger = logging.getLogger(__name__) OPEN_HANDSHAKE_TIMEOUT = 30. @@ -157,16 +157,17 @@ def cleanup(proto): def onConnect(self): if self.crsb_user and self.crsb_user_secret: - logger.info(f"Client connected. Starting WAMP-Ticket " - f"authentication on realm {self.config.realm} " - f"as crsb_user {self.crsb_user}") + logger.info("Client connected, starting WAMP-Ticket challenge.") + logger.debug("crsb_user=%r, realm=%r, ", + self.crsb_user, self.config.realm) self.join(self.config.realm, ["wampcra"], self.crsb_user.name) else: logger.info("Attempting to log in as anonymous") def onChallenge(self, challenge): if challenge.method == "wampcra": - logger.info(f"WAMP-Ticket challenge received: {challenge}") + logger.info(f"WAMP-Ticket challenge received.") + logger.debug("challenge=%r", challenge) signature = auth.compute_wcs(self.crsb_user_secret.encode('utf8'), challenge.extra['challenge'].encode('utf8')) # noqa # pylint: disable=line-too-long return signature.decode('ascii') diff --git a/golem/rpc/utils.py b/golem/rpc/utils.py index b683beb5ee..a8f4e1fd5d 100644 --- a/golem/rpc/utils.py +++ b/golem/rpc/utils.py @@ -40,3 +40,17 @@ def predicate(member): continue mapping[uri] = method return mapping + + +def int_to_string(item): + # Deeply convert all integer elements of collections to string + if isinstance(item, list): + for k, v in enumerate(item): + item[k] = int_to_string(v) + elif isinstance(item, dict): + for k, v in item.items(): + item[k] = int_to_string(v) + elif isinstance(item, int): + item = str(item) + + return item diff --git a/golem/task/benchmarkmanager.py b/golem/task/benchmarkmanager.py index e63743cc09..e794f37b69 100644 --- a/golem/task/benchmarkmanager.py +++ b/golem/task/benchmarkmanager.py @@ -65,6 +65,8 @@ def error_callback(err: Union[str, Exception]): self.dir_manager) logger.info(builder) task = builder.build() + task.initialize(builder.dir_manager) + br = BenchmarkRunner( task=task, root_path=self.dir_manager.root_path, diff --git a/golem/task/rpc.py b/golem/task/rpc.py index 91868c6204..4ad71396e6 100644 --- a/golem/task/rpc.py +++ b/golem/task/rpc.py @@ -17,13 +17,17 @@ from apps.rendering.task.renderingtask import RenderingTask from golem.core import golem_async from golem.core import common -from golem.core import deferred as golem_deferred from golem.core import simpleserializer +from golem.core.deferred import DeferredSeq from golem.ethereum import exceptions as eth_exceptions +from golem.model import Actor from golem.resource import resource from golem.rpc import utils as rpc_utils from golem.task import taskbase, taskkeeper, taskstate, tasktester +if typing.TYPE_CHECKING: + from golem.client import Client # noqa pylint: disable=unused-import + logger = logging.getLogger(__name__) TASK_NAME_RE = re.compile(r"(\w|[\-\. ])+$") @@ -71,7 +75,7 @@ def _validate_task_dict(client, task_dict) -> None: subtasks_count=subtasks_count, optimize_total=False, use_frames=options.get('frame_count', 1) > 1, - frames=[None]*options.get('frame_count', 1), + frames=[None] * options.get('frame_count', 1), ) if computed_subtasks != subtasks_count: raise ValueError( @@ -153,39 +157,54 @@ def on_error(*args, **kwargs): client.task_tester.run() -@golem_async.deferred_run() +def _create_task(client: 'Client', task_dict: dict) -> taskbase.Task: + validate_client(client) + prepare_and_validate_task_dict(client, task_dict) + return client.task_manager.create_task(task_dict) + + +def _prepare_task( + client: 'Client', + task: taskbase.Task, + force: bool +) -> defer.Deferred: + logger.debug('_prepare_task(). dict=%r', task.task_definition.to_dict()) + seq = DeferredSeq() + seq.push(client.task_manager.initialize_task, task) + seq.push(enqueue_new_task, client, task, force=force) + return seq.execute() + + def _restart_subtasks( - client, - old_task_id, - task_dict, - subtask_ids_to_copy, - force, + client: 'Client', + old_task_id: str, + task_dict: dict, + subtask_ids_to_copy: typing.Iterable[str], + ignore_gas_price: bool = False, ): - @defer.inlineCallbacks - @safe_run( - lambda e: logger.error( - 'Restarting subtasks_failed. task_dict=%r, subtask_ids_to_copy=%r', - task_dict, - subtask_ids_to_copy, - ), - ) - def deferred(): - new_task = yield enqueue_new_task( - client=client, - task=client.task_manager.create_task(task_dict), - force=force, - ) + new_task = _create_task(client, task_dict) + def _copy_results(*_): client.task_manager.copy_results( old_task_id=old_task_id, new_task_id=new_task.header.task_id, subtask_ids_to_copy=subtask_ids_to_copy ) - # Function passed to twisted.threads.deferToThread can't itself - # return a deferred, that's why I defined inner deferred function - # and use sync_wait below. - validate_client(client) - golem_deferred.sync_wait(deferred()) + + # Fire and forget the next steps after create_task + deferred = _prepare_task( + client=client, + task=new_task, + force=ignore_gas_price) + deferred.addErrback( + lambda failure: _restart_subtasks_error( + e=failure.value, + _self=None, + task_id=new_task.header.task_id, + subtask_ids=subtask_ids_to_copy + ) + ) + deferred.addCallback(_copy_results) @defer.inlineCallbacks @@ -300,7 +319,7 @@ def add_resources(client, resources, res_id, timeout): @defer.inlineCallbacks -def _inform_subsystems(client, task): +def _setup_task_resources(client, task): task_id = task.header.task_id if client.config_desc.net_masking_enabled: @@ -347,7 +366,7 @@ def _start_task(client, task, resource_server_result): @defer.inlineCallbacks def enqueue_new_task(client, task, force=False) \ - -> typing.Generator[defer.Deferred, typing.Any, taskbase.Task]: + -> typing.Generator[defer.Deferred, typing.Any, taskbase.Task]: """Feed a fresh Task to all golem subsystems""" validate_client(client) task_id = task.header.task_id @@ -357,14 +376,14 @@ def enqueue_new_task(client, task, force=False) \ task.get_total_tasks(), task.header.deadline, ) - logger.info('Enqueue new task %r', task) + logger.debug('Enqueue new task. task_id=%r', task) - resource_server_result = yield _inform_subsystems( + resource_server_result = yield _setup_task_resources( client=client, task=task, ) - logger.info("Task created. task_id=%r", task_id) + logger.debug("Task resources created. task_id=%r", task_id) try: yield _ensure_task_deposit( @@ -379,7 +398,7 @@ def enqueue_new_task(client, task, force=False) \ resource_server_result=resource_server_result, ) - logger.info("Task enqueued. task_id=%r", task_id) + logger.info("Task started. task_id=%r", task_id) except eth_exceptions.EthereumError as e: logger.error( "Can't enqueue_new_task. task_id=%(task_id)r, e=%(e_name)s: %(e)s", @@ -396,21 +415,37 @@ def enqueue_new_task(client, task, force=False) \ return task -def _create_task_error(e, _self, task_dict, **_kwargs) \ +def _create_task_error(e, _self, task_dict, *args, **_kwargs) \ -> typing.Tuple[None, typing.Union[str, typing.Dict]]: - logger.error("Cannot create task %r: %s", task_dict, e) + _self.client.task_manager.task_creation_failed(task_dict.get('id'), str(e)) if hasattr(e, 'to_dict'): - return None, e.to_dict() + return None, rpc_utils.int_to_string(e.to_dict()) return None, str(e) -def _restart_task_error(e, _self, task_id, **_kwargs): +def _restart_task_error(e, _self, task_id, *args, **_kwargs) \ + -> typing.Tuple[None, str]: logger.error("Cannot restart task %r: %s", task_id, e) + + if hasattr(e, 'to_dict'): + return None, rpc_utils.int_to_string(e.to_dict()) + return None, str(e) +def _restart_subtasks_error(e, _self, task_id, subtask_ids, *_args, **_kwargs) \ + -> typing.Union[str, typing.Dict]: + logger.error("Failed to restart subtasks. task_id: %r, subtask_ids: %r, %s", + task_id, subtask_ids, e) + + if hasattr(e, 'to_dict'): + return rpc_utils.int_to_string(e.to_dict()) + + return str(e) + + def _test_task_error(e, self, task_dict, **_kwargs): logger.error("Test task error: %s", e) logger.debug("Test task details. task_dict=%s", task_dict) @@ -442,47 +477,54 @@ def create_task(self, task_dict, force=False) \ :return: (task_id, None) on success; (task_id or None, error_message) on failure """ - validate_client(self.client) - prepare_and_validate_task_dict(self.client, task_dict) - - task: taskbase.Task = self.task_manager.create_task(task_dict) - self._validate_enough_funds_to_pay_for_task(task, force) + logger.info('Creating task. task_dict=%r', task_dict) + logger.debug('force=%r', force) + + task = _create_task(self.client, task_dict) + self._validate_enough_funds_to_pay_for_task( + task.subtask_price, + task.get_total_tasks(), + task.header.concent_enabled, + force + ) task_id = task.header.task_id - deferred = enqueue_new_task(self.client, task, force=force) - # We want to return quickly from create_task without waiting for - # deferred completion. - deferred.addErrback( # pylint: disable=no-member + # Fire and forget the next steps after create_task + deferred = _prepare_task(client=self.client, task=task, force=force) + deferred.addErrback( lambda failure: _create_task_error( e=failure.value, _self=self, task_dict=task_dict, force=force - ), + ) ) + return task_id, None def _validate_enough_funds_to_pay_for_task( - self, task: taskbase.Task, force: bool + self, + subtask_price: int, + subtask_count: int, + concent_enabled: bool, + force: bool ): - self._validate_lock_funds_possibility( - total_price_gnt=task.price, - number_of_tasks=task.get_total_tasks(), - ) - min_amount, _ = msg_helpers.requestor_deposit_amount(task.price) - concent_enabled = task.header.concent_enabled + self._validate_lock_funds_possibility(subtask_price, subtask_count) + concent_available = self.client.concent_service.available if concent_enabled and concent_available: + min_amount, _ = msg_helpers.requestor_deposit_amount(subtask_price) self.client.transaction_system.validate_concent_deposit_possibility( required=min_amount, - tasks_num=task.get_total_tasks(), + tasks_num=subtask_count, force=force, ) def _validate_lock_funds_possibility( self, - total_price_gnt: int, - number_of_tasks: int) -> None: + subtask_price: int, + subtask_count: int) -> None: + total_price_gnt: int = subtask_price * subtask_count transaction_system = self.client.transaction_system missing_funds: typing.List[eth_exceptions.MissingFunds] = [] @@ -494,7 +536,7 @@ def _validate_lock_funds_possibility( currency='GNT' )) - eth = transaction_system.eth_for_batch_payment(number_of_tasks) + eth = transaction_system.eth_for_batch_payment(subtask_count) eth_available = transaction_system.get_available_eth() if eth > eth_available: missing_funds.append(eth_exceptions.MissingFunds( @@ -508,13 +550,18 @@ def _validate_lock_funds_possibility( @rpc_utils.expose('comp.task.restart') @safe_run(_restart_task_error) - def restart_task(self, task_id: str, force: bool = False) \ - -> typing.Tuple[typing.Optional[str], typing.Optional[str]]: + def restart_task( + self, + task_id: str, + force: bool = False, + disable_concent: bool = False + ) -> typing.Tuple[typing.Optional[str], typing.Optional[str]]: """ :return: (new_task_id, None) on success; (None, error_message) on failure """ logger.info('Restarting task. task_id=%r', task_id) + logger.debug('force=%r, disable_concent=%r', force, disable_concent) # Task state is changed to restarted and stays this way until it's # deleted from task manager. @@ -526,6 +573,14 @@ def restart_task(self, task_id: str, force: bool = False) \ # Create new task that is a copy of the definition of the old one. # It has a new deadline and a new task id. try: + task = self.task_manager.tasks[task_id] + self._validate_enough_funds_to_pay_for_task( + task.subtask_price, + task.get_total_tasks(), + False if disable_concent else task.header.concent_enabled, + force + ) + task_dict = copy.deepcopy( self.task_manager.get_task_definition_dict( self.task_manager.tasks[task_id], @@ -534,24 +589,87 @@ def restart_task(self, task_id: str, force: bool = False) \ except KeyError: return None, "Task not found: '{}'".format(task_id) - task_dict.pop('id', None) - prepare_and_validate_task_dict(self.client, task_dict) - new_task = self.task_manager.create_task(task_dict) - validate_client(self.client) - enqueue_new_task( # pylint: disable=no-member - client=self.client, - task=new_task, - force=force, - ).addErrback( + del task_dict['id'] + if disable_concent: + task_dict['concent_enabled'] = False + + new_task = _create_task(self.client, task_dict) + # Fire and forget the next steps after create_task + deferred = _prepare_task(client=self.client, task=new_task, force=force) + deferred.addErrback( lambda failure: _restart_task_error( e=failure.value, _self=self, task_id=task_id, - ), + ) ) self.task_manager.put_task_in_restarted_state(task_id) return new_task.header.task_id, None + @rpc_utils.expose('comp.task.subtasks.restart') + @safe_run(_restart_subtasks_error) + def restart_subtasks( + self, + task_id: str, + subtask_ids: typing.List[str], + ignore_gas_price: bool = False, + disable_concent: bool = False + ) -> typing.Optional[typing.Union[str, typing.Dict]]: + """ + Restarts a set of subtasks from the given task. If the specified task is + already finished, all failed subtasks will be restarted along with the + set provided as a parameter. Finished subtasks will have their results + copied over to the newly created task. + :param task_id: the ID of the task which contains the given subtasks. + :param subtask_ids: the set of subtask IDs which should be restarted. + If this is empty and the task is finished, all of the task's subtasks + marked as failed will be restarted. + :param ignore_gas_price: if True, this will ignore long transaction time + errors and proceed with the restart. + :param disable_concent: setting this flag to True will result in forcing + Concent to be disabled for the task. This only has effect when the task + is already finished and needs to be restarted. + :return: In case of any errors, returns the representation of the error + (either a string or a dict). Otherwise, returns None. + """ + task = self.task_manager.tasks.get(task_id) + if not task: + return f'Task not found: {task_id!r}' + + subtasks_to_restart = set(subtask_ids) + + for sub_id in subtasks_to_restart: + if self.task_manager.subtask_to_task( + sub_id, Actor.Requestor) != task_id: + return f'Subtask does not belong to the given task.' \ + f'task_id: {task_id}, subtask_id: {sub_id}' + + logger.info('Restarting subtasks. task_id=%r', task_id) + logger.debug('subtask_ids=%r, ignore_gas_price=%r, disable_concent=%r', + subtask_ids, ignore_gas_price, disable_concent) + + task_state = self.client.task_manager.tasks_states[task_id] + + if task_state.status.is_active(): + self._validate_enough_funds_to_pay_for_task( + task.subtask_price, + len(subtask_ids), + False if disable_concent else task.header.concent_enabled, + ignore_gas_price + ) + + for subtask_id in subtask_ids: + self.client.restart_subtask(subtask_id) + else: + return self._restart_finished_task_subtasks( + task_id, + subtask_ids, + ignore_gas_price, + disable_concent + ) + + return None + @rpc_utils.expose('comp.task.subtasks.frame.restart') @safe_run( lambda e, _self, task_id, frame: logger.error( @@ -563,77 +681,83 @@ def restart_frame_subtasks( self, task_id: str, frame: int - ): + ) -> typing.Optional[typing.Union[str, typing.Dict]]: logger.debug('restart_frame_subtasks. task_id=%r, frame=%r', task_id, frame) - frame_subtasks: typing.Dict[str, dict] =\ + frame_subtasks: typing.FrozenSet[str] =\ self.task_manager.get_frame_subtasks(task_id, frame) if not frame_subtasks: logger.error('Frame restart failed, frame has no subtasks.' 'task_id=%r, frame=%r', task_id, frame) - return + return None - task_state = self.client.task_manager.tasks_states[task_id] + return self.restart_subtasks(task_id, list(frame_subtasks)) - if task_state.status.is_active(): - for subtask_id in frame_subtasks: - self.client.restart_subtask(subtask_id) - else: - self.restart_subtasks_from_task(task_id, frame_subtasks) - - @rpc_utils.expose('comp.task.restart_subtasks') - @safe_run( - lambda e, _self, task_id, subtask_ids: logger.error( - 'Task restart failed. e=%s, task_id=%s subtask_ids=%s', - e, task_id, subtask_ids - ), - ) - def restart_subtasks_from_task( + @safe_run(_restart_subtasks_error) + def _restart_finished_task_subtasks( self, task_id: str, subtask_ids: typing.Iterable[str], - force: bool = False, - ): - logger.debug('restart_subtasks_from_task. task_id=%r, subtask_ids=%r,' - 'force=%r', task_id, subtask_ids, force) + ignore_gas_price: bool = False, + disable_concent: bool = False + ) -> typing.Optional[typing.Union[str, typing.Dict]]: + logger.debug('_restart_finished_task_subtasks. task_id=%r, ' + 'subtask_ids=%r, ignore_gas_price=%r', task_id, + subtask_ids, ignore_gas_price) try: - self.task_manager.put_task_in_restarted_state( - task_id, - clear_tmp=False, - ) old_task = self.task_manager.tasks[task_id] + finished_subtask_ids = set( sub_id for sub_id, sub in old_task.subtasks_given.items() if sub['status'] == taskstate.SubtaskStatus.finished ) subtask_ids_to_copy = finished_subtask_ids - set(subtask_ids) - logger.debug('restart_subtasks_from_task. subtask_ids_to_copy=%r', - subtask_ids_to_copy) + + self._validate_enough_funds_to_pay_for_task( + old_task.subtask_price, + old_task.get_total_tasks() - len(subtask_ids_to_copy), + False if disable_concent else old_task.header.concent_enabled, + ignore_gas_price + ) + + self.task_manager.put_task_in_restarted_state( + task_id, + clear_tmp=False, + ) + + logger.debug('_restart_finished_task_subtasks. ' + 'subtask_ids_to_copy=%r', subtask_ids_to_copy) except self.task_manager.AlreadyRestartedError: - logger.error('Task already restarted: %r', task_id) - return + err_msg = f'Task already restarted: {task_id!r}' + logger.error(err_msg) + return err_msg except KeyError: - logger.error('Task not found: %r', task_id) - return + err_msg = f'Task not found: {task_id!r}' + logger.error(err_msg) + return err_msg task_dict = copy.deepcopy( self.task_manager.get_task_definition_dict(old_task), ) del task_dict['id'] - logger.debug('Restarting task. task_dict=%s', task_dict) - prepare_and_validate_task_dict(self.client, task_dict) + if disable_concent: + task_dict['concent_enabled'] = False + + logger.debug('_restart_finished_task_subtasks. task_dict=%s', task_dict) _restart_subtasks( client=self.client, subtask_ids_to_copy=subtask_ids_to_copy, old_task_id=task_id, task_dict=task_dict, - force=force, + ignore_gas_price=ignore_gas_price, ) # Don't wait for deferred + return None + @rpc_utils.expose('comp.tasks.check') @safe_run(_test_task_error) def run_test_task(self, task_dict) -> bool: @@ -645,8 +769,8 @@ def run_test_task(self, task_dict) -> bool: } return False - self.client.task_test_result = None prepare_and_validate_task_dict(self.client, task_dict) + self.client.task_test_result = None _run_test_task( client=self.client, task_dict=task_dict, @@ -654,6 +778,49 @@ def run_test_task(self, task_dict) -> bool: # Don't wait for _deferred return True + @rpc_utils.expose('comp.task.subtasks.estimated.cost') + def get_estimated_subtasks_cost( + self, + task_id: str, + subtask_ids: typing.List[str] + ) -> typing.Tuple[typing.Optional[dict], typing.Optional[str]]: + """ + Estimates the cost of restarting an array of subtasks from a given task. + If the specified task is finished, all of the failed subtasks from that + task will be added to the estimation. + :param task_id: ID of the task containing the subtasks to be restarted. + :param subtask_ids: a list of subtask IDs which should be restarted. If + one of the subtasks does not belong to the given task, an error will be + returned. + :return: a result, error tuple. When the result is present the error + should be None (and vice-versa). + """ + task = self.task_manager.tasks.get(task_id) + if not task: + return None, f'Task not found: {task_id}' + + subtasks_to_restart = set(subtask_ids) + + for sub_id in subtasks_to_restart: + if self.task_manager.subtask_to_task( + sub_id, Actor.Requestor) != task_id: + return None, f'Subtask does not belong to the given task.' \ + f'task_id: {task_id}, subtask_id: {sub_id}' + + if self.task_manager.task_finished(task_id): + failed_subtask_ids = set( + sub_id for sub_id, subtask in task.subtasks_given.items() + if subtask['status'] == taskstate.SubtaskStatus.failure + ) + subtasks_to_restart |= failed_subtask_ids + + result = self._get_cost_estimation( + len(subtasks_to_restart), + task.subtask_price + ) + + return result, None + @rpc_utils.expose('comp.tasks.estimated.cost') def get_estimated_cost( self, @@ -706,6 +873,12 @@ def get_estimated_cost( computation_time=subtask_timeout ) + result = self._get_cost_estimation(subtask_count, subtask_price) + + logger.info('Estimated task cost. result=%r', result) + return result, None + + def _get_cost_estimation(self, subtask_count: int, subtask_price: int): estimated_gnt: int = subtask_count * subtask_price estimated_eth: int = self.client \ .transaction_system.eth_for_batch_payment(subtask_count) @@ -714,7 +887,7 @@ def get_estimated_cost( estimated_deposit_eth: int = self.client.transaction_system \ .eth_for_deposit() - result = { + return { 'GNT': str(estimated_gnt), 'ETH': str(estimated_eth), 'deposit': { @@ -724,15 +897,11 @@ def get_estimated_cost( }, } - logger.info('Estimated task cost. result=%r', result) - return result, None - @rpc_utils.expose('comp.task.rendering.task_fragments') def get_fragments(self, task_id: str) -> \ - typing.Tuple[ - typing.Optional[typing.Dict[int, typing.List[typing.Dict]]], - typing.Optional[str] - ]: + typing.Tuple[ + typing.Optional[typing.Dict[int, typing.List[typing.Dict]]], + typing.Optional[str]]: """ Returns the task fragments for a given rendering task. A single task fragment is a collection of subtasks referring to the same, common part diff --git a/golem/task/server/helpers.py b/golem/task/server/helpers.py index 1e8b70aea3..dbf72df21e 100644 --- a/golem/task/server/helpers.py +++ b/golem/task/server/helpers.py @@ -123,7 +123,6 @@ def send_report_computed_task(task_server, waiting_task_result) -> None: report_computed_task = message.tasks.ReportComputedTask( task_to_compute=task_to_compute, - node_name=my_node.node_name, address=my_node.prv_addr, port=task_server.cur_port, key_id=my_node.key, @@ -136,16 +135,18 @@ def send_report_computed_task(task_server, waiting_task_result) -> None: options=client_options.__dict__, ) + signed_report_computed_task = msg_utils.copy_and_sign( + msg=report_computed_task, + private_key=task_server.keys_auth._private_key, # noqa pylint: disable=protected-access + ) + msg_queue.put( waiting_task_result.owner.key, report_computed_task, ) - report_computed_task = msg_utils.copy_and_sign( - msg=report_computed_task, - private_key=task_server.keys_auth._private_key, # noqa pylint: disable=protected-access - ) + history.add( - msg=report_computed_task, + msg=signed_report_computed_task, node_id=waiting_task_result.owner.key, local_role=model.Actor.Provider, remote_role=model.Actor.Requestor, @@ -176,7 +177,7 @@ def send_report_computed_task(task_server, waiting_task_result) -> None: # cancelled and thus, never sent to the Concent. delayed_forcing_msg = message.concents.ForceReportComputedTask( - report_computed_task=report_computed_task, + report_computed_task=signed_report_computed_task, result_hash='sha1:' + waiting_task_result.package_sha1 ) logger.debug('[CONCENT] ForceReport: %s', delayed_forcing_msg) diff --git a/golem/task/server/resources.py b/golem/task/server/resources.py index dacf026cba..d068b34e73 100644 --- a/golem/task/server/resources.py +++ b/golem/task/server/resources.py @@ -282,7 +282,18 @@ def _share_handshake_nonce(self, key_id): self.NONCE_TASK, client_options=options, async_=True) + + def handshake_error(exc): + session = self.sessions.get(key_id) + if not session: + logger.info( + 'Session not found. node=%s', + common.short_node_id(key_id), + ) + return + session._handshake_error(key_id, exc) # noqa pylint:disable=protected-access + deferred.addCallbacks( lambda res: self._nonce_shared(key_id, res, options), - lambda exc: self._handshake_error(key_id, exc) + handshake_error, ) diff --git a/golem/task/server/verification.py b/golem/task/server/verification.py index c0eecb70f3..41763b99f4 100644 --- a/golem/task/server/verification.py +++ b/golem/task/server/verification.py @@ -5,6 +5,8 @@ from golem_messages import utils as msg_utils from golem_messages.datastructures import p2p as dt_p2p +from apps.core.task.coretaskstate import RunVerification + from golem import model from golem.core import common from golem.network import history @@ -18,6 +20,7 @@ logger = logging.getLogger(__name__) + class VerificationMixin: keys_auth: 'keysauth.KeysAuth' task_manager: 'taskmanager.TaskManager' @@ -42,14 +45,27 @@ def verify_results( def verification_finished(): logger.debug("Verification finished handler.") - if not self.task_manager.verify_subtask(subtask_id): - logger.debug("Verification failure. subtask_id=%r", subtask_id) - self.send_result_rejected( - report_computed_task=report_computed_task, - reason=message.tasks.SubtaskResultsRejected.REASON - .VerificationNegative - ) - return + + task_id = self.task_manager.subtask_to_task( + subtask_id, model.Actor.Requestor) + is_verification_lenient = ( + self.task_manager.tasks[task_id] + .task_definition.run_verification == RunVerification.lenient) + + verification_failed = \ + not self.task_manager.verify_subtask(subtask_id) + if verification_failed: + if not is_verification_lenient: + logger.debug("Verification failure. subtask_id=%r", + subtask_id) + self.send_result_rejected( + report_computed_task=report_computed_task, + reason=message.tasks.SubtaskResultsRejected.REASON + .VerificationNegative + ) + return + logger.info("Verification failed, but I'm paying anyway." + " subtask_id=%s", subtask_id) task_to_compute = report_computed_task.task_to_compute @@ -70,23 +86,29 @@ def verification_finished(): timeout_seconds=config_desc.disallow_ip_timeout_seconds, ) - payment_processed_ts = self.accept_result( + payment = self.accept_result( subtask_id, report_computed_task.provider_id, task_to_compute.provider_ethereum_address, task_to_compute.price, + unlock_funds=not (verification_failed + and is_verification_lenient), ) response_msg = message.tasks.SubtaskResultsAccepted( report_computed_task=report_computed_task, - payment_ts=payment_processed_ts, + payment_ts=int(payment.created_date.timestamp()), + ) + + signed_response_msg = msg_utils.copy_and_sign( + msg=response_msg, + private_key=self.keys_auth._private_key, # noqa pylint: disable=protected-access ) + msg_queue.put(node.key, response_msg) + history.add( - msg_utils.copy_and_sign( - msg=response_msg, - private_key=self.keys_auth._private_key, # noqa pylint: disable=protected-access - ), + signed_response_msg, node_id=task_to_compute.provider_id, local_role=model.Actor.Requestor, remote_role=model.Actor.Provider, @@ -129,14 +151,16 @@ def send_result_rejected( report_computed_task=report_computed_task, reason=reason, ) - msg_queue.put(node.key, response_msg) - response_msg = msg_utils.copy_and_sign( + signed_response_msg = msg_utils.copy_and_sign( msg=response_msg, private_key=self.keys_auth._private_key, # noqa pylint: disable=protected-access ) + + msg_queue.put(node.key, response_msg) + history.add( - response_msg, + signed_response_msg, node_id=report_computed_task.task_to_compute.provider_id, local_role=model.Actor.Requestor, remote_role=model.Actor.Provider, diff --git a/golem/task/taskbase.py b/golem/task/taskbase.py index 9b54303ec9..58e6708013 100644 --- a/golem/task/taskbase.py +++ b/golem/task/taskbase.py @@ -255,7 +255,7 @@ def get_progress(self) -> float: pass # Implement in derived class def get_resources(self) -> list: - """ Return list of files that are need to compute this task.""" + """ Return list of files that are needed to compute this task.""" return [] @abc.abstractmethod diff --git a/golem/task/taskcomputer.py b/golem/task/taskcomputer.py index e9b688148d..51a02e3c55 100644 --- a/golem/task/taskcomputer.py +++ b/golem/task/taskcomputer.py @@ -1,6 +1,6 @@ import logging from pathlib import Path -from typing import Any, Dict, Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING import os import time @@ -8,7 +8,7 @@ from threading import Lock from pydispatch import dispatcher -from twisted.internet.defer import Deferred, TimeoutError +from twisted.internet.defer import Deferred, TimeoutError, inlineCallbacks from golem.clientconfigdescriptor import ClientConfigDescriptor from golem.core.common import deadline_to_timeout @@ -17,6 +17,8 @@ from golem.docker.image import DockerImage from golem.docker.manager import DockerManager from golem.docker.task_thread import DockerTaskThread +from golem.envs.docker.cpu import DockerCPUConfig, DockerCPUEnvironment +from golem.hardware import scale_memory, MemSize from golem.manager.nodestatesnapshot import ComputingSubtaskStateSnapshot from golem.resource.dirmanager import DirManager from golem.task.timer import ProviderTimer @@ -51,25 +53,31 @@ class TaskComputer(object): lock = Lock() dir_lock = Lock() - def __init__(self, task_server: 'TaskServer', use_docker_manager=True, - finished_cb=None) -> None: + def __init__( + self, + task_server: 'TaskServer', + docker_cpu_env: DockerCPUEnvironment, + use_docker_manager=True, + finished_cb=None + ) -> None: self.task_server = task_server # Currently computing TaskThread self.counting_thread = None # Is task computer currently able to run computation? self.runnable = True self.listeners = [] - self.last_task_request = time.time() self.dir_manager: DirManager = DirManager( task_server.get_task_computer_root()) - self.task_request_frequency = None self.docker_manager: DockerManager = DockerManager.install() if use_docker_manager: - self.docker_manager.check_environment() - + self.docker_manager.check_environment() # pylint: disable=no-member self.use_docker_manager = use_docker_manager + + self.docker_cpu_env = docker_cpu_env + sync_wait(self.docker_cpu_env.prepare()) + run_benchmarks = self.task_server.benchmark_manager.benchmarks_needed() deferred = self.change_config( task_server.config_desc, in_background=False, @@ -81,57 +89,35 @@ def __init__(self, task_server: 'TaskServer', use_docker_manager=True, self.stats = IntStatsKeeper(CompStats) - self.assigned_subtask: Optional['ComputeTaskDef'] = None + # So apparently it is perfectly fine for mypy to assign None to a + # non-optional variable. And if I tried Optional['ComputeTaskDef'] + # then I would get "Optional[Any] is not indexable" error. + # Get your sh*t together, mypy! + self.assigned_subtask: 'ComputeTaskDef' = None - self.last_task_timeout_checking = None self.support_direct_computation = False # Should this node behave as provider and compute tasks? self.compute_tasks = task_server.config_desc.accept_tasks \ and not task_server.config_desc.in_shutdown self.finished_cb = finished_cb - def task_given(self, ctd: 'ComputeTaskDef'): - if self.assigned_subtask is not None: - logger.error("Trying to assign a task, when it's already assigned") - return False - - ProviderTimer.start() - + def task_given(self, ctd: 'ComputeTaskDef') -> None: + assert self.assigned_subtask is None self.assigned_subtask = ctd - self.__request_resource( - ctd['task_id'], - ctd['subtask_id'], - ctd['resources'], - ) - return True + ProviderTimer.start() def has_assigned_task(self) -> bool: return bool(self.assigned_subtask) - def resource_collected(self, res_id): - subtask = self.assigned_subtask - if not subtask or subtask['task_id'] != res_id: - logger.error("Resource collected for a wrong task, %s", res_id) - return False - self.last_task_timeout_checking = time.time() - self.__compute_task( - subtask['subtask_id'], - subtask['docker_images'], - subtask['extra_data'], - subtask['deadline']) - return True - - def resource_failure(self, res_id, reason): - subtask = self.assigned_subtask - if not subtask or subtask['task_id'] != res_id: - logger.error("Resource failure for a wrong task, %s", res_id) - return - self.task_server.send_task_failed( - subtask['subtask_id'], - subtask['task_id'], - 'Error downloading resources: {}'.format(reason), - ) - self.__task_finished(subtask) + @property + def assigned_task_id(self) -> Optional[str]: + if self.assigned_subtask is None: + return None + return self.assigned_subtask.get('task_id') + + def task_interrupted(self) -> None: + assert self.assigned_subtask is not None + self._task_finished() def task_computed(self, task_thread: TaskThread) -> None: if task_thread.end_time is None: @@ -141,16 +127,18 @@ def task_computed(self, task_thread: TaskThread) -> None: try: subtask = self.assigned_subtask assert subtask is not None - self.assigned_subtask = None subtask_id = subtask['subtask_id'] + task_id = subtask['task_id'] + task_header = self.task_server.task_keeper.task_headers[task_id] # get paid for max working time, # thus task withholding won't make profit - task_header = \ - self.task_server.task_keeper.task_headers[subtask['task_id']] work_time_to_be_paid = task_header.subtask_timeout - except KeyError: - logger.error("No subtask with id %r", subtask_id) + except (KeyError, AssertionError): + logger.error("Task header not found in task keeper. " + "task_id=%r, subtask_id=%r", + task_id, subtask_id) + self._task_finished() return was_success = False @@ -195,30 +183,34 @@ def task_computed(self, task_thread: TaskThread) -> None: dispatcher.send(signal='golem.monitor', event='computation_time_spent', success=was_success, value=work_time_to_be_paid) - self.__task_finished(subtask) + self._task_finished() - def run(self): - """ Main loop of task computer """ + def check_timeout(self): if self.counting_thread is not None: self.counting_thread.check_timeout() - elif self.compute_tasks and self.runnable: - last_request = time.time() - self.last_task_request - if last_request > self.task_request_frequency: - self.__request_task() def get_progress(self) -> Optional[ComputingSubtaskStateSnapshot]: if not self.is_computing() or self.assigned_subtask is None: return None c: TaskThread = self.counting_thread + try: + outfilebasename = c.extra_data.get( # type: ignore + 'crops' + )[0].get( + 'outfilebasename' + ) + except (IndexError, KeyError): + outfilebasename = '' + tcss = ComputingSubtaskStateSnapshot( subtask_id=self.assigned_subtask['subtask_id'], progress=c.get_progress(), seconds_to_timeout=c.task_timeout, running_time_seconds=(time.time() - c.start_time), + outfilebasename=outfilebasename, **c.extra_data, ) - return tcss def is_computing(self) -> bool: @@ -243,11 +235,14 @@ def get_environment(self): return task_header.environment - def change_config(self, config_desc, in_background=True, - run_benchmarks=False): + def change_config( + self, + config_desc: ClientConfigDescriptor, + in_background: bool = True, + run_benchmarks: bool = False + ) -> Deferred: self.dir_manager = DirManager( self.task_server.get_task_computer_root()) - self.task_request_frequency = config_desc.task_request_interval self.compute_tasks = config_desc.accept_tasks \ and not config_desc.in_shutdown return self.change_docker_config( @@ -260,27 +255,41 @@ def config_changed(self): for l in self.listeners: l.config_changed() + @inlineCallbacks def change_docker_config( self, config_desc: ClientConfigDescriptor, run_benchmarks: bool, work_dir: Path, in_background: bool = True - ) -> Optional[Deferred]: + ) -> Deferred: dm = self.docker_manager assert isinstance(dm, DockerManager) dm.build_config(config_desc) - deferred = Deferred() + yield self.docker_cpu_env.clean_up() + self.docker_cpu_env.update_config(DockerCPUConfig( + work_dir=work_dir, + cpu_count=config_desc.num_cores, + memory_mb=scale_memory( + config_desc.max_memory_size, + unit=MemSize.kibi, + to_unit=MemSize.mebi + ) + )) + yield self.docker_cpu_env.prepare() + if not dm.hypervisor and run_benchmarks: + deferred = Deferred() self.task_server.benchmark_manager.run_all_benchmarks( deferred.callback, deferred.errback ) - return deferred + return (yield deferred) if dm.hypervisor and self.use_docker_manager: # noqa pylint: disable=no-member self.lock_config(True) + deferred = Deferred() def status_callback(): return self.is_computing() @@ -305,7 +314,7 @@ def done_callback(config_differs): work_dir=work_dir, in_background=in_background) - return deferred + return (yield deferred) return None @@ -316,21 +325,16 @@ def lock_config(self, on=True): for l in self.listeners: l.lock_config(on) - def __request_task(self): - if self.has_assigned_task(): - return - - self.last_task_request = time.time() - requested_task = self.task_server.request_task() - if requested_task is not None: - self.stats.increase_stat('tasks_requested') + def start_computation(self) -> None: # pylint: disable=too-many-locals + subtask = self.assigned_subtask + assert subtask is not None - def __request_resource(self, task_id, subtask_id, resources): - self.task_server.request_resource(task_id, subtask_id, resources) + task_id = subtask['task_id'] + subtask_id = subtask['subtask_id'] + docker_images = subtask['docker_images'] + extra_data = subtask['extra_data'] + subtask_deadline = subtask['deadline'] - def __compute_task(self, subtask_id, docker_images, - extra_data, subtask_deadline): - task_id = self.assigned_subtask['task_id'] task_header = self.task_server.task_keeper.task_headers.get(task_id) if not task_header: @@ -368,23 +372,25 @@ def __compute_task(self, subtask_id, docker_images, task_timeout) else: logger.error("Cannot run PyTaskThread in this version") - subtask = self.assigned_subtask - self.assigned_subtask = None self.task_server.send_task_failed( subtask_id, - subtask['task_id'], + self.assigned_subtask['task_id'], "Host direct task not supported", ) - self.__task_finished(subtask) + self._task_finished() return with self.lock: self.counting_thread = tt + self.task_server.task_keeper.task_started(task_id) tt.start().addBoth(lambda _: self.task_computed(tt)) - def __task_finished(self, ctd: 'ComputeTaskDef') -> None: + def _task_finished(self) -> None: + ctd = self.assigned_subtask + assert ctd is not None + self.assigned_subtask = None ProviderTimer.finish() dispatcher.send( @@ -396,6 +402,7 @@ def __task_finished(self, ctd: 'ComputeTaskDef') -> None: with self.lock: self.counting_thread = None + self.task_server.task_keeper.task_ended(ctd['task_id']) if self.finished_cb: self.finished_cb() diff --git a/golem/task/taskkeeper.py b/golem/task/taskkeeper.py index 714075c01e..4c8143fb4f 100644 --- a/golem/task/taskkeeper.py +++ b/golem/task/taskkeeper.py @@ -4,11 +4,12 @@ import pickle import time import typing - import random from collections import Counter from eth_utils import decode_hex +from twisted.internet.defer import inlineCallbacks, Deferred + from golem_messages import ( idgenerator, helpers, @@ -20,8 +21,12 @@ from golem.core import common from golem.core import golem_async +from golem.core.deferred import sync_wait from golem.core.variables import NUM_OF_RES_TRANSFERS_NEEDED_FOR_VER from golem.environments.environment import SupportStatus, UnsupportReason +from golem.environments.environmentsmanager import \ + EnvironmentsManager as OldEnvManager +from golem.envs.manager import EnvironmentManager as NewEnvManager from golem.network.hyperdrive.client import HyperdriveClientOptions from golem.task.taskproviderstats import ProviderStatsManager @@ -323,7 +328,9 @@ class TaskHeaderKeeper: def __init__( self, - environments_manager, + old_env_manager: OldEnvManager, + # FIXME: rename to `env_manager` when old env manager is removed + new_env_manager: NewEnvManager, node: dt_p2p.Node, min_price=0.0, remove_task_timeout=180, @@ -334,6 +341,8 @@ def __init__( self.task_headers: typing.Dict[str, dt_tasks.TaskHeader] = {} # ids of tasks that this node may try to compute self.supported_tasks: typing.List[str] = [] + # ids of tasks that are computing on this node + self.running_tasks: typing.Set[str] = set() # results of tasks' support checks self.support_status: typing.Dict[str, SupportStatus] = {} # tasks that were removed from network recently, so they won't @@ -347,12 +356,15 @@ def __init__( self.min_price = min_price self.verification_timeout = verification_timeout self.removed_task_timeout = remove_task_timeout - self.environments_manager = environments_manager + self.old_env_manager = old_env_manager + # FIXME: rename to `env_manager` when old env manager is removed + self.new_env_manager = new_env_manager self.max_tasks_per_requestor = max_tasks_per_requestor self.task_archiver = task_archiver self.node = node - def check_support(self, header: dt_tasks.TaskHeader) -> SupportStatus: + @inlineCallbacks + def check_support(self, header: dt_tasks.TaskHeader) -> Deferred: """Checks if task described with given task header dict may be computed by this node. This node must support proper environment, be allowed to make computation @@ -360,26 +372,71 @@ def check_support(self, header: dt_tasks.TaskHeader) -> SupportStatus: application version. :return SupportStatus: ok() if this node may compute a task """ - supported = self.check_environment(header.environment) + if header.environment_prerequisites: + supported = yield self._check_new_environment( + header.environment, header.environment_prerequisites + ) + else: + supported = self._check_old_environment(header.environment) supported = supported.join(self.check_mask(header)) supported = supported.join(self.check_price(header)) + if not supported.is_ok(): - logger.info("Unsupported task %s, reason: %r", - header.task_id, supported.desc) + if supported.err_reason == UnsupportReason.MASK_MISMATCH: + # as this is a condition where a particular provider's + # configuration is not at fault, it doesn't make sense to + # alert users to this task support "failure" ... + loglevel = logging.DEBUG + else: + loglevel = logging.INFO + + logger.log(loglevel, "Unsupported task %s, reason: %r", + header.task_id, supported.desc) + return supported - def check_environment(self, env: str) -> SupportStatus: - """Checks if this node supports the given environment + def _check_old_environment(self, env: str) -> SupportStatus: + """Checks if this node supports the given (old) environment :param str env: environment :return SupportStatus: ok() if this node support environment for this task, err() otherwise """ status = SupportStatus.ok() - if not self.environments_manager.accept_tasks(env): + if not self.old_env_manager.accept_tasks(env): status = SupportStatus.err( {UnsupportReason.ENVIRONMENT_NOT_ACCEPTING_TASKS: env}) - return self.environments_manager.get_support_status(env).join(status) + return self.old_env_manager.get_support_status(env).join(status) + + @inlineCallbacks + def _check_new_environment( + self, env_id: str, prerequisites_dict: dict) -> Deferred: + """ Check if node supports the given environment. Try to install + the prerequisites. If installation fails the verdict is + 'unsupported'. """ + try: + env = self.new_env_manager.environment(env_id) + except KeyError: + logger.info("Environment '%s' not found.", env_id) + return SupportStatus.err({ + UnsupportReason.ENVIRONMENT_MISSING: env_id + }) + + try: + prerequisites = env.parse_prerequisites(prerequisites_dict) + except ValueError: + logger.info("Parsing prerequisites failed: %r", prerequisites_dict) + return SupportStatus.err({ + UnsupportReason.ENVIRONMENT_UNSUPPORTED: env_id + }) + + installed = yield env.install_prerequisites(prerequisites) + if not installed: + logger.info("Installing prerequisites failed: %r", prerequisites) + return SupportStatus.err({ + UnsupportReason.ENVIRONMENT_UNSUPPORTED: env_id + }) + return SupportStatus.ok() def check_mask(self, header: dt_tasks.TaskHeader) -> SupportStatus: """ Check if ID of this node matches the mask in task header """ @@ -414,7 +471,8 @@ def get_all_tasks(self): """ return list(self.task_headers.values()) - def change_config(self, config_desc): + @inlineCallbacks + def change_config(self, config_desc) -> Deferred: """Change config options, ie. minimal price that this node may offer for computation. If a minimal price didn't change it won't do anything. If it has changed it will try again to check which @@ -426,7 +484,7 @@ def change_config(self, config_desc): self.min_price = config_desc.min_price self.supported_tasks = [] for id_, th in self.task_headers.items(): - supported = self.check_support(th) + supported = yield self.check_support(th) self.support_status[id_] = supported if supported: self.supported_tasks.append(id_) @@ -464,7 +522,7 @@ def add_task_header(self, header: dt_tasks.TaskHeader) -> bool: self._get_tasks_by_owner_set(header.task_owner.key).add(task_id) - self.update_supported_set(header) + sync_wait(self.update_supported_set(header)) self.check_max_tasks_per_owner(header.task_owner.key) @@ -478,10 +536,11 @@ def add_task_header(self, header: dt_tasks.TaskHeader) -> bool: logger.warning("Wrong task header received: {}".format(err)) return False - def update_supported_set(self, header: dt_tasks.TaskHeader) -> None: + @inlineCallbacks + def update_supported_set(self, header: dt_tasks.TaskHeader) -> Deferred: task_id = header.task_id - support = self.check_support(header) + support = yield self.check_support(header) self.support_status[task_id] = support if not support and task_id in self.supported_tasks: @@ -524,18 +583,23 @@ def find_newest_node(self, node_id) -> typing.Optional[dt_p2p.Node]: def check_max_tasks_per_owner(self, owner_key_id): owner_task_set = self._get_tasks_by_owner_set(owner_key_id) - if len(owner_task_set) <= self.max_tasks_per_requestor: + not_running = owner_task_set - self.running_tasks + + if len(not_running) <= self.max_tasks_per_requestor: return - by_age = sorted(owner_task_set, + by_age = sorted(not_running, key=lambda tid: self.last_checking[tid]) # leave alone the first (oldest) max_tasks_per_requestor # headers, remove the rest to_remove = by_age[self.max_tasks_per_requestor:] - logger.warning("Too many tasks from %s, dropping %d tasks", - owner_key_id, len(to_remove)) + logger.debug( + "Limiting tasks for this node, dropping %d tasks. " + "owner=%s, ids_to_remove=%r", + len(to_remove), common.short_node_id(owner_key_id), to_remove + ) for tid in to_remove: self.remove_task_header(tid) @@ -547,6 +611,11 @@ def remove_task_header(self, task_id) -> bool: if task_id in self.removed_tasks: return False + if task_id in self.running_tasks: + logger.warning("Can not remove task header, task is running. " + "task_id=%s", task_id) + return False + try: owner_key_id = self.task_headers[task_id].task_owner.key self.tasks_by_owner[owner_key_id].discard(task_id) @@ -610,8 +679,9 @@ def remove_old_tasks(self): for t in list(self.task_headers.values()): cur_time = common.get_timestamp_utc() if cur_time > t.deadline: - logger.warning("Task owned by %s dies, task_id: %s", - t.task_owner.key, t.task_id) + logger.debug("Task owned by %s removed after deadline, " + "task_id: %s", + t.task_owner.key, t.task_id) self.remove_task_header(t.task_id) for task_id, remove_time in list(self.removed_tasks.items()): @@ -649,3 +719,14 @@ def get_unsupport_reasons(self): avg = None ret.append({'reason': reason.value, 'ntasks': count, 'avg': avg}) return ret + + def task_started(self, task_id): + self.running_tasks.add(task_id) + + def task_ended(self, task_id): + try: + self.running_tasks.remove(task_id) + except ValueError: + logger.warning("Can not remove running task, already removed. " + "Maybe the callback is called twice. task_id=%r", + task_id) diff --git a/golem/task/taskmanager.py b/golem/task/taskmanager.py index 619bb6dbbc..a84ba29f4f 100644 --- a/golem/task/taskmanager.py +++ b/golem/task/taskmanager.py @@ -1,3 +1,5 @@ +# pylint: disable=too-many-lines + import logging import os import pickle @@ -27,8 +29,9 @@ from golem import model from golem.clientconfigdescriptor import ClientConfigDescriptor from golem.core.common import get_timestamp_utc, HandleForwardedError, \ - HandleKeyError, node_info_str, short_node_id, to_unicode, update_dict + HandleKeyError, short_node_id, to_unicode, update_dict from golem.manager.nodestatesnapshot import LocalTaskStateSnapshot +from golem.network import nodeskeeper from golem.ranking.manager.database_manager import update_provider_efficiency, \ update_provider_efficacy from golem.resource.dirmanager import DirManager @@ -68,6 +71,8 @@ class TaskManager(TaskEventListener): """ Keeps and manages information about requested tasks Requestor uses TaskManager to assign task to providers """ + # pylint: disable=too-many-public-methods + handle_task_key_error = HandleKeyError(log_task_key_error) handle_subtask_key_error = HandleKeyError(log_subtask_key_error) handle_generic_key_error = HandleForwardedError(KeyError, @@ -86,6 +91,7 @@ def __init__( apps_manager=AppsManager(), finished_cb=None, ) -> None: + # pylint: disable=too-many-instance-attributes super().__init__() self.apps_manager = apps_manager @@ -121,8 +127,15 @@ def __init__( resource_manager ) - self.activeStatus = [TaskStatus.computing, TaskStatus.starting, - TaskStatus.waiting] + self.CREATING_STATUS = frozenset([ + TaskStatus.creating, + TaskStatus.errorCreating, + ]) + self.ACTIVE_STATUS = frozenset([ + TaskStatus.computing, + TaskStatus.starting, + TaskStatus.waiting, + ]) self.FINISHED_STATUS = frozenset([ TaskStatus.finished, TaskStatus.aborted, @@ -163,9 +176,17 @@ def create_task(self, dictionary, minimal=False): minimal) definition.task_id = CoreTask.create_task_id(self.keys_auth.public_key) definition.concent_enabled = dictionary.get('concent_enabled', False) - builder = builder_type(self.node, definition, self.dir_manager) - return builder.build() + task = builder_type(self.node, definition, self.dir_manager).build() + task_id = task.header.task_id + + logger.info("Creating task. type=%r, id=%s", type(task), task_id) + self.tasks[task_id] = task + self.tasks_states[task_id] = TaskState(task) + return task + + def initialize_task(self, task: Task): + task.initialize(self.dir_manager) def get_task_definition_dict(self, task: Task): if isinstance(task, dict): @@ -176,25 +197,25 @@ def get_task_definition_dict(self, task: Task): def add_new_task(self, task: Task, estimated_fee: int = 0) -> None: task_id = task.header.task_id - if task_id in self.tasks: - raise RuntimeError("Task {} has been already added" - .format(task.header.task_id)) + task_state = self.tasks_states.get(task_id) + + if not task_state: + task_state = TaskState(task) + self.tasks[task_id] = task + self.tasks_states[task_id] = task_state + + if task_state.status is not TaskStatus.creating: + raise RuntimeError("Task {} has already been added".format(task_id)) task.header.task_owner = self.node self.sign_task_header(task.header) task.register_listener(self) - ts = TaskState() - ts.status = TaskStatus.notStarted - ts.outputs = task.get_output_names() - ts.subtasks_count = task.get_total_tasks() - ts.time_started = time.time() - ts.estimated_cost = task.price - ts.estimated_fee = estimated_fee + task_state.status = TaskStatus.notStarted + task_state.time_started = time.time() + task_state.estimated_fee = estimated_fee - self.tasks[task_id] = task - self.tasks_states[task_id] = ts logger.info("Task %s added", task_id) self._create_task_output_dir(task.task_definition) @@ -203,6 +224,14 @@ def add_new_task(self, task: Task, estimated_fee: int = 0) -> None: op=TaskOp.CREATED, persist=False) + @handle_task_key_error + def task_creation_failed(self, task_id: str, reason: str) -> None: + logger.error("Cannot create task. id=%s : %s", task_id, reason) + + task_state = self.tasks_states[task_id] + task_state.status = TaskStatus.errorCreating + task_state.status_message = reason + @handle_task_key_error def increase_task_mask(self, task_id: str, num_bits: int = 1) -> None: """ Increase mask for given task i.e. make it more restrictive """ @@ -249,7 +278,7 @@ def dump_task(self, task_id: str) -> None: with filepath.open('wb') as f: pickle.dump(data, f, protocol=2) logger.debug('TASK %s DUMPED in %r', task_id, filepath) - except Exception as e: + except Exception: # pylint: disable=broad-except logger.exception( 'DUMP ERROR task_id: %r task: %r state: %r', task_id, self.tasks.get(task_id, ''), @@ -346,12 +375,10 @@ def restore_tasks(self) -> None: def resources_send(self, task_id): self.tasks_states[task_id].status = TaskStatus.waiting self.notice_task_updated(task_id) - logger.info("Resources for task {} sent".format(task_id)) + logger.info("Resources for task sent. id=%s", task_id) def got_wants_to_compute(self, - task_id: str, - key_id: str, # pylint: disable=unused-argument - node_name: str): # pylint: disable=unused-argument + task_id: str): """ Updates number of offers to compute task. @@ -359,8 +386,6 @@ def got_wants_to_compute(self, elsewhere. Silently ignores wrong task ids. :param str task_id: id of the task in the offer - :param key_id: id of the node offering computations - :param node_name: name of the node offering computations :return: Nothing :rtype: None """ @@ -369,12 +394,16 @@ def got_wants_to_compute(self, op=TaskOp.WORK_OFFER_RECEIVED, persist=False) + def task_being_created(self, task_id: str) -> bool: + task_status = self.tasks_states[task_id].status + return task_status in self.CREATING_STATUS + def task_finished(self, task_id: str) -> bool: task_status = self.tasks_states[task_id].status return task_status in self.FINISHED_STATUS def task_needs_computation(self, task_id: str) -> bool: - if self.task_finished(task_id): + if self.task_being_created(task_id) or self.task_finished(task_id): task_status = self.tasks_states[task_id].status logger.info( 'task is not active: %(task_id)s, status: %(task_status)s', @@ -390,14 +419,13 @@ def task_needs_computation(self, task_id: str) -> bool: return False return True - def get_next_subtask( - self, node_id, node_name, task_id, estimated_performance, price, - max_resource_size, max_memory_size, address=""): + def get_next_subtask( # pylint: disable=too-many-arguments + self, node_id, task_id, estimated_performance, price, + max_resource_size, max_memory_size): """ Assign next subtask from task to node with given id and name. If subtask is assigned the function is returning a tuple :param node_id: - :param node_name: :param task_id: :param estimated_performance: :param price: @@ -408,9 +436,10 @@ def get_next_subtask( or None. It is recommended to call is_my_task and should_wait_for_node before this to find the reason why the task is not able to be picked up """ + # pylint: disable=too-many-return-statements logger.debug( - 'get_next_subtask(%r, %r, %r, %r, %r, %r, %r)', - node_id, node_name, task_id, estimated_performance, price, + 'get_next_subtask(%r, %r, %r, %r, %r, %r)', + node_id, task_id, estimated_performance, price, max_resource_size, max_memory_size, ) @@ -434,14 +463,14 @@ def get_next_subtask( if task.get_progress() == 1.0: logger.error("Task already computed. " - "task_id=%r, node_name=%r, node_id=%r", - task_id, node_name, node_id) + "task_id=%r, node_id=%r", + task_id, node_id) return None extra_data = task.query_extra_data( estimated_performance, node_id, - node_name + "", ) ctd = extra_data.ctd @@ -470,7 +499,7 @@ def check_compute_task_def(): self.subtask2task_mapping[ctd['subtask_id']] = task_id self.__add_subtask_to_tasks_states( - node_name, node_id, ctd, price, + node_id, ctd, price, ) self.notice_task_updated(task_id, subtask_id=ctd['subtask_id'], @@ -478,7 +507,7 @@ def check_compute_task_def(): logger.debug( "Subtask generated. task=%s, node=%s, ctd=%s", task_id, - node_info_str(node_name, node_id), + short_node_id(node_id), ctd, ) @@ -583,7 +612,6 @@ def copy_results( self.subtask2task_mapping[new_subtask_id] = \ new_task_id self.__add_subtask_to_tasks_states( - node_name='', node_id='', price=0, ctd=extra_data.ctd) @@ -659,10 +687,7 @@ def after_results_extracted(results): new_task.copy_subtask_results( new_subtask_id, old_subtask, results) - new_subtask_state = \ - self.__set_subtask_state_finished(new_subtask_id) - old_subtask_state = self.tasks_states[old_task_id] \ - .subtask_states[old_subtask_id] + self.__set_subtask_state_finished(new_subtask_id) self.notice_task_updated( task_id=new_task_id, @@ -677,7 +702,7 @@ def get_tasks_headers(self): ret = [] for tid, task in self.tasks.items(): status = self.tasks_states[tid].status - if task.needs_computation() and status in self.activeStatus: + if task.needs_computation() and status in self.ACTIVE_STATUS: ret.append(task.header) return ret @@ -686,9 +711,9 @@ def get_trust_mod(self, subtask_id): if subtask_id in self.subtask2task_mapping: task_id = self.subtask2task_mapping[subtask_id] return self.tasks[task_id].get_trust_mod(subtask_id) - else: - logger.error("This is not my subtask {}".format(subtask_id)) - return 0 + + logger.error("This is not my subtask. id=%s", subtask_id) + return 0 def update_task_signatures(self): for task in list(self.tasks.values()): @@ -702,8 +727,7 @@ def verify_subtask(self, subtask_id): if subtask_id in self.subtask2task_mapping: task_id = self.subtask2task_mapping[subtask_id] return self.tasks[task_id].verify_subtask(subtask_id) - else: - return False + return False def get_node_id_for_subtask(self, subtask_id): if subtask_id not in self.subtask2task_mapping: @@ -756,7 +780,7 @@ def verification_finished_(): verification_finished() - if self.tasks_states[task_id].status in self.activeStatus: + if self.tasks_states[task_id].status in self.ACTIVE_STATUS: if not self.tasks[task_id].finished_computation(): self.tasks_states[task_id].status = TaskStatus.computing else: @@ -872,7 +896,7 @@ def check_timeouts(self): nodes_with_timeouts = [] for t in list(self.tasks.values()): th = t.header - if self.tasks_states[th.task_id].status not in self.activeStatus: + if self.tasks_states[th.task_id].status not in self.ACTIVE_STATUS: continue cur_time = int(get_timestamp_utc()) # Check subtask timeout @@ -1070,7 +1094,6 @@ def get_task_dict(self, task_id) -> Optional[Dict]: state = self.query_task_state(task.header.task_id) dictionary = { - 'duration': state.elapsed_time, # single=True retrieves one preview file. If rendering frames, # it's the preview of the most recently computed frame. 'preview': task_type.get_preview(task, single=True) @@ -1126,16 +1149,20 @@ def add_comp_task_request(self, theader, price): """ Add a header of a task which this node may try to compute """ self.comp_task_keeper.add_request(theader, price) - def __add_subtask_to_tasks_states(self, node_name, node_id, + def __add_subtask_to_tasks_states(self, node_id, ctd, price: int): - logger.debug('add_subtask_to_tasks_states(%r, %r, %r)', - node_name, node_id, ctd) + logger.debug('add_subtask_to_tasks_states(%r, %r)', + node_id, ctd) + # we're retrieving the node_info so that we can set `node_name` on + # SubtaskState, which is later used e.g. to render the subtasks list + # in CLI and on the front-end + node_info = nodeskeeper.get(node_id) ss = SubtaskState( subtask_id=ctd['subtask_id'], node_id=node_id, - node_name=node_name, + node_name=node_info.node_name if node_info else '', price=price, deadline=ctd['deadline'], extra_data=ctd['extra_data'], diff --git a/golem/task/taskserver.py b/golem/task/taskserver.py index dab57ea97b..c21f47c513 100644 --- a/golem/task/taskserver.py +++ b/golem/task/taskserver.py @@ -3,6 +3,7 @@ import itertools import logging import os +import shutil import time import weakref from enum import Enum @@ -24,10 +25,14 @@ from apps.appsmanager import AppsManager from apps.core.task.coretask import CoreTask from golem.clientconfigdescriptor import ClientConfigDescriptor +from golem.core.common import short_node_id from golem.core.variables import MAX_CONNECT_SOCKET_ADDRESSES -from golem.core.common import node_info_str, short_node_id from golem.environments.environment import SupportStatus, UnsupportReason +from golem.envs.docker.cpu import DockerCPUConfig +from golem.envs.docker.non_hypervised import NonHypervisedDockerCPUEnvironment +from golem.envs.manager import EnvironmentManager from golem.marketplace import OfferPool +from golem.model import TaskPayment from golem.network.transport import msg_queue from golem.network.transport.network import ProtocolFactory, SessionFactory from golem.network.transport.tcpnetwork import ( @@ -52,7 +57,6 @@ from golem.task.taskconnectionshelper import TaskConnectionsHelper from golem.task.taskstate import TaskOp from golem.utils import decode_hex - from .server import concent from .server import helpers from .server import queue_ as srv_queue @@ -63,7 +67,6 @@ from .taskmanager import TaskManager from .tasksession import TaskSession - logger = logging.getLogger(__name__) tmp_cycler = itertools.cycle(list(range(550))) @@ -103,10 +106,18 @@ def __init__(self, self.keys_auth = client.keys_auth self.config_desc = config_desc + os.makedirs(self.get_task_computer_root(), exist_ok=True) + docker_cpu_config = DockerCPUConfig( + work_dir=Path(self.get_task_computer_root())) + docker_cpu_env = NonHypervisedDockerCPUEnvironment(docker_cpu_config) + new_env_manager = EnvironmentManager() + new_env_manager.register_env(docker_cpu_env) + self.node = node self.task_archiver = task_archiver self.task_keeper = TaskHeaderKeeper( - environments_manager=client.environments_manager, + old_env_manager=client.environments_manager, + new_env_manager=EnvironmentManager(), node=self.node, min_price=config_desc.min_price, task_archiver=task_archiver) @@ -128,6 +139,7 @@ def __init__(self, ) self.task_computer = TaskComputer( task_server=self, + docker_cpu_env=docker_cpu_env, use_docker_manager=use_docker_manager, finished_cb=task_finished_cb) self.task_connections_helper = TaskConnectionsHelper() @@ -155,6 +167,7 @@ def __init__(self, self.acl_ip = DenyAcl([], max_times=config_desc.disallow_ip_max_times) self.resource_handshakes = {} self.requested_tasks: Set[str] = set() + self._last_task_request_time: float = time.time() network = TCPNetwork( ProtocolFactory(SafeProtocol, self, SessionFactory(TaskSession)), @@ -191,7 +204,8 @@ def sync_network(self, timeout=None): ), self._sync_pending, self._send_waiting_results, - self.task_computer.run, + self._request_random_task, + self.task_computer.check_timeout, self.task_connections_helper.sync, self._sync_forwarded_session_requests, self.__remove_old_tasks, @@ -205,7 +219,6 @@ def sync_network(self, timeout=None): for job in jobs: try: - #logger.debug("TServer sync running: job=%r", job) job() except Exception: # pylint: disable=broad-except logger.exception("TaskServer.sync_network job %r failed", job) @@ -225,27 +238,38 @@ def resume(self): CoreTask.VERIFICATION_QUEUE.resume() def get_environment_by_id(self, env_id): - return self.task_keeper.environments_manager.get_environment_by_id( + return self.task_keeper.old_env_manager.get_environment_by_id( env_id) def request_task_by_id(self, task_id: str) -> None: - """Requests task possibly after successful resource handshake. - """ + """ Requests task possibly after successful resource handshake. """ try: - task_header: dt_tasks.TaskHeader = self.task_keeper.task_headers[ - task_id - ] + task_header = self.task_keeper.task_headers[task_id] except KeyError: logger.debug("Task missing in TaskKeeper. task_id=%s", task_id) return self._request_task(task_header) - def request_task(self) -> Optional[str]: - """Chooses random task from network to compute on our machine""" - task_header: dt_tasks.TaskHeader = \ - self.task_keeper.get_task(self.requested_tasks) + def _request_random_task(self) -> Optional[str]: + """ If there is no task currently computing and time elapsed from last + request exceeds the configured request interval, choose a random + task from the network to compute on our machine. """ + + if time.time() - self._last_task_request_time \ + < self.config_desc.task_request_interval: + return None + + if self.task_computer.has_assigned_task() \ + or (not self.task_computer.compute_tasks) \ + or (not self.task_computer.runnable): + return None + + task_header = self.task_keeper.get_task(self.requested_tasks) if task_header is None: return None + + self._last_task_request_time = time.time() + self.task_computer.stats.increase_stat('tasks_requested') return self._request_task(task_header) def _request_task(self, theader: dt_tasks.TaskHeader) -> Optional[str]: @@ -312,14 +336,13 @@ def _request_task(self, theader: dt_tasks.TaskHeader) -> Optional[str]: self.task_manager.add_comp_task_request( theader=theader, price=price) wtct = message.tasks.WantToComputeTask( - node_name=self.config_desc.node_name, perf_index=performance, price=price, max_resource_size=self.config_desc.max_resource_size, max_memory_size=self.config_desc.max_memory_size, concent_enabled=self.client.concent_service.enabled, provider_public_key=self.get_key_id(), - provider_ethereum_public_key=self.get_key_id(), + provider_ethereum_address=self.keys_auth.eth_addr, task_header=theader, ) msg_queue.put( @@ -336,10 +359,22 @@ def _request_task(self, theader: dt_tasks.TaskHeader) -> Optional[str]: return None - def task_given(self, node_id: str, ctd: message.ComputeTaskDef, - price: int) -> bool: - if not self.task_computer.task_given(ctd): + def task_given( + self, + node_id: str, + ctd: message.ComputeTaskDef, + price: int + ) -> bool: + if self.task_computer.has_assigned_task(): + logger.error("Trying to assign a task, when it's already assigned") return False + + self.task_computer.task_given(ctd) + self.request_resource( + ctd['task_id'], + ctd['subtask_id'], + ctd['resources'], + ) self.requested_tasks.clear() update_requestor_assigned_sum(node_id, price) dispatcher.send( @@ -350,6 +385,26 @@ def task_given(self, node_id: str, ctd: message.ComputeTaskDef, ) return True + def resource_collected(self, task_id: str) -> bool: + if self.task_computer.assigned_task_id != task_id: + logger.error("Resource collected for a wrong task, %s", task_id) + return False + + self.task_computer.start_computation() + return True + + def resource_failure(self, task_id: str, reason: str) -> None: + if self.task_computer.assigned_task_id != task_id: + logger.error("Resource failure for a wrong task, %s", task_id) + return + + self.task_computer.task_interrupted() + self.send_task_failed( + self.task_computer.assigned_subtask['subtask_id'], + task_id, + f'Error downloading resources: {reason}', + ) + def send_results(self, subtask_id, task_id, result): if 'data' not in result: @@ -358,6 +413,13 @@ def send_results(self, subtask_id, task_id, result): if subtask_id in self.results_to_send: raise RuntimeError("Incorrect subtask_id: {}".format(subtask_id)) + # this is purely for tests + if self.config_desc.overwrite_results: + for file_path in result['data']: + shutil.copyfile( + src=self.config_desc.overwrite_results, + dst=file_path) + header = self.task_keeper.task_headers[task_id] delay_time = 0.0 @@ -371,12 +433,12 @@ def send_results(self, subtask_id, task_id, result): delay_time=delay_time, owner=header.task_owner) - self.create_and_set_result_package(wtr) + self._create_and_set_result_package(wtr) self.results_to_send[subtask_id] = wtr Trust.REQUESTED.increase(header.task_owner.key) - def create_and_set_result_package(self, wtr): + def _create_and_set_result_package(self, wtr): task_result_manager = self.task_manager.task_result_manager wtr.result_secret = task_result_manager.gen_secret() @@ -504,12 +566,14 @@ def retry_sending_task_result(self, subtask_id): if wtr: wtr.already_sending = False + @inlineCallbacks def change_config(self, config_desc, run_benchmarks=False): PendingConnectionsServer.change_config(self, config_desc) self.config_desc = config_desc - self.task_keeper.change_config(config_desc) - return self.task_computer.change_config( + yield self.task_keeper.change_config(config_desc) + result = yield self.task_computer.change_config( config_desc, run_benchmarks=run_benchmarks) + return result def get_task_computer_root(self): return os.path.join(self.client.datadir, "ComputerRes") @@ -528,6 +592,7 @@ def subtask_rejected(self, sender_node_id, subtask_id): def subtask_accepted( self, sender_node_id: str, + task_id: str, subtask_id: str, payer_address: str, value: int, @@ -536,11 +601,12 @@ def subtask_accepted( logger.debug("Subtask %r result accepted", subtask_id) self.task_result_sent(subtask_id) self.client.transaction_system.expect_income( - sender_node_id, - subtask_id, - payer_address, - value, - accepted_ts, + sender_node=sender_node_id, + task_id=task_id, + subtask_id=subtask_id, + payer_address=payer_address, + value=value, + accepted_ts=accepted_ts, ) def subtask_settled(self, sender_node_id, subtask_id, settled_ts): @@ -568,23 +634,28 @@ def subtask_failure(self, subtask_id, err): Trust.COMPUTED.decrease(node_id) self.task_manager.task_computation_failure(subtask_id, err) - def accept_result(self, subtask_id, key_id, eth_address: str, value: int): + def accept_result(self, subtask_id, key_id, eth_address: str, value: int, + *, unlock_funds=True) -> TaskPayment: mod = min( max(self.task_manager.get_trust_mod(subtask_id), self.min_trust), self.max_trust) Trust.COMPUTED.increase(key_id, mod) task_id = self.task_manager.get_task_id(subtask_id) + task = self.task_manager.tasks[task_id] - payment_processed_ts = self.client.transaction_system.add_payment_info( - subtask_id, - value, - eth_address, + payment = self.client.transaction_system.add_payment_info( + node_id=task.header.task_owner.key, + task_id=task.header.task_id, + subtask_id=subtask_id, + value=value, + eth_address=eth_address, ) - self.client.funds_locker.remove_subtask(task_id) + if unlock_funds: + self.client.funds_locker.remove_subtask(task_id) logger.debug('Result accepted for subtask: %s Created payment ts: %r', - subtask_id, payment_processed_ts) - return payment_processed_ts + subtask_id, payment) + return payment def income_listener(self, event='default', node_id=None, **kwargs): if event == 'confirmed': @@ -701,13 +772,12 @@ def should_accept_provider( # noqa pylint: disable=too-many-arguments,too-many- self, node_id, address, - node_name, task_id, provider_perf, max_resource_size, max_memory_size): - node_name_id = node_info_str(node_name, node_id) + node_name_id = short_node_id(node_id) ids = f'provider={node_name_id}, task_id={task_id}' if task_id not in self.task_manager.tasks: diff --git a/golem/task/tasksession.py b/golem/task/tasksession.py index 5c96010082..985c8d3113 100644 --- a/golem/task/tasksession.py +++ b/golem/task/tasksession.py @@ -16,10 +16,8 @@ from twisted.internet import defer import golem -from golem.config.active import EthereumConfig from golem.core import common from golem.core import golem_async -from golem.core.keysauth import KeysAuth from golem.core import variables from golem.docker.environment import DockerEnvironment from golem.docker.image import DockerImage @@ -137,6 +135,11 @@ def task_computer(self) -> 'TaskComputer': def concent_service(self): return self.task_server.client.concent_service + @property + def deposit_contract_address(self): + return self.task_server.client\ + .transaction_system.deposit_contract_address + @property def is_active(self) -> bool: if not self.conn.opened: @@ -225,7 +228,6 @@ def send_hello(self): """ Send first hello message, that should begin the communication """ self.send( message.base.Hello( - client_key_id=self.task_server.get_key_id(), client_ver=golem.__version__, rand_val=self.rand_val, proto_id=variables.PROTOCOL_CONST.ID, @@ -274,7 +276,9 @@ def _react_to_want_to_compute_task(self, msg): self._cannot_assign_task(msg.task_id, reasons.NotMyTask) return - node_name_id = common.node_info_str(msg.node_name, self.key_id) + node_name_id = common.short_node_id( + self.key_id, + ) logger.info("Received offer to compute. task_id=%r, node=%r", msg.task_id, node_name_id) @@ -284,8 +288,7 @@ def _react_to_want_to_compute_task(self, msg): msg.task_id, node_name_id, ) - self.task_manager.got_wants_to_compute(msg.task_id, self.key_id, - msg.node_name) + self.task_manager.got_wants_to_compute(msg.task_id) logger.debug( "WTCT processing... task_id=%s, node=%s", @@ -296,7 +299,6 @@ def _react_to_want_to_compute_task(self, msg): task_server_ok = self.task_server.should_accept_provider( self.key_id, self.address, - msg.node_name, msg.task_id, msg.perf_index, msg.max_resource_size, @@ -394,7 +396,7 @@ def _offer_chosen( msg: message.tasks.WantToComputeTask, node_id: str, ): - node_name_id = common.node_info_str(msg.node_name, node_id) + node_name_id = common.short_node_id(node_id) reasons = message.tasks.CannotAssignTask.REASON if not is_chosen: logger.info( @@ -407,9 +409,8 @@ def _offer_chosen( logger.info("Offer confirmed, assigning subtask") ctd = self.task_manager.get_next_subtask( - self.key_id, msg.node_name, msg.task_id, msg.perf_index, - msg.price, msg.max_resource_size, msg.max_memory_size, - self.address) + self.key_id, msg.task_id, msg.perf_index, + msg.price, msg.max_resource_size, msg.max_memory_size) logger.debug( "CTD generated. task_id=%s, node=%s ctd=%s", @@ -433,6 +434,7 @@ def _offer_chosen( # overwrite resources so they are serialized by resource_manager resources = self.task_server.get_resources(ctd['subtask_id']) ctd["resources"] = resources + logger.info("resources_result: %r", resources_result) logger.info( "Subtask assigned. task_id=%r, node=%s, subtask_id=%r", @@ -468,19 +470,25 @@ def _offer_chosen( ) ttc.generate_ethsig(self.my_private_key) if ttc.concent_enabled: + logger.debug( + f"Signing promissory notes for GNTDeposit at: " + f"{self.deposit_contract_address}" + ) ttc.sign_promissory_note(private_key=self.my_private_key) ttc.sign_concent_promissory_note( - deposit_contract_address=getattr( - EthereumConfig, 'deposit_contract_address'), + deposit_contract_address=self.deposit_contract_address, private_key=self.my_private_key ) + signed_ttc = msg_utils.copy_and_sign( + msg=ttc, + private_key=self.my_private_key, + ) + self.send(ttc) + history.add( - msg=msg_utils.copy_and_sign( - msg=ttc, - private_key=self.my_private_key, - ), + msg=signed_ttc, node_id=self.key_id, local_role=Actor.Requestor, remote_role=Actor.Provider, @@ -582,8 +590,7 @@ def _cannot_compute(reason): if not (msg.verify_promissory_note() and msg.verify_concent_promissory_note( - deposit_contract_address=getattr( - EthereumConfig, 'deposit_contract_address') + deposit_contract_address=self.deposit_contract_address )): _cannot_compute(reasons.PromissoryNoteMissing) logger.debug( @@ -751,11 +758,12 @@ def _react_to_subtask_results_accepted( ) self.task_server.subtask_accepted( - self.key_id, - msg.subtask_id, - msg.task_to_compute.requestor_ethereum_address, - msg.task_to_compute.price, - msg.payment_ts, + sender_node_id=self.key_id, + task_id=msg.task_id, + subtask_id=msg.subtask_id, + payer_address=msg.task_to_compute.requestor_ethereum_address, + value=msg.task_to_compute.price, + accepted_ts=msg.payment_ts, ) self.dropped() @@ -787,8 +795,7 @@ def ask_for_verification(_): subtask_results_rejected=msg ) srv.sign_concent_promissory_note( - deposit_contract_address=getattr( - EthereumConfig, 'deposit_contract_address'), + deposit_contract_address=self.deposit_contract_address, private_key=self.my_private_key, ) @@ -834,10 +841,21 @@ def _react_to_hello(self, msg): if not self.conn.opened: logger.info("Hello received after connection closed. msg=%s", msg) return + + if (msg.proto_id != variables.PROTOCOL_CONST.ID)\ + or (msg.node_info is None): + logger.info( + "Task protocol version mismatch %r (msg) vs %r (local)", + msg.proto_id, + variables.PROTOCOL_CONST.ID + ) + self.disconnect(message.base.Disconnect.REASON.ProtocolVersion) + return + send_hello = False if self.key_id is None: - self.key_id = msg.client_key_id + self.key_id = msg.node_info.key try: existing_session = self.task_server.sessions[self.key_id] except KeyError: @@ -854,31 +872,6 @@ def _react_to_hello(self, msg): return send_hello = True - if (msg.proto_id != variables.PROTOCOL_CONST.ID)\ - or (msg.node_info is None): - logger.info( - "Task protocol version mismatch %r (msg) vs %r (local)", - msg.proto_id, - variables.PROTOCOL_CONST.ID - ) - self.disconnect(message.base.Disconnect.REASON.ProtocolVersion) - return - - if not KeysAuth.is_pubkey_difficult( - self.key_id, - self.task_server.config_desc.key_difficulty): - logger.info( - "Key from %s (%s:%d) is not difficult enough (%d < %d).", - common.node_info_str( - msg.node_info.node_name, - msg.client_key_id, - ), - self.address, self.port, - KeysAuth.get_difficulty(self.key_id), - self.task_server.config_desc.key_difficulty) - self.disconnect(message.base.Disconnect.REASON.KeyNotDifficult) - return - nodeskeeper.store(msg.node_info) if send_hello: diff --git a/golem/task/taskstate.py b/golem/task/taskstate.py index 0563d72c51..f4ad5e9727 100644 --- a/golem/task/taskstate.py +++ b/golem/task/taskstate.py @@ -1,33 +1,42 @@ from enum import Enum, auto import functools import time -from typing import Dict +from typing import Dict, Optional from golem_messages import datastructures from golem_messages import validators -class TaskState(object): - def __init__(self): - self.status = TaskStatus.notStarted +class TaskState: + # pylint: disable=too-many-instance-attributes + + def __init__(self, task=None) -> None: + self.status = TaskStatus.creating + self.status_message: Optional[str] = None self.progress = 0.0 self.remaining_time = 0 self.elapsed_time = 0 - self.time_started = 0 + self.time_started = 0.0 self.payment_booked = False self.payment_settled = False - self.outputs = [] - self.subtasks_count = 0 self.subtask_states: Dict[str, SubtaskState] = {} self.resource_hash = None self.package_hash = None self.package_path = None self.package_size = None - self.extra_data = {} + self.extra_data: Dict = {} self.last_update_time = time.time() - self.estimated_cost = 0 self.estimated_fee = 0 + if task: + self.outputs = task.get_output_names() + self.subtasks_count = task.get_total_tasks() + self.estimated_cost = task.price + else: + self.outputs = [] + self.subtasks_count = 0 + self.estimated_cost = 0 + def __setattr__(self, key, value): super().__setattr__(key, value) # Set last update time when changing status to other than 'restarted' @@ -44,6 +53,7 @@ def to_dictionary(self): 'time_remaining': self.remaining_time, 'last_updated': getattr(self, 'last_update_time', None), 'status': self.status.value, + 'status_message': self.status_message, 'estimated_cost': getattr(self, 'estimated_cost', None), 'estimated_fee': getattr(self, 'estimated_fee', None) } @@ -142,6 +152,8 @@ def serialize_status(cls, value: SubtaskStatus): class TaskStatus(Enum): + creating = "Creating" + errorCreating = "Error creating" notStarted = "Not started" creatingDeposit = "Creating the deposit" sending = "Sending" diff --git a/golem/task/timer.py b/golem/task/timer.py index e17a13f220..a6a6671585 100644 --- a/golem/task/timer.py +++ b/golem/task/timer.py @@ -66,8 +66,8 @@ def __init__(self) -> None: @property def profit_factor(self): - return self._profit_factor * \ - math.exp(-self._ALPHA * (time.time() - self._finished)) + thrist = time.time() - self._finished + return self._profit_factor * math.exp(-self._ALPHA * thrist) def _finish(self) -> None: super()._finish() diff --git a/golem/testutils.py b/golem/testutils.py index 644e6e2e41..dfac8d80f0 100644 --- a/golem/testutils.py +++ b/golem/testutils.py @@ -45,66 +45,6 @@ class DockerTestJobFailure(Exception): pass -class TestTaskManager(TaskManager): - def __init__(self, node, keys_auth, root_path, config_desc: ClientConfigDescriptor, tasks_dir="tasks", - task_persistence=False, apps_manager=AppsManager(), finished_cb=None): - - with patch('golem.core.statskeeper.StatsKeeper._get_or_create'): - super().__init__(node, keys_auth, root_path, config_desc, tasks_dir, task_persistence, apps_manager, - finished_cb) - self.apps_manager = apps_manager - apps = list(apps_manager.apps.values()) - task_types = [app.task_type_info() for app in apps] - self.task_types = {t.name.lower(): t for t in task_types} - - self.node = node - self.keys_auth = keys_auth - - self.tasks: Dict[str, Task] = {} - self.tasks_states: Dict[str, TaskState] = {} - self.subtask2task_mapping: Dict[str, str] = {} - - self.task_persistence = task_persistence - - tasks_dir = Path(tasks_dir) - self.tasks_dir = tasks_dir / "tmanager" - if not self.tasks_dir.is_dir(): - self.tasks_dir.mkdir(parents=True) - self.root_path = root_path - self.dir_manager = DirManager(self.get_task_manager_root()) - - resource_manager = HyperdriveResourceManager( - self.dir_manager, - resource_dir_method=self.dir_manager.get_task_temporary_dir, - client_kwargs={ - 'host': config_desc.hyperdrive_rpc_address, - 'port': config_desc.hyperdrive_rpc_port, - }, - ) - self.task_result_manager = EncryptedResultPackageManager( - resource_manager - ) - - self.activeStatus = [TaskStatus.computing, TaskStatus.starting, - TaskStatus.waiting] - - # These lines were commented, because tests hang on initialization here. - - # self.comp_task_keeper = CompTaskKeeper( - # tasks_dir, - # persist=self.task_persistence, - # ) - - # self.requestor_stats_manager = RequestorTaskStatsManager() - # self.provider_stats_manager = \ - # self.comp_task_keeper.provider_stats_manager - - self.finished_cb = finished_cb - - if self.task_persistence: - self.restore_tasks() - - class TempDirFixture(unittest.TestCase): root_dir = None @@ -267,11 +207,7 @@ def wrapper(self, *args, **kwargs): return wrapper -class TestTaskIntegration(TempDirFixture): - - class MockCompTaskKeeper: - def __init__(**kwargs): - self.provider_stats_manager = None +class TestTaskIntegration(DatabaseFixture): @staticmethod def check_file_existence(filename): @@ -311,7 +247,8 @@ def setUp(self): ccd = ClientConfigDescriptor() tasks_dir = os.path.join(self.tempdir, 'tasks') - self.task_manager = TestTaskManager(self.node, + with patch('golem.core.statskeeper.StatsKeeper._get_or_create'): + self.task_manager = TaskManager(self.node, self.keys_auth, self.tempdir, tasks_dir=tasks_dir, @@ -324,6 +261,7 @@ def _add_task(self, task_dict): task = self.task_manager.create_task(task_dict) self.task_manager.add_new_task(task) + self.task_manager.initialize_task(task) return task def _get_provider_dir(self, subtask_id): @@ -355,22 +293,20 @@ def execute_task(self, task_def): task: Task = self._add_task(task_def) task_id = task.task_definition.task_id - logger.info("Executing test task [task_id = {}]" + logger.info("Executing test task [task_id = {}] " "on mocked provider.".format(task_id)) self.task_manager.start_task(task_id) for i in range(task.task_definition.subtasks_count): ctd: ComputeTaskDef = self.task_manager. \ get_next_subtask(node_id=self.node_id, - node_name=self.node_name, task_id=task.task_definition.task_id, estimated_performance=1000, price=int( task.price / task.task_definition.subtasks_count), max_resource_size=10000000000, - max_memory_size=10000000000, - address='127.0.0.1') + max_memory_size=10000000000) subtask_id = ctd["subtask_id"] diff --git a/golem/tools/talkback.py b/golem/tools/talkback.py index 4888f6a655..474933f6d7 100644 --- a/golem/tools/talkback.py +++ b/golem/tools/talkback.py @@ -1,7 +1,6 @@ import logging -logger = logging.getLogger('talkback') -logger.setLevel(logging.INFO) +logger = logging.getLogger(__name__) def enable_sentry_logger(value): @@ -12,7 +11,7 @@ def enable_sentry_logger(value): or h.name == 'sentry-metrics'] for handler in sentry_handler: msg_part = 'Enabling' if talkback_value else 'Disabling' - logger.info('%s talkback %r service', msg_part, handler.name) + logger.debug('%s talkback %r service', msg_part, handler.name) handler.set_enabled(talkback_value) except Exception as e: # pylint: disable=broad-except msg_part = 'enable' if talkback_value else 'disable' diff --git a/golem/tools/testchildprocesses.py b/golem/tools/testchildprocesses.py new file mode 100644 index 0000000000..91be8d72a7 --- /dev/null +++ b/golem/tools/testchildprocesses.py @@ -0,0 +1,27 @@ +# TODO: move all test-related code somewhere to `tests` #4193 +import psutil + + +class KillLeftoverChildrenTestMixin: + _children_on_start = None + + @ staticmethod + def _get_process_children(): + p = psutil.Process() + return set([c.pid for c in p.children(recursive=True)]) + + def setUp(self): + super().setUp() + self._children_on_start = self._get_process_children() + + def tearDown(self): + super().tearDown() + + # in case any new child processes are still alive here, terminate them + nkotb = self._get_process_children() - self._children_on_start + for k in nkotb: + try: + p = psutil.Process(k) + p.kill() + except psutil.Error: + pass diff --git a/golem/tools/testwithreactor.py b/golem/tools/testwithreactor.py index fe68e96c65..d60c18d490 100644 --- a/golem/tools/testwithreactor.py +++ b/golem/tools/testwithreactor.py @@ -1,9 +1,11 @@ +# TODO: move all test-related code somewhere to `tests` #4193 import sys import time import unittest from threading import Thread import twisted +from twisted._threads import AlreadyQuit from twisted.internet.selectreactor import SelectReactor from twisted.internet.task import Clock @@ -82,7 +84,10 @@ def start(self): def stop(self): self.working = False if self.reactor.threadpool: - self.reactor.threadpool.stop() + try: + self.reactor.threadpool.stop() + except AlreadyQuit: + pass self.reactor.stop() while not self.done: diff --git a/golem/utils.py b/golem/utils.py index ea67b5f0c3..06547675dc 100644 --- a/golem/utils.py +++ b/golem/utils.py @@ -16,10 +16,6 @@ def find_free_net_port(): return s.getsockname()[1] # Return the port assigned. -def pubkeytoaddr(pubkey: str) -> str: - return to_checksum_address(encode_hex(sha3(decode_hex(pubkey))[12:])) - - def privkeytoaddr(privkey: bytes) -> str: """ Converts a private key bytes sequence to a string, representing the diff --git a/golem/verificator/blender_verifier.py b/golem/verificator/blender_verifier.py index c4507cc8d3..1907a87c2e 100644 --- a/golem/verificator/blender_verifier.py +++ b/golem/verificator/blender_verifier.py @@ -1,4 +1,6 @@ +import shutil from datetime import datetime +from pathlib import Path from typing import Type import logging @@ -18,7 +20,7 @@ # pylint: disable=R0902 class BlenderVerifier(FrameRenderingVerifier): DOCKER_NAME = "golemfactory/blender_verifier" - DOCKER_TAG = '1.1' + DOCKER_TAG = '1.5' def __init__(self, verification_data, docker_task_cls: Type) -> None: @@ -55,6 +57,15 @@ def stop(self): if self.docker_task: self.docker_task.end_comp() + @staticmethod + def _copy_files_with_directory_hierarchy(file_paths, copy_to): + common_dir = os.path.commonpath(file_paths) + for path in file_paths: + relative_path = os.path.relpath(path, start=common_dir) + target_dir = os.path.join(copy_to, relative_path) + os.makedirs(os.path.dirname(target_dir), exist_ok=True) + shutil.copy(path, target_dir) + def start_rendering(self, timeout=0): self.timeout = timeout @@ -72,13 +83,30 @@ def failure(exc): self.finished.addErrback(failure) subtask_info = self.verification_data['subtask_info'] - work_dir = os.path.dirname(self.verification_data['results'][0]) + root_dir = Path(os.path.dirname( + self.verification_data['results'][0])).parent + work_dir = os.path.join(root_dir, 'work') + os.makedirs(work_dir, exist_ok=True) + res_dir = os.path.join(root_dir, 'resources') + os.makedirs(res_dir, exist_ok=True) + + tmp_dir = os.path.join(root_dir, "tmp") + + resources = self.verification_data['resources'] + results = self.verification_data['results'] + + assert resources + assert results + + self._copy_files_with_directory_hierarchy(resources, res_dir) + self._copy_files_with_directory_hierarchy(results, work_dir) + dir_mapping = self.docker_task_cls.specify_dir_mapping( - resources=subtask_info['path_root'], - temporary=os.path.dirname(work_dir), + resources=res_dir, + temporary=tmp_dir, work=work_dir, - output=os.path.join(work_dir, "output"), - logs=os.path.join(work_dir, "logs"), + output=os.path.join(root_dir, "output"), + logs=os.path.join(root_dir, "logs"), ) extra_data = dict( diff --git a/golemapp.py b/golemapp.py index e3b4ac50e5..0af6e47451 100755 --- a/golemapp.py +++ b/golemapp.py @@ -27,6 +27,7 @@ from golem.core import variables # noqa from golem.core.common import install_reactor # noqa from golem.core.simpleenv import get_local_datadir # noqa +from golem.rpc.router import SerializerType # noqa logger = logging.getLogger('golemapp') # using __name__ gives '__main__' here @@ -104,6 +105,12 @@ def monkey_patched_getLogger(*args, **kwargs): @click.option('--enable-talkback', is_flag=True, default=None) @click.option('--hyperdrive-port', type=int, help="Hyperdrive public port") @click.option('--hyperdrive-rpc-port', type=int, help="Hyperdrive RPC port") +@click.option('--crossbar-serializer', default=None, + type=click.Choice([ + SerializerType.msgpack.value, + SerializerType.json.value, + ]), + help="Crossbar serializer (default: msgpack)") # Python flags, needed by crossbar (package only) @click.option('-m', nargs=1, default=None) @click.option('--node', expose_value=False) @@ -122,7 +129,7 @@ def start( # pylint: disable=too-many-arguments, too-many-locals monitor, concent, datadir, node_address, rpc_address, peer, mainnet, net, geth_address, password, accept_terms, accept_concent_terms, accept_all_terms, version, log_level, enable_talkback, m, - hyperdrive_port, hyperdrive_rpc_port, + hyperdrive_port, hyperdrive_rpc_port, crossbar_serializer ): freeze_support() @@ -138,11 +145,14 @@ def start( # pylint: disable=too-many-arguments, too-many-locals return 0 set_environment('mainnet' if mainnet else net, concent) + # These are done locally since they rely on golem.config.active to be set - from golem.config.active import CONCENT_VARIANT + from golem.config.active import EthereumConfig from golem.appconfig import AppConfig from golem.node import Node + ethereum_config = EthereumConfig() + # We should use different directories for different chains datadir = get_local_datadir('default', root_dir=datadir) os.makedirs(datadir, exist_ok=True) @@ -181,8 +191,8 @@ def _start(): log_golem_version() log_platform_info() - log_ethereum_chain() - log_concent_choice(CONCENT_VARIANT) + log_ethereum_config(ethereum_config) + log_concent_choice(ethereum_config.CONCENT_VARIANT) node = Node( datadir=datadir, @@ -191,9 +201,11 @@ def _start(): peers=peer, use_monitor=monitor, use_talkback=enable_talkback, - concent_variant=CONCENT_VARIANT, + concent_variant=ethereum_config.CONCENT_VARIANT, geth_address=geth_address, password=password, + crossbar_serializer=(SerializerType(crossbar_serializer) + if crossbar_serializer else None), ) if accept_terms: @@ -267,9 +279,12 @@ def log_platform_info(): humanize.naturalsize(swapinfo.total, binary=True)) -def log_ethereum_chain(): - from golem.config.active import EthereumConfig - logger.info("Ethereum chain: %s", EthereumConfig.CHAIN) +def log_ethereum_config(ethereum_config): + logger.info("Ethereum chain: %s", ethereum_config.CHAIN) + logger.debug("Ethereum config: %s", [ + (attr, getattr(ethereum_config, attr)) + for attr in dir(ethereum_config) if not attr.startswith('__') + ]) def log_concent_choice(value: dict): diff --git a/requirements-lint.txt b/requirements-lint.txt index f75dd67b34..919ac23a3b 100644 --- a/requirements-lint.txt +++ b/requirements-lint.txt @@ -6,17 +6,17 @@ codecov==2.0.15 coverage==4.5.1 factory-boy==2.9.2 Faker==0.8.9 -flake8==3.5.0 +flake8==3.7.7 freezegun==0.3.11 idna==2.5 isort==4.3.4 lazy-object-proxy==1.3.1 mccabe==0.6.1 -mypy==0.580 +mypy==0.670 pluggy==0.6.0 py==1.5.3 -pycodestyle==2.4.0 -pyflakes==1.6.0 +pycodestyle==2.5.0 +pyflakes==2.1.1 pylint==1.9.2 pytest-cov==2.5.1 pytest==3.3.1 @@ -24,6 +24,6 @@ python-dateutil==2.7.2 requests==2.21.0 six==1.11.0 text-unidecode==1.2 -typed-ast==1.1.0 -urllib3==1.23 +typed-ast==1.3.1 +urllib3==1.24.3 wrapt==1.10.11 diff --git a/requirements-lint_to-freeze.txt b/requirements-lint_to-freeze.txt index 2dc59c9aeb..4370315d0c 100644 --- a/requirements-lint_to-freeze.txt +++ b/requirements-lint_to-freeze.txt @@ -1,2 +1,3 @@ -r requirements-test.txt mypy +urllib3==1.24.3 diff --git a/requirements-test.txt b/requirements-test.txt index 6d0b88d476..e497d9a7c2 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -7,7 +7,7 @@ codecov==2.0.15 coverage==4.5.1 factory-boy==2.9.2 Faker==0.8.9 -flake8==3.5.0 +flake8==3.7.7 freezegun==0.3.11 idna==2.5 isort==4.3.4 @@ -16,14 +16,15 @@ mccabe==0.6.1 parameterized==0.7.0 pluggy==0.6.0 py==1.5.3 -pycodestyle==2.4.0 -pyflakes==1.6.0 +pycodestyle==2.5.0 +pyflakes==2.1.1 pylint==1.9.2 pytest-cov==2.5.1 +pytest-timeout==1.2.1 pytest==3.3.1 python-dateutil==2.7.2 requests==2.21.0 six==1.11.0 text-unidecode==1.2 -urllib3==1.23 +urllib3==1.24.3 wrapt==1.10.11 diff --git a/requirements-test_to-freeze.txt b/requirements-test_to-freeze.txt index 2daf9657fb..627a1c60eb 100644 --- a/requirements-test_to-freeze.txt +++ b/requirements-test_to-freeze.txt @@ -7,4 +7,7 @@ freezegun==0.3.11 parameterized==0.7.0 pylint==1.9.2 pytest-cov +pytest-timeout==1.2.1 pytest==3.3.1 +requests==2.21.0 +urllib3==1.24.3 diff --git a/requirements.txt b/requirements.txt index 0f4ef974a1..3d02ec5ef7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -#git+https://github.com/golemfactory/golem-messages@ +#git+https://github.com/golemfactory/golem-messages@#egg=golem-messages --extra-index-url https://builds.golem.network --trusted-host build.golem.network appdirs==1.4.3 argh==0.26.2 @@ -8,6 +8,7 @@ autobahn==17.10.1 Automat==0.6.0 Babel==2.6.0 base58==0.2.5 +bidict==0.18.0 bitcoin==1.1.42 bitstring==3.1.5 cached-property==1.4.2 @@ -20,7 +21,7 @@ cloudpickle==0.6.1 coincurve==7.1.0 constantly==15.1.0 crossbar==17.12.1 -cryptography==2.2.2 +cryptography==2.3.1 cytoolz==0.9.0.1 distro==1.3.0 dnspython==1.15.0 @@ -35,9 +36,10 @@ eth-tester==0.1.0-beta.24 eth-utils==1.0.3 ethereum==1.6.1 ffmpeg-tools==0.11.0 +eventlet==0.24.1 fs==2.4.4 -Golem-Messages==3.4.0 -Golem-Smart-Contracts-Interface==1.7.0 +Golem-Messages==3.9.0 +Golem-Smart-Contracts-Interface==1.10.0 greenlet==0.4.15 h2==3.0.1 hpack==3.0.0 @@ -48,7 +50,7 @@ hyperlink==18.0.0 idna==2.5 incremental==17.5.0 ipaddress==1.0.19 -Jinja2==2.10 +Jinja2==2.10.1 lmdb==0.94 MarkupSafe==1.0 miniupnpc==2.0.2 @@ -59,7 +61,7 @@ Naked==0.1.31 ndg-httpsclient==0.4.4 netaddr==0.7.19 netifaces==0.10.4 -numpy==1.13.1 +numpy==1.15.4 olefile==0.45.1 opencv-contrib-python-headless==3.4.3.18 OpenEXR==1.3.0 @@ -83,8 +85,8 @@ priority==1.3.0 psutil==5.2.2 py-cpuinfo==4.0.0 py-ubjson==0.11.0 -pyasn1-modules==0.2.1 -pyasn1==0.4.1 +pyasn1-modules==0.2.4 +pyasn1==0.4.5 pycparser==2.17 pycryptodome==3.6.6 PyDispatcher==2.0.5 @@ -92,8 +94,8 @@ pyelliptic==1.5.10 pyethash==0.1.27 Pygments==2.2.0 pylru==1.0.9 -PyNaCl==1.2.1 -pyOpenSSL==17.1.0 +PyNaCl==1.3.0 +pyOpenSSL==17.5.0 pyparsing==2.2.0 PyQRCode==1.2.1 pysha3==1.0.2 @@ -122,10 +124,10 @@ token-bucket==0.2.0 toolz==0.9.0 treq==17.8.0 Twisted==17.9.0 -txaio==2.8.2 +txaio==18.8.1 txtorcon==0.20.0 u-msgpack-python==2.5.0 -urllib3==1.23 +urllib3==1.24.3 watchdog==0.8.3 web3==4.2.1 websocket-client==0.47.0 diff --git a/requirements_to-freeze.txt b/requirements_to-freeze.txt index bc560aca1d..cb235c5c4d 100644 --- a/requirements_to-freeze.txt +++ b/requirements_to-freeze.txt @@ -1,10 +1,11 @@ -#git+https://github.com/golemfactory/golem-messages@ +#git+https://github.com/golemfactory/golem-messages@#egg=golem-messages --extra-index-url https://builds.golem.network appdirs>=1.4 asn1crypto==0.22.0 autobahn==17.10.1 Automat==0.6.0 base58 +bidict cbor==1.0.0 certifi cffi==1.10.0 @@ -15,15 +16,15 @@ docker==3.5.0 enforce==0.3.4 eth-utils==1.0.3 ethereum==1.6.1 -Golem-Messages==3.4.0 -Golem-Smart-Contracts-Interface==1.7.0 +Golem-Messages==3.9.0 +Golem-Smart-Contracts-Interface==1.10.0 html2text==2018.1.9 humanize==0.5.1 incremental==17.5.0 miniupnpc<2.1,>=2.0 ndg-httpsclient netifaces==0.10.4 -numpy==1.13.1 +numpy==1.15.4 OpenEXR os-win packaging @@ -33,10 +34,10 @@ pluggy==0.6.0 portalocker==1.2.1 psutil==5.2.2 py-cpuinfo==4.0.0 -pyasn1==0.4.1 +pyasn1==0.4.5 pycparser==2.17 pydispatcher -pyOpenSSL==17.1.0 +pyOpenSSL==17.5.0 pysha3>=1.0.2 pytz PyYAML>=5.1 @@ -50,7 +51,8 @@ six==1.11.0 tabulate token_bucket==0.2.0 Twisted==17.9.0 -txaio==2.8.2 +txaio==18.8.1 +urllib3==1.24.3 web3==4.2.1 zope.interface==4.4.2 zxcvbn-python diff --git a/scripts/concent_acceptance_tests/additional_verification/base.py b/scripts/concent_acceptance_tests/additional_verification/base.py index 8435e9e5fa..22515b5c8b 100644 --- a/scripts/concent_acceptance_tests/additional_verification/base.py +++ b/scripts/concent_acceptance_tests/additional_verification/base.py @@ -6,6 +6,7 @@ from golem_messages import factories as msg_factories from golem_messages.message import tasks as tasks_msg + from apps.blender.blenderenvironment import BlenderEnvironment from golem.core.simplehash import SimpleHash @@ -77,32 +78,43 @@ def get_ctd(self, **kwargs): ) return ctd - def get_srv_file_kwargs(self, results_filename=None): - if not results_filename: - results_filename = self.results_filename - - rct_path = 'subtask_results_rejected__report_computed_task__' - ttc_path = rct_path + 'task_to_compute__' + def ttc_file_kwargs(self, ttc_path = ''): return { ttc_path + 'compute_task_def': self.get_ctd(), ttc_path + 'size': self.size(self.resources_filename), ttc_path + 'package_hash': self.hash(self.resources_filename), ttc_path + 'concent_enabled': True, + } + + def rct_file_kwargs(self, results_filename=None): + if not results_filename: + results_filename = self.results_filename + + rct_path = 'subtask_results_rejected__report_computed_task__' + return { rct_path + 'size': self.size(results_filename), rct_path + 'package_hash': self.hash(results_filename), } def get_srv(self, results_filename=None, **kwargs): rct_path = 'subtask_results_rejected__report_computed_task__' - files_kwargs = self.get_srv_file_kwargs( - results_filename=results_filename) - files_kwargs.update(kwargs) - return msg_factories.concents.SubtaskResultsVerifyFactory( + files_kwargs = self.rct_file_kwargs(results_filename=results_filename) + + ttc = self.gen_ttc(**self.ttc_file_kwargs()) + + srv = msg_factories.concents.SubtaskResultsVerifyFactory( **self.gen_rtc_kwargs(rct_path), - **self.gen_ttc_kwargs(rct_path + 'task_to_compute__'), + **{rct_path + 'task_to_compute': ttc}, subtask_results_rejected__sign__privkey=self.requestor_priv_key, **files_kwargs, + **kwargs, + ) + srv.sign_concent_promissory_note( + deposit_contract_address= + self.ethereum_config.deposit_contract_address, + private_key=self.provider_priv_key, ) + return srv def get_correct_srv(self, results_filename=None, **kwargs): vn = tasks_msg.SubtaskResultsRejected.REASON.VerificationNegative diff --git a/scripts/concent_acceptance_tests/additional_verification/test_messages.py b/scripts/concent_acceptance_tests/additional_verification/test_messages.py index 8972071561..29dd742a81 100644 --- a/scripts/concent_acceptance_tests/additional_verification/test_messages.py +++ b/scripts/concent_acceptance_tests/additional_verification/test_messages.py @@ -1,4 +1,4 @@ -from golem_messages.message import concents as concent_msg +from golem_messages import message from .base import SubtaskResultsVerifyBaseTest @@ -6,37 +6,39 @@ class SubtaskResultsVerifyTest(SubtaskResultsVerifyBaseTest): def test_send_srv_reason_incorrect(self): - srv = self.get_srv() + srv = self.get_srv(subtask_results_rejected__reason=None) response = self.provider_send(srv) msg = self.provider_load_response(response) - self.assertIsInstance(msg, concent_msg.ServiceRefused) + self.assertIsInstance(msg, message.concents.ServiceRefused) self.assertEqual( msg.reason, - concent_msg.ServiceRefused.REASON.InvalidRequest + message.concents.ServiceRefused.REASON.InvalidRequest ) def test_send_srv_no_deposit(self): srv = self.get_correct_srv() response = self.provider_send(srv) msg = self.provider_load_response(response) - self.assertIsInstance(msg, concent_msg.ServiceRefused) + self.assertIsInstance(msg, message.concents.ServiceRefused) self.assertEqual( msg.reason, - concent_msg.ServiceRefused.REASON.TooSmallRequestorDeposit + message.concents.ServiceRefused.REASON.TooSmallRequestorDeposit ) def test_send(self): srv = self.get_srv_with_deposit() response = self.provider_send(srv) msg = self.provider_load_response(response) - self.assertIsInstance(msg, concent_msg.AckSubtaskResultsVerify) - self.assertEqual(msg.subtask_results_verify, srv) + self.assertIsInstance(msg, message.concents.AckSubtaskResultsVerify) + self.assertMessageEqual(msg.subtask_results_verify, srv) ftt = msg.file_transfer_token - self.assertIsInstance(ftt, concent_msg.FileTransferToken) + self.assertIsInstance(ftt, message.concents.FileTransferToken) self.assertTrue(ftt.is_upload) self.assertTrue( ftt.get_file_info( - concent_msg.FileTransferToken.FileInfo.Category.results)) + message.concents.FileTransferToken.FileInfo.Category.results) + ) self.assertTrue( ftt.get_file_info( - concent_msg.FileTransferToken.FileInfo.Category.resources)) + message.concents.FileTransferToken.FileInfo.Category.resources) + ) diff --git a/scripts/concent_acceptance_tests/base.py b/scripts/concent_acceptance_tests/base.py index 161b81a333..c8a9b40a6d 100644 --- a/scripts/concent_acceptance_tests/base.py +++ b/scripts/concent_acceptance_tests/base.py @@ -5,7 +5,6 @@ import functools import logging import os -import random import sys import tempfile import time @@ -20,7 +19,9 @@ from golem_messages import cryptography from golem_messages import helpers from golem_messages import serializer +from golem_messages import factories as msg_factories from golem_messages import utils as msg_utils +from golem_messages import shortcuts from golem_messages.message.base import Message from golem_messages.message import concents @@ -72,6 +73,8 @@ def setUp(self): base64.b64encode(self.provider_pub_key).decode()) logger.debug('Requestor key: %s', base64.b64encode(self.requestor_pub_key).decode()) + from golem.config.environments.testnet import EthereumConfig + self.ethereum_config = EthereumConfig() @property def provider_priv_key(self): @@ -89,6 +92,24 @@ def requestor_priv_key(self): def requestor_pub_key(self): return self.requestor_keys.raw_pubkey + def ttc_add_promissory_and_sign(self, ttc, priv_key=None) -> None: + priv_key = priv_key or self.requestor_priv_key + ttc.sign_promissory_note(private_key=priv_key) + ttc.sign_concent_promissory_note( + deposit_contract_address= + self.ethereum_config.deposit_contract_address, + private_key=priv_key, + ) + ttc.sign_message(priv_key) + + def gen_ttc(self, **kwargs): + ttc = msg_factories.tasks.TaskToComputeFactory( + **self.gen_ttc_kwargs(), + **kwargs + ) + self.ttc_add_promissory_and_sign(ttc) + return ttc + def gen_ttc_kwargs(self, prefix=''): encoded_requestor_pubkey = msg_utils.encode_hex(self.requestor_pub_key) kwargs = { @@ -192,6 +213,19 @@ def assertSamePayload(self, msg1, msg2): ) ) + @staticmethod + def _dump_and_load(msg): + msg_d = shortcuts.dump(msg, None, None) + return shortcuts.load(msg_d, None, None) + + def assertMessageEqual(self, msg1, msg2): + # @todo: remove after this is implemented: + # https://github.com/golemfactory/golem-messages/issues/348 + return self.assertEqual( + self._dump_and_load(msg1), + self._dump_and_load(msg2) + ) + def assertServiceRefused( self, msg: concents.ServiceRefused, @@ -233,9 +267,6 @@ class SCIBaseTest(ConcentBaseTest): def setUp(self): super().setUp() - from golem.config.environments.testnet import EthereumConfig - random.seed() - self.transaction_timeout = datetime.timedelta(seconds=300) self.sleep_interval = 15 @@ -249,19 +280,19 @@ def setUp(self): self.requestor_sci = new_sci_rpc( storage=requestor_storage, - rpc=EthereumConfig.NODE_LIST[0], + rpc=self.ethereum_config.NODE_LIST[0], address=self.requestor_eth_addr, tx_sign=lambda tx: tx.sign(self.requestor_keys.raw_privkey), - contract_addresses=EthereumConfig.CONTRACT_ADDRESSES, - chain=EthereumConfig.CHAIN, + contract_addresses=self.ethereum_config.CONTRACT_ADDRESSES, + chain=self.ethereum_config.CHAIN, ) self.provider_sci = new_sci_rpc( storage=provider_storage, - rpc=EthereumConfig.NODE_LIST[0], + rpc=self.ethereum_config.NODE_LIST[0], address=self.provider_eth_addr, tx_sign=lambda tx: tx.sign(self.provider_keys.raw_privkey), - contract_addresses=EthereumConfig.CONTRACT_ADDRESSES, - chain=EthereumConfig.CHAIN, + contract_addresses=self.ethereum_config.CONTRACT_ADDRESSES, + chain=self.ethereum_config.CHAIN, ) # pylint: disable=too-many-arguments diff --git a/scripts/concent_acceptance_tests/basic/test_basic.py b/scripts/concent_acceptance_tests/basic/test_basic.py index 9a70c35c43..5520d53aec 100644 --- a/scripts/concent_acceptance_tests/basic/test_basic.py +++ b/scripts/concent_acceptance_tests/basic/test_basic.py @@ -20,7 +20,7 @@ class SendTest(ConcentBaseTest, unittest.TestCase): def test_send(self): msg = msg_factories.concents.ForceReportComputedTaskFactory( **self.gen_rtc_kwargs('report_computed_task__'), - **self.gen_ttc_kwargs('report_computed_task__task_to_compute__'), + **{'report_computed_task__task_to_compute': self.gen_ttc()}, ) logger.debug("Sending FRCT: %s", msg) @@ -52,7 +52,7 @@ def test_invalid_GM_version(self): version = msg_factories_helpers.fake_version() msg = msg_factories.concents.ForceReportComputedTaskFactory( **self.gen_rtc_kwargs('report_computed_task__'), - **self.gen_ttc_kwargs('report_computed_task__task_to_compute__'), + **{'report_computed_task__task_to_compute': self.gen_ttc()}, ) with mock.patch('golem_messages.__version__', version): with self.assertRaisesRegex( diff --git a/scripts/concent_acceptance_tests/force_accept/test_requestor_doesnt_send.py b/scripts/concent_acceptance_tests/force_accept/test_requestor_doesnt_send.py index afc33bb499..ae7e7aea32 100644 --- a/scripts/concent_acceptance_tests/force_accept/test_requestor_doesnt_send.py +++ b/scripts/concent_acceptance_tests/force_accept/test_requestor_doesnt_send.py @@ -24,16 +24,16 @@ class RequestorDoesntSendTestCase(SCIBaseTest): """Requestor doesn't send Ack/Reject of SubtaskResults""" - def prepare_report_computed_task(self, mode, **kwargs): + def prepare_report_computed_task(self, mode, ttc_kwargs, rct_kwargs): """Returns ReportComputedTask with open force acceptance window Can be modified by delta """ - + _rct_kwargs = self.gen_rtc_kwargs() + _rct_kwargs.update(rct_kwargs) report_computed_task = msg_factories.tasks.ReportComputedTaskFactory( - **self.gen_rtc_kwargs(), - **self.gen_ttc_kwargs('task_to_compute__'), - **kwargs, + **_rct_kwargs, + **{'task_to_compute': self.gen_ttc(**ttc_kwargs)}, ) # Difference between timestamp and deadline has to be constant # because it's part of SVT formula @@ -85,25 +85,19 @@ def prepare_report_computed_task(self, mode, **kwargs): return report_computed_task def provider_send_force( - self, mode='within', rct_kwargs=None, **kwargs): - if rct_kwargs is None: - rct_kwargs = {} + self, mode='within', ttc_kwargs=None, rct_kwargs=None, **kwargs): + ttc_kwargs = ttc_kwargs or {} + rct_kwargs = rct_kwargs or {} price = random.randint(1 << 20, 10 << 20) self.requestor_put_deposit(helpers.requestor_deposit_amount(price)[0]) - rct_kwargs['task_to_compute__price'] = price + ttc_kwargs['price'] = price report_computed_task = self.prepare_report_computed_task( mode=mode, - **rct_kwargs, + ttc_kwargs=ttc_kwargs, + rct_kwargs=rct_kwargs, ) fsr = msg_factories.concents.ForceSubtaskResultsFactory( ack_report_computed_task__report_computed_task=report_computed_task, - **self.gen_rtc_kwargs( - 'ack_report_computed_task__' - 'report_computed_task__'), - **self.gen_ttc_kwargs( - 'ack_report_computed_task__' - 'report_computed_task__' - 'task_to_compute__'), **kwargs, ) fsr.task_to_compute.generate_ethsig(private_key=self.requestor_priv_key) @@ -176,12 +170,12 @@ def test_already_processed(self): task_id = fake_golem_uuid(requestor_id) subtask_id = fake_golem_uuid(requestor_id) kwargs = { - 'task_to_compute__requestor_id': requestor_id, - 'task_to_compute__task_id': task_id, - 'task_to_compute__subtask_id': subtask_id, + 'requestor_id': requestor_id, + 'task_id': task_id, + 'subtask_id': subtask_id, } - self.assertIsNone(self.provider_send_force(rct_kwargs=kwargs)) - second_response = self.provider_send_force(rct_kwargs=kwargs) + self.assertIsNone(self.provider_send_force(ttc_kwargs=kwargs)) + second_response = self.provider_send_force(ttc_kwargs=kwargs) self.assertIsInstance(second_response, message.concents.ServiceRefused) def test_no_response_from_requestor(self): diff --git a/scripts/concent_acceptance_tests/force_download/base.py b/scripts/concent_acceptance_tests/force_download/base.py index 96528e0ccf..e3859eeb16 100644 --- a/scripts/concent_acceptance_tests/force_download/base.py +++ b/scripts/concent_acceptance_tests/force_download/base.py @@ -8,6 +8,6 @@ class ForceDownloadBaseTest(ConcentBaseTest): def get_fgtr(self, **kwargs): return msg_factories.concents.ForceGetTaskResultFactory( **self.gen_rtc_kwargs('report_computed_task__'), - **self.gen_ttc_kwargs('report_computed_task__task_to_compute__'), + **{'report_computed_task__task_to_compute': self.gen_ttc()}, **kwargs, ) diff --git a/scripts/concent_acceptance_tests/force_download/test_messages.py b/scripts/concent_acceptance_tests/force_download/test_messages.py index 2b3b1c4608..f10ce6ba47 100644 --- a/scripts/concent_acceptance_tests/force_download/test_messages.py +++ b/scripts/concent_acceptance_tests/force_download/test_messages.py @@ -18,12 +18,13 @@ def test_send(self): response = self.requestor_send(fgtr) msg = self.requestor_load_response(response) self.assertIsInstance(msg, concent_msg.AckForceGetTaskResult) - self.assertEqual(msg.force_get_task_result, fgtr) + self.assertMessageEqual(msg.force_get_task_result, fgtr) def test_send_fail_timeout(self): ttc = msg_factories.tasks.TaskToComputeFactory.past_deadline( **self.gen_ttc_kwargs(), ) + self.ttc_add_promissory_and_sign(ttc) fgtr = msg_factories.concents.ForceGetTaskResultFactory( report_computed_task__task_to_compute=ttc, **self.gen_rtc_kwargs('report_computed_task__'), diff --git a/scripts/concent_acceptance_tests/force_payment/test_payment.py b/scripts/concent_acceptance_tests/force_payment/test_payment.py index ef5eee2918..1337609877 100644 --- a/scripts/concent_acceptance_tests/force_payment/test_payment.py +++ b/scripts/concent_acceptance_tests/force_payment/test_payment.py @@ -7,7 +7,7 @@ from golem_messages import cryptography from golem_messages import factories as msg_factories from golem_messages import message -from golem_messages.utils import encode_hex as encode_key_id +from golem_messages.utils import encode_hex as encode_key_id, pubkey_to_address import golem_sci.structs from golem.network.concent import exceptions as concent_exceptions @@ -37,7 +37,7 @@ def _prepare_list_of_acceptances(self): for _ in range(3): rct = msg_factories.tasks.ReportComputedTaskFactory( **self.gen_rtc_kwargs(), - **self.gen_ttc_kwargs('task_to_compute__'), + **{'task_to_compute': self.gen_ttc()}, ) sra = msg_factories.tasks.SubtaskResultsAcceptedFactory( report_computed_task=rct, @@ -100,7 +100,7 @@ def test_multiple_requestors(self): """ rct = msg_factories.tasks.ReportComputedTaskFactory( **self.gen_rtc_kwargs(), - **self.gen_ttc_kwargs('task_to_compute__'), + **{'task_to_compute': self.gen_ttc()}, ) sra1 = msg_factories.tasks.SubtaskResultsAcceptedFactory( report_computed_task=rct, @@ -109,19 +109,23 @@ def test_multiple_requestors(self): sra1.sign_message(self.requestor_priv_key) requestor2_keys = cryptography.ECCx(None) - ttc2_kwargs = self.gen_ttc_kwargs('task_to_compute__') + ttc2_kwargs = self.gen_ttc_kwargs() ttc2_kwargs.update({ - 'task_to_compute__sign__privkey': requestor2_keys.raw_privkey, - 'task_to_compute__requestor_public_key': encode_key_id( + 'sign__privkey': requestor2_keys.raw_privkey, + 'requestor_public_key': encode_key_id( requestor2_keys.raw_pubkey, ), - 'task_to_compute__requestor_ethereum_public_key': encode_key_id( + 'requestor_ethereum_public_key': encode_key_id( requestor2_keys.raw_pubkey, ), }) + ttc2 = msg_factories.tasks.TaskToComputeFactory(**ttc2_kwargs) + self.ttc_add_promissory_and_sign( + ttc2, priv_key=requestor2_keys.raw_privkey + ) rct2 = msg_factories.tasks.ReportComputedTaskFactory( **self.gen_rtc_kwargs(), - **ttc2_kwargs, + **{'task_to_compute': ttc2}, ) sra2 = msg_factories.tasks.SubtaskResultsAcceptedFactory( report_computed_task=rct2, @@ -147,36 +151,39 @@ def test_multiple_requestors(self): self.assertServiceRefused(response) def test_multiple_eth_accounts(self): - ttc_kwargs = self.gen_ttc_kwargs('task_to_compute__') + ttc_kwargs = self.gen_ttc_kwargs() provider1_keys = cryptography.ECCx(None) ttc_kwargs.update({ - 'task_to_compute__' 'want_to_compute_task__' - 'provider_ethereum_public_key': encode_key_id( + 'provider_ethereum_address': pubkey_to_address( provider1_keys.raw_pubkey ), }) + ttc = msg_factories.tasks.TaskToComputeFactory(**ttc_kwargs) + self.ttc_add_promissory_and_sign(ttc) rct = msg_factories.tasks.ReportComputedTaskFactory( sign__privkey=provider1_keys.privkey, - **ttc_kwargs, + task_to_compute=ttc, ) sra1 = msg_factories.tasks.SubtaskResultsAcceptedFactory( report_computed_task=rct, payment_ts=int(time.time()) - 3600*24, ) sra1.sign_message(self.requestor_priv_key) - ttc2_kwargs = self.gen_ttc_kwargs('task_to_compute__') + ttc2_kwargs = self.gen_ttc_kwargs() provider2_keys = cryptography.ECCx(None) ttc2_kwargs.update({ 'task_to_compute__' 'want_to_compute_task__' - 'provider_ethereum_public_key': encode_key_id( + 'provider_ethereum_address': pubkey_to_address( provider2_keys.raw_pubkey ), }) + ttc2 = msg_factories.tasks.TaskToComputeFactory(**ttc_kwargs) + self.ttc_add_promissory_and_sign(ttc2) rct2 = msg_factories.tasks.ReportComputedTaskFactory( sign__privkey=provider2_keys.privkey, - **ttc2_kwargs, + task_to_compute=ttc2, ) sra2 = msg_factories.tasks.SubtaskResultsAcceptedFactory( report_computed_task=rct2, @@ -199,7 +206,7 @@ def test_provider_asks_too_early(self): """ rct = msg_factories.tasks.ReportComputedTaskFactory( **self.gen_rtc_kwargs(), - **self.gen_ttc_kwargs('task_to_compute__'), + **{'task_to_compute': self.gen_ttc()}, ) sra = msg_factories.tasks.SubtaskResultsAcceptedFactory( report_computed_task=rct, @@ -212,7 +219,7 @@ def test_provider_asks_too_early(self): ], ) response = self.assertPaymentRejected(fp, fpr_reasons.TimestampError) - self.assertEqual(response.force_payment, fp) + self.assertMessageEqual(response.force_payment, fp) def test_force_payment_committed_requestor_has_more_funds(self): """Concent service commits forced payment @@ -251,7 +258,7 @@ def test_requestor_has_no_funds(self): def test_sra_not_signed(self): rct = msg_factories.tasks.ReportComputedTaskFactory( **self.gen_rtc_kwargs(), - **self.gen_ttc_kwargs('task_to_compute__'), + **{'task_to_compute': self.gen_ttc()}, ) sra = msg_factories.tasks.SubtaskResultsAcceptedFactory( report_computed_task=rct, diff --git a/scripts/concent_acceptance_tests/force_report/test_messages.py b/scripts/concent_acceptance_tests/force_report/test_messages.py index e91d5f95db..e2e2c5ec5c 100644 --- a/scripts/concent_acceptance_tests/force_report/test_messages.py +++ b/scripts/concent_acceptance_tests/force_report/test_messages.py @@ -15,10 +15,12 @@ class ForceReportComputedTaskTest(ConcentBaseTest, unittest.TestCase): - def get_frct(self, **kwargs): + def get_frct(self, ttc_kwargs=None, **kwargs): + ttc_kwargs = ttc_kwargs or {} return msg_factories.concents.ForceReportComputedTaskFactory( **self.gen_rtc_kwargs('report_computed_task__'), - **self.gen_ttc_kwargs('report_computed_task__task_to_compute__'), + **{'report_computed_task__task_to_compute': + self.gen_ttc(**ttc_kwargs)}, **kwargs, ) @@ -30,7 +32,9 @@ def test_send(self): def test_send_ttc_deadline_float(self): deadline = calendar.timegm(time.gmtime()) + \ datetime.timedelta(days=1, microseconds=123).total_seconds() - frct = self.get_frct(report_computed_task__task_to_compute__compute_task_def__deadline=deadline) # noqa pylint:disable=line-too-long + frct = self.get_frct( + ttc_kwargs={'compute_task_def__deadline': deadline} + ) response = self.provider_send(frct) self.assertIsNone(response) @@ -43,6 +47,7 @@ def test_task_timeout(self): ttc = msg_factories.tasks.TaskToComputeFactory.past_deadline( **self.gen_ttc_kwargs(), ) + self.ttc_add_promissory_and_sign(ttc) frct = msg_factories.concents.ForceReportComputedTaskFactory( report_computed_task__task_to_compute=ttc, **self.gen_rtc_kwargs('report_computed_task__'), @@ -62,8 +67,11 @@ def test_requestor_receive(self): frct = self.get_frct() self.provider_send(frct) frct_rcv = self.requestor_receive() - self.assertEqual(frct.report_computed_task, - frct_rcv.report_computed_task) + + self.assertMessageEqual( + frct.report_computed_task, + frct_rcv.report_computed_task + ) ### # @@ -126,8 +134,10 @@ def test_reject_rct_timeout(self): arct_rcv = frct_response.ack_report_computed_task self.assertIsInstance(arct_rcv, message.tasks.AckReportComputedTask) arct_rcv.verify_signature(self.variant['pubkey']) - self.assertEqual(arct_rcv.report_computed_task, - frct.report_computed_task) + self.assertMessageEqual( + arct_rcv.report_computed_task, + frct.report_computed_task + ) def send_and_verify_received_reject(self, rrct): response = self.requestor_send(rrct) @@ -143,7 +153,7 @@ def send_and_verify_received_reject(self, rrct): ) rrct_rcv = frct_response.reject_report_computed_task rrct_rcv.verify_signature(self.requestor_pub_key) - self.assertEqual(rrct_rcv, rrct) + self.assertMessageEqual(rrct_rcv, rrct) def test_reject_rct_cannot_compute_task(self): frct = self.get_frct() diff --git a/scripts/docker/clean-docker-vm.ps1 b/scripts/docker/clean-docker-vm.ps1 index 0e650b0444..197018cece 100644 --- a/scripts/docker/clean-docker-vm.ps1 +++ b/scripts/docker/clean-docker-vm.ps1 @@ -40,10 +40,18 @@ try { "Done" #> "Clean Docker Machine..." + +"hasInstalled: " + $hasInstalled +"pfSFFolder: " + $pfSFFolder # TODO: Change to docker or docker-machine updated, always execute this time from TB -> binaries. #if ( -Not ( $hasInstalled.indexof("Docker") = -1 )) #{ -$dockerRmCmd = """" + $pfSFFolder + "Docker Toolbox\docker-machine.exe"" rm -f golem" +$dockerBin = """" + $pfSFFolder + "Golem\docker-machine.exe""" +$dockerVersionCmd = $dockerBin + " --version" +"Executing: " + $dockerVersionCmd +cmd.exe /c $dockerVersionCmd +$dockerRmCmd = $dockerBin + " rm -f golem" +"Executing: " + $dockerRmCmd cmd.exe /c $dockerRmCmd #} diff --git a/scripts/docker/create-share.ps1 b/scripts/docker/create-share.ps1 index a7d9e54c8b..fb51c382e5 100644 --- a/scripts/docker/create-share.ps1 +++ b/scripts/docker/create-share.ps1 @@ -56,13 +56,30 @@ if (Get-SmbShare | Where-Object -Property Name -EQ $SmbShareName) { "Sharing directory..." -$Command = "New-SmbShare -Name $SmbShareName -Path '$SharedDirPath' -FullAccess '$env:COMPUTERNAME\$UserName'" -$Output = (New-TemporaryFile).FullName +# Creating temporary files for capturing stout & stderr +$StdoutPath = (New-TemporaryFile).FullName +$StderrPath = (New-TemporaryFile).FullName +$TmpScriptPath = "$env:TEMP/tmp-script.ps1" + +# Generate code for a script that will capture the original script's stdout & stderr +# This is done because powershell cannot redirect output of a process started with 'RunAs' verb +@" +`$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [Text.UTF8Encoding]::UTF8 + +try { + New-SmbShare -Name $SmbShareName -Path "$SharedDirPath" -FullAccess "$env:COMPUTERNAME\$UserName" >"$StdoutPath" 2>"$StderrPath" +} catch { + `$_ | Out-File -FilePath "$StderrPath" -Encoding "UTF8" + throw +} +"@ | Out-File -Encoding "UTF8" -FilePath $TmpScriptPath $Process = Start-Process -FilePath "powershell.exe" ` - -ArgumentList "-Command $Command 2>&1 | Out-File -FilePath '$Output' -Encoding UTF8" ` - -Wait -PassThru -Verb RunAs -WindowStyle hidden + -ArgumentList "-NoProfile -NoLogo -ExecutionPolicy RemoteSigned `"$TmpScriptPath`"" ` + -Wait -PassThru -Verb RunAs -WindowStyle Hidden -Get-Content -Encoding "UTF8" $Output | Write-Output +Get-Content -Encoding "UTF8" -Path $StdoutPath | Write-Output +Get-Content -Encoding "UTF8" -Path $StderrPath | Write-Error exit $Process.ExitCode diff --git a/scripts/docker_integrity/README.md b/scripts/docker_integrity/README.md new file mode 100644 index 0000000000..7116fd22f8 --- /dev/null +++ b/scripts/docker_integrity/README.md @@ -0,0 +1,33 @@ +This script verifies the integrity of Golem's Docker hub images. + +In order to do that, a registry of docker images required by Golem is defined in +the `image_integrity.ini` file that has a format of: + +``` +golemfactory/image_name 1.0 sha256-hash-of-the-image +``` + +The registry holds entries valid for the current branch and must include only +production images. + +To run verification, just launch the script: + +`./scripts/docker_integrity/verify.py` + +To ensure that all docker images used by Golem are included in the verification +check, add a `--verify-coverage` flag: + +`./scripts/docker_integrity/verify.py --verify-coverage` + +This detects situations when Golem's images have been updated without including +them in the verification and, at the same time, should prevent accidental updates +that cause non-production images to make it into the major branch. + +The script will run through all images listed in the registry and will produce +a consistent report. + +If all images are found intact, it will exit normally, with an exit code of `0`. + +Should it encounter hash mismatches, it will produce a failure report and an +exit code of `1`. It will also exit erroneously if any errors are encountered +that would prevent correct verification of images. diff --git a/scripts/docker_integrity/image_integrity.ini b/scripts/docker_integrity/image_integrity.ini new file mode 100644 index 0000000000..b676c7b3d2 --- /dev/null +++ b/scripts/docker_integrity/image_integrity.ini @@ -0,0 +1,12 @@ +# +# NEVER PUT NON-PRODUCTION IMAGES/TAGS INTO THIS FILE +# +# repository tag hash +golemfactory/base 1.5 93c72af33f5eefaf325f594f0f46237cb07c25bbc3a1283ae91eb70761dcd035 +golemfactory/blender 1.10 9d857c19e136e084edae95ba6982bb168f411e414ade50215d639f0c907df398 +golemfactory/blender_nvgpu 1.4 ff84d6f5a84557eb6f2535b5bdb2caa3d4e720c96f960f934274869d7cc3aa63 +golemfactory/blender_verifier 1.5 705f94c0e6944d792ac4c47330c443a0905e03c98a183ab6e8775b3717508628 +golemfactory/dummy 1.2 60ba63d94c08ceebe67d8af6325fe37928d5178f0f7d340a195df5cf8d042d4b +golemfactory/glambda 1.4 2417d0fcde4a90d69b78a5552920beac8cca8d68283eace7c68ea231ea623b7b +golemfactory/nvgpu 1.4 7344c68586f06e61a1adae738d95d7dcd37306c6936c21ea06437326ba32b5f0 +golemfactory/wasm 0.3.0 fea1d5c524044bd889ebea906db49a4345cce78b2c7ab2f8c4ef4e71ffbebbb4 diff --git a/scripts/docker_integrity/verify.py b/scripts/docker_integrity/verify.py new file mode 100755 index 0000000000..a16c7d6e29 --- /dev/null +++ b/scripts/docker_integrity/verify.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python +import argparse +import json +import pathlib +import re +import requests +from requests.status_codes import codes as http_codes +import sys +import typing + +DOCKERHUB_URI = 'https://registry.hub.docker.com/v2/' +REPOSITORY_ROOT = 'golemfactory' +IMAGES_FILE = pathlib.Path(__file__).parents[0] / 'image_integrity.ini' +GOLEM_IMAGES_FILE = pathlib.Path(__file__).parents[2] / 'apps/images.ini' + + +class COLORS(object): + RESET = '\033[0m' + RED = '\033[1;31m' + GREEN = '\033[1;32m' + + +class AuthenticationError(Exception): + pass + + +class ConfigurationError(Exception): + pass + + +class CommunicationError(Exception): + pass + + +class CoverageError(Exception): + pass + + +def get_golem_images() -> dict: + images: dict = {} + + with open(GOLEM_IMAGES_FILE) as f: + for l in f: + m = re.match( + r"(?P[\w._/]+)\s+\S+\s+(?P[\w.]+)", l) + if not m: + continue + + images[m.group('repo')] = m.group('tag') + + if not images: + raise ConfigurationError( + "Could not parse Golem `images.ini`. Format has changed?" + ) + + return images + + +def get_images() -> dict: + images: dict = {} + with open(IMAGES_FILE) as f: + for l in f: + m = re.match( + r"(?P[\w._/]+)\s+(?P[\w.]+)\s+(?P\w+)?$", l) + + if not m: + continue + + m_repo = m.group('repo') + m_tag = m.group('tag') + + repo = images.setdefault(m_repo, {}) + + if m_tag in repo and m.group('hash') != repo.get(m_tag): + raise ConfigurationError( + f"{m_repo}:{m_tag} has a conflicting hash: " + f"'{m.group('hash')}' vs '{repo.get(m_tag)}' " + f"defined in '{IMAGES_FILE}'." + ) + else: + repo[m.group('tag')] = m.group('hash') + + return images + + +def authenticate(repository: str): + r = requests.get(DOCKERHUB_URI) + if not r.status_code == http_codes.UNAUTHORIZED: + raise AuthenticationError( + f"Unexpected status code: {r.status_code} " + f"while retrieving: {DOCKERHUB_URI}" + ) + auth_properties = { + g[0]: g[1] + for g in re.findall( + r"(\w+)=\"(.+?)\"", r.headers.get('Www-Authenticate', '') + ) + } + realm = auth_properties.get('realm') + if not realm: + raise AuthenticationError( + f"Could not find expected auth header in: {r.headers}" + ) + auth_r = requests.get( # type:ignore + realm, + params={ + 'service': auth_properties.get('service'), + 'scope': f'repository:{repository}:pull', + } + ) + if not auth_r.status_code == http_codes.OK: + raise AuthenticationError( + f"Could not access: {realm}" + ) + try: + token = auth_r.json().get('token') + return { + 'Authorization': f'Bearer {token}', + 'Accept': 'application/vnd.docker.distribution.manifest.v2+json' + } + except json.decoder.JSONDecodeError: + raise AuthenticationError( + f"Auth token not found in {auth_r.text}, retrieved from {realm}." + ) + + +def get_manifest(token: dict, repository: str, tag: str): + r = requests.get( + DOCKERHUB_URI + f'{repository}/manifests/{tag}', + headers=token + ) + try: + manifest = r.json() + if not isinstance(manifest, dict): + raise CommunicationError( + f"Expected a dictionary, got {type(manifest)}: {manifest} " + f"for {repository}:{tag}" + ) + except json.JSONDecodeError as e: + raise CommunicationError( + f"Failed to retrieve the correct manifest for {repository}:{tag}, " + f"got {r.status_code} - {r.text}" + ) from e + + return manifest + + +def get_info(repository: str, tag: str): + r = requests.get(DOCKERHUB_URI + f'repositories/{repository}/tags/{tag}/') + try: + info = r.json() + if not isinstance(info, dict): + raise CommunicationError( + f"Expected a dictionary, got {type(info)}: {info} " + f"for {repository}:{tag}" + ) + except json.JSONDecodeError as e: + raise CommunicationError( + f"Failed to retrieve image info for {repository}:{tag}, " + f"got {r.status_code} - {r.text}" + ) from e + + return info + + +def verify_images() -> typing.Tuple[int, int]: + cnt_images = 0 + cnt_failures = 0 + for repository, tags in get_images().items(): + token = authenticate(repository) + for tag, img_hash in tags.items(): + cnt_images += 1 + manifest = get_manifest(token, repository, tag) + manifest_hash = manifest.get('config', {}).get('digest', '')[7:] + if img_hash != manifest_hash: + last_updated = get_info(repository, tag).get('last_updated') + print( + f'{repository}:{tag}: ' + f'{COLORS.RED}hash differs ' + f'(expected:{img_hash}, received:{manifest_hash}).' + f'{COLORS.RESET}' + f' Last updated: {last_updated}' + ) + cnt_failures += 1 + else: + print( + f'{repository}:{tag}: {COLORS.GREEN}\u2713{COLORS.RESET}' + ) + + return cnt_images, cnt_failures + + +def verify_coverage(): + integrity_images = get_images() + for repository, tag in get_golem_images().items(): + if tag not in integrity_images.get(repository): + raise CoverageError( + f'{repository}:{tag} is not present in {IMAGES_FILE}') + + +def run_verification(): + + cnt_images, cnt_failures = verify_images() + + if cnt_failures: + print( + f'{COLORS.RED}{cnt_failures} out of {cnt_images} images ' + f'{"have" if cnt_failures > 1 else "has a"} modified ' + f'hash{"es" if cnt_failures > 1 else ""}' + f'!{COLORS.RESET}' + ) + sys.exit(1) + + print( + f'{COLORS.GREEN}All {cnt_images} images successfully verified :)' + f'{COLORS.RESET}' + ) + sys.exit(0) + + +def run(): + + parser = argparse.ArgumentParser( + description="Verify integrity of Golem Docker hub images") + parser.add_argument( + '--verify-coverage', + help=f"Ensure all Golem images defined in {GOLEM_IMAGES_FILE} " + f"are checked for integrity.", + action='store_true', + ) + args = parser.parse_args() + + if args.verify_coverage: + print("Verifying coverage... ") + verify_coverage() + print(f"{COLORS.GREEN}All images protected :){COLORS.RESET}") + + print("Verifying Golem Docker image integrity...") + run_verification() + + +run() diff --git a/scripts/get-slow-argument.py b/scripts/get-slow-argument.py index 48f37efb06..0360b64c23 100644 --- a/scripts/get-slow-argument.py +++ b/scripts/get-slow-argument.py @@ -3,6 +3,7 @@ # - input: pull_id from CI ( argv ) # - output: argument to use for this test ( stdout ) +import os import sys import requests @@ -13,11 +14,33 @@ # config vars required_approvals = 1 +_logging = os.environ.get('LOGGING', False) + class ApprovalError(Exception): pass +def _log(msg, *args): + if _logging: + sys.stderr.write(msg.format(*args) + '\n') + + +def _get_json_data(link): + _log("_get_json_data: {}", link) + # Github API requires user agent. + req = requests.get(url, headers={'User-Agent': 'build-bot'}) + json_data = req.json() + + if "message" in json_data \ + and json_data["message"].startswith("API rate"): + + sys.stderr.write("Raw reply:{}".format(json_data)) + raise ApprovalError + + return req, json_data + + # When build is not a PR the input is: "" or "false" if pull_request_id not in ["", "false"]: base_url = "https://api.github.com/" \ @@ -25,23 +48,26 @@ class ApprovalError(Exception): url = base_url.format(pull_request_id) try: - # Github API requires user agent. - req = requests.get(url, headers={'User-Agent': 'build-bot'}) - - json_data = req.json() + req, json_data = _get_json_data(url) - if "message" in json_data \ - and json_data["message"].startswith("API rate"): + while 'next' in req.links: + _log("got link: {}", req.links) + url = req.links['next']['url'] + req, new_json_data = _get_json_data(url) + json_data += new_json_data - sys.stderr.write("Raw reply:{}".format(json_data)) - raise ApprovalError + # _log("Raw json_data: {}", json_data) + _log("len json_data: {}", len(json_data)) check_states = ["APPROVED", "CHANGES_REQUESTED"] review_states = [a for a in json_data if a["state"] in check_states] unique_reviews = {x['user']['login']: x for x in review_states}.values() + _log("unique_reviews: {}", unique_reviews) result = [a for a in unique_reviews if a["state"] == "APPROVED"] + _log("result: {}", result) approvals = len(result) + _log("approvals: {}", approvals) run_slow = approvals >= required_approvals except(requests.HTTPError, requests.Timeout, ApprovalError) as e: sys.stderr.write("Error calling github, run all tests. {}".format(url)) diff --git a/scripts/node_integration_tests/conftest.py b/scripts/node_integration_tests/conftest.py index 2da12385a8..d8a459ee7c 100644 --- a/scripts/node_integration_tests/conftest.py +++ b/scripts/node_integration_tests/conftest.py @@ -1,44 +1,11 @@ from typing import List import _pytest -REUSE_KEYS = True +from .key_reuse import NodeKeyReuseConfig + DUMP_OUTPUT_ON_CRASH = False DUMP_OUTPUT_ON_FAIL = False - -class NodeKeyReuseException(Exception): - pass - - -class NodeKeyReuse: - instance = None - _first_test = True - - @classmethod - def get(cls): - if not cls.instance: - cls.instance = cls() - return cls.instance - - @property - def keys_ready(self): - return not self._first_test - - def mark_keys_ready(self): - if not self.enabled: - raise NodeKeyReuseException("Key reuse disabled.") - self._first_test = False - - @staticmethod - def disable(): - global REUSE_KEYS - REUSE_KEYS = False - - @property - def enabled(self): - return REUSE_KEYS - - class DumpOutput: @staticmethod def enabled_on_crash(): @@ -66,6 +33,10 @@ def pytest_addoption(parser: _pytest.config.Parser) -> None: help="Disables reuse of provider's and requestor's node keys. " "All node_integration_tests run with new, fresh keys." ) + parser.addoption( + "--granary-hostname", action="store", + help="The ssh hostname for the granary server to use." + ) parser.addoption( "--dump-output-on-fail", action="store_true", help="Dump the nodes' outputs on any test failure." @@ -79,7 +50,10 @@ def pytest_addoption(parser: _pytest.config.Parser) -> None: def pytest_collection_modifyitems(config: _pytest.config.Config, items: List[_pytest.main.Item]) -> None: if config.getoption("--disable-key-reuse"): - NodeKeyReuse.disable() + NodeKeyReuseConfig.disable() + hostname = config.getoption("--granary-hostname") + if hostname: + NodeKeyReuseConfig.set_granary(hostname) if config.getoption('--dump-output-on-crash'): DumpOutput.enable_on_crash() if config.getoption('--dump-output-on-fail'): diff --git a/scripts/node_integration_tests/helpers.py b/scripts/node_integration_tests/helpers.py index 5efbed07d0..7b8daad65f 100644 --- a/scripts/node_integration_tests/helpers.py +++ b/scripts/node_integration_tests/helpers.py @@ -1,7 +1,7 @@ import datetime import itertools import os -import pathlib +from pathlib import Path import queue import re import subprocess @@ -15,13 +15,17 @@ from . import tasks -def get_testdir() -> str: +class ConfigurationError(Exception): + pass + + +def get_testdir() -> Path: env_key = 'GOLEM_INTEGRATION_TEST_DIR' datadir = os.environ.get(env_key, None) if not datadir: datadir = tempfile.mkdtemp(prefix='golem-integration-test-') os.environ[env_key] = datadir - return datadir + return Path(datadir) def mkdatadir(role: str) -> str: @@ -62,10 +66,10 @@ def _params_from_dict(d: typing.Dict[str, typing.Any]) -> typing.List[str]: def run_golem_node( node_type: str, args: typing.Dict[str, typing.Any], - nodes_root: typing.Optional[pathlib.Path] = None + nodes_root: typing.Optional[Path] = None ) -> subprocess.Popen: node_file = node_type + '.py' - cwd = pathlib.Path(__file__).resolve().parent + cwd = Path(__file__).resolve().parent node_script = str(cwd / 'nodes' / node_file) return subprocess.Popen( args=['python', node_script, *_params_from_dict(args)], @@ -120,12 +124,24 @@ def search_output(q: queue.Queue, pattern) -> typing.Optional[typing.Match]: def construct_test_task(task_package_name: str, task_settings: str) \ -> typing.Dict[str, typing.Any]: settings = tasks.get_settings(task_settings) - cwd = pathlib.Path(__file__).resolve().parent + cwd = Path(__file__).resolve().parent tasks_path = (cwd / 'tasks' / task_package_name).glob('**/*') settings['resources'] = [str(f) for f in tasks_path if f.is_file()] return settings +def scene_file_path(task_package_name: str, file_path: str) -> str: + cwd = Path(__file__).resolve().parent + full_path = cwd / 'tasks' / task_package_name / file_path + if not full_path.is_file(): + raise ConfigurationError( + f"Could not find {file_path} " + f"(expanded as {full_path} " + f"in task {task_package_name}" + ) + return str(full_path) + + def set_task_output_path(task_dict: dict, output_path: str) -> None: task_dict['options']['output_path'] = output_path diff --git a/scripts/node_integration_tests/key_reuse.py b/scripts/node_integration_tests/key_reuse.py new file mode 100644 index 0000000000..fd58841bb8 --- /dev/null +++ b/scripts/node_integration_tests/key_reuse.py @@ -0,0 +1,263 @@ +import json +import os +from pathlib import Path +import shutil +from typing import Dict, Optional + +from eth_keyfile import create_keyfile_json, decode_keyfile_json + +from golem.core.keysauth import WrongPassword +from tests.factories.granary import Granary, Account + +from scripts.node_integration_tests.playbooks.test_config_base import NodeId + +KEYSTORE_DIR = 'rinkeby/keys' +KEYSTORE_FILE = 'keystore.json' +TRANSACTION_FILE = 'tx.json' +PASSWORD = 'goleM.8' + +_logging = False + + +def _log(*args): + # Private log function since pytest.tearDown() does not print logger + if _logging: + print(*args) + + +class NodeKeyReuseBase: + # Base class for key reuse providers + def begin_test(self, datadirs: Dict[NodeId, Path]) -> None: + raise NotImplementedError() + + def end_test(self) -> None: + raise NotImplementedError() + + +class NodeKeyReuseConfig: + # Configuration singleton class for reusing keys + # Selects the right provider ( subclass of NodeKeyReuseBase ) + # Based on command line arguments ( TBA: and enviromnet vars ) + instance: 'Optional[NodeKeyReuseConfig]' = None + provider: Optional[NodeKeyReuseBase] = None + enabled: bool = True + + # Provider specific variables + granary_hostname: Optional[str] = None + local_reuse_dir: Optional[Path] = None + + @classmethod + def get(cls): + _log("NodeKeyReuseConfig.get() called.") + if not cls.instance: + cls.instance = cls() + if cls.granary_hostname: + print("key_reuse - granary selected:", cls.granary_hostname) + cls.provider = NodeKeyReuseGranary(cls.granary_hostname) + else: + print("key_reuse - local folder selected:", cls.local_reuse_dir) + assert cls.local_reuse_dir is not None, \ + "ERROR: No folder for reuse, call set_dir() first" + cls.provider = NodeKeyReuseLocalFolder(cls.local_reuse_dir) + return cls.instance + + @classmethod + def set_dir(cls, dir: Path): + _log("NodeKeyReuseConfig.set_dir() called. dir=", dir) + assert cls.provider is None, "ERROR: Can not set_dir() after get()" + cls.local_reuse_dir = dir + + @classmethod + def begin_test(cls, datadirs: Dict[NodeId, Path]): + _log("NodeKeyReuseConfig.begin_test() called. dirs= ", datadirs) + if cls.enabled and cls.provider: + cls.provider.begin_test(datadirs) + + @classmethod + def end_test(cls): + _log("NodeKeyReuseConfig.end_test() called.") + if cls.enabled and cls.provider: + cls.provider.end_test() + + @classmethod + def disable(cls): + _log("NodeKeyReuseConfig.disable() called.") + cls.enabled = False + + @classmethod + def enable(cls): + _log("NodeKeyReuseConfig.enable() called.") + cls.enabled = True + + @classmethod + def reset(cls): + _log("NodeKeyReuseConfig.reset() called.") + cls.instance = None + + @classmethod + def set_granary(cls, hostname): + _log("NodeKeyReuseConfig.set_granary() called. host=", hostname) + cls.granary_hostname = hostname + + +class NodeKeyReuseLocalFolder(NodeKeyReuseBase): + # Key reuse provider implementation that uses a local folder to store keys + def __init__(self, test_dir: Path): + self.dir: Path = test_dir / 'key_reuse' + self.datadirs: Dict[NodeId, Path] = {} + self._first_test = True + + def begin_test(self, datadirs: Dict[NodeId, Path]) -> None: + _log("NodeKeyReuseLocalFolder.begin_test() called.") + self.datadirs = datadirs + + if not self._first_test: + _log("Moving keys from reuse-dirs to data-dirs") + self._recycle_keys() + + def end_test(self) -> None: + _log("NodeKeyReuseLocalFolder.end_test() called.") + try: + _log("Moving keys from data-dirs to reuse-dirs") + self._copy_keystores() + except FileNotFoundError: + print('Copying keystores failed...') + return + + self._first_test = False + + def _recycle_keys(self) -> None: + # this is run before running second and later tests + for i, datadir in enumerate(self.datadirs.values()): + _log("NodeKeyReuseLocalFolder._recycle_keys() loop. " + "i", i, 'datadir', datadir) + reuse_dir = self.dir / str(i) + if not reuse_dir.exists(): + continue + self._replace_keystore(reuse_dir, datadir) + + @staticmethod + def _replace_keystore(src: Path, dst: Path) -> None: + src_file = src / KEYSTORE_FILE + dst_file = dst / KEYSTORE_DIR / KEYSTORE_FILE + os.makedirs(str(dst / KEYSTORE_DIR)) + shutil.copyfile(str(src_file), str(dst_file)) + + def _copy_keystores(self) -> None: + # this is run after tests + self._prepare_keystore_reuse_folders() + for i, datadir in enumerate(self.datadirs.values()): + _log("NodeKeyReuseLocalFolder._copy_keystores() loop. " + "i", i, 'datadir', datadir) + self._copy_keystore( + datadir, self.dir / str(i)) + + def _prepare_keystore_reuse_folders(self) -> None: + # this is run after tests + try: + for i in range(len(self.datadirs)): + reuse_dir = self.dir / str(i) + _log("NodeKeyReuseLocalFolder._prepare_keystore_reuse_folders()" + "i", i, 'reuse_dir', reuse_dir) + shutil.rmtree(reuse_dir, ignore_errors=True) + os.makedirs(reuse_dir) + except OSError: + print('Unexpected problem with creating folders for keystore') + raise + + @staticmethod + def _copy_keystore(datadir: Path, reuse_dir: Path) -> None: + src = str(datadir / KEYSTORE_DIR / KEYSTORE_FILE) + dst = str(reuse_dir / KEYSTORE_FILE) + _log("NodeKeyReuseLocalFolder._copy_keystore() file. " + "src=", src, ", dst=", dst) + shutil.copyfile(src, dst) + + +class NodeKeyReuseGranary(NodeKeyReuseBase): + # Key reuse provider implementation that uses a remote granary to store keys + def __init__(self, hostname: str): + self.datadirs: Dict[NodeId, Path] = {} + self.granary = Granary(hostname) + + def begin_test(self, datadirs: Dict[NodeId, Path]) -> None: + self.datadirs = datadirs + _log("NodeKeyReuseGranary.begin_test() called. " + "Moving keys from granary to data-dirs") + self._recycle_keys() + + def end_test(self) -> None: + _log("NodeKeyReuseGranary.end_test() called.") + try: + _log("Moving keys from data-dirs to granary") + self._copy_keystores() + except FileNotFoundError: + print('Copying keystores failed...') + return + + def _recycle_keys(self) -> None: + # this is run before tests + for datadir in self.datadirs.values(): + account = self.granary.request_account() + if account is not None: + self._replace_keystore( + account, datadir + ) + else: + print("WARNING: No key from granary, will generate one") + + def _replace_keystore(self, account: Account, dst: Path) -> None: + dst_key_dir = dst / KEYSTORE_DIR + dst_key_file = dst_key_dir / KEYSTORE_FILE + dst_trans_file = dst_key_dir / TRANSACTION_FILE + os.makedirs(str(dst_key_dir)) + self._save_private_key(account.raw_key, dst_key_file, PASSWORD) + if account.transaction_store: + dst_trans_file.write_text(account.transaction_store) + + def _copy_keystores(self): + # this is run after tests + # return key to granary as binary private key and transactions.json + + for datadir in self.datadirs.values(): + account = self._copy_keystore(datadir) + if account: + self.granary.return_account(account) + + @staticmethod + def _copy_keystore(datadir: Path) -> Optional[Account]: + + src_key_dir = datadir / KEYSTORE_DIR + src_ts_file = src_key_dir / TRANSACTION_FILE + src_key_file = src_key_dir / KEYSTORE_FILE + ts = '{}' + keystore = None + + try: # read tx.json + with open(src_ts_file, 'r') as f: + ts = f.read() + except FileNotFoundError: + _log('No tx.json, continue') + try: # read keystore.json + with open(src_key_file, 'r') as f: + keystore = json.load(f) + except FileNotFoundError: + _log('No File, no key') + return None + + try: # unlock the key + priv_key = decode_keyfile_json(keystore, PASSWORD.encode('utf-8')) + except ValueError: + raise WrongPassword + + return Account(priv_key, ts) + + @staticmethod + def _save_private_key(key, key_path: Path, password: str) -> None: + keystore = create_keyfile_json( + key, + password.encode('utf-8'), + iterations=1024, + ) + with open(key_path, 'w') as f: + json.dump(keystore, f) diff --git a/scripts/node_integration_tests/nodes/json_serializer.py b/scripts/node_integration_tests/nodes/json_serializer.py new file mode 100644 index 0000000000..55951ebe7b --- /dev/null +++ b/scripts/node_integration_tests/nodes/json_serializer.py @@ -0,0 +1,15 @@ +from golemapp import start +from golem.client import Client +from golem.rpc import utils as rpc_utils + + +@rpc_utils.expose('test.bignum') +def _get_bignum(self): + return 2**64 + 1337 + + +# using setattr silences mypy complaining about "has no attribute" +setattr(Client, "_get_bignum", _get_bignum) + + +start() diff --git a/scripts/node_integration_tests/playbooks/base.py b/scripts/node_integration_tests/playbooks/base.py index cfef2a6a4a..5acc9e51d8 100644 --- a/scripts/node_integration_tests/playbooks/base.py +++ b/scripts/node_integration_tests/playbooks/base.py @@ -1,4 +1,4 @@ -from functools import partial +from functools import partial, wraps from pathlib import Path import re import sys @@ -7,6 +7,8 @@ import traceback import typing +from bidict import bidict + from twisted.internet import reactor, task from twisted.internet.error import ReactorNotRunning from twisted.internet import _sslverify # pylint: disable=protected-access @@ -35,10 +37,71 @@ def print_error(error): print(f"Error: {error}") +def catch_and_print_exceptions(f): + @wraps(f) + def wrapper(*args, **kwargs): + try: + f(*args, **kwargs) + except: + traceback.print_exc() + return wrapper + + class NodeTestPlaybook: INTERVAL = 1 RECONNECT_COUNTDOWN_INITIAL = 10 + def __init__(self, config: 'TestConfigBase') -> None: + self.config = config + + def setup_datadir( + node_id: NodeId, + node_configs: + 'typing.Union[NodeConfig, typing.List[NodeConfig]]') \ + -> None: + if isinstance(node_configs, list): + datadir: typing.Optional[str] = None + for node_config in node_configs: + if node_config.datadir is None: + if datadir is None: + datadir = helpers.mkdatadir(node_id.value) + node_config.datadir = datadir + else: + if node_configs.datadir is None: + node_configs.datadir = helpers.mkdatadir(node_id.value) + + for node_id, node_configs in self.config.nodes.items(): + setup_datadir(node_id, node_configs) + + self.output_path = tempfile.mkdtemp( + prefix="golem-integration-test-output-") + helpers.set_task_output_path(self.config.task_dict, self.output_path) + + self.nodes: 'typing.Dict[NodeId, Popen]' = {} + self.output_queues: 'typing.Dict[NodeId, Queue]' = {} + self.nodes_ports: typing.Dict[NodeId, int] = {} + self.nodes_keys: bidict[NodeId, str] = bidict() + self.nodes_exit_codes: typing.Dict[NodeId, typing.Optional[int]] = {} + + self._loop = task.LoopingCall(self.run) + self.start_time: float = 0 + self.exit_code = 0 + self.current_step = 0 + self.known_tasks: typing.Optional[typing.Set[str]] = None + self.task_id: typing.Optional[str] = None + self.nodes_started = False + self.task_in_creation = False + self.subtasks: typing.Dict[NodeId, typing.Set[str]] = {} + + self.reconnect_attempts_left = 7 + self.reconnect_countdown = self.RECONNECT_COUNTDOWN_INITIAL + self.has_requested_eth: bool = False + self.retry_counter = 0 + self.retry_limit = 128 + + self.start_nodes() + self.started = True + @property def task_settings_dict(self) -> dict: return tasks.get_settings(self.config.task_settings) @@ -85,10 +148,12 @@ def next(self): self._success() return self.current_step += 1 + self.retry_counter = 0 def previous(self): assert (self.current_step > 0), "Cannot move back past step 0" self.current_step -= 1 + self.retry_counter = 0 def _wait_gnt_eth(self, node_id: NodeId, result): gnt_balance = helpers.to_ether(result.get('gnt')) @@ -97,11 +162,15 @@ def _wait_gnt_eth(self, node_id: NodeId, result): if gnt_balance > 0 and eth_balance > 0 and gntb_balance > 0: print("{} has {} total GNT ({} GNTB) and {} ETH.".format( node_id.value, gnt_balance, gntb_balance, eth_balance)) + # FIXME: Remove this sleep when golem handles it ( #4221 ) + if self.has_requested_eth: + time.sleep(30) self.next() else: print("Waiting for {} GNT(B)/converted GNTB/ETH ({}/{}/{})".format( node_id.value, gnt_balance, gntb_balance, eth_balance)) + self.has_requested_eth = True time.sleep(15) def step_wait_for_gnt(self, node_id: NodeId): @@ -283,13 +352,20 @@ def step_verify_output(self): print("Failed to find the output.") self.fail() - def step_get_subtasks(self, node_id: NodeId = NodeId.requestor): + def step_get_subtasks(self, node_id: NodeId = NodeId.requestor, + statuses: typing.Set[str] = {'Finished'}): def on_success(result): - self.subtasks = { - s.get('subtask_id') + subtasks = { + self.nodes_keys.inverse[s['node_id']]: s.get('subtask_id') for s in result - if s.get('status') == 'Finished' + if s.get('status') in statuses } + for k, v in subtasks.items(): + if k not in self.subtasks: + self.subtasks[k] = {v} + else: + self.subtasks[k].add(v) + if not self.subtasks: self.fail("No subtasks found???") self.next() @@ -306,10 +382,10 @@ def on_success(result): for p in result if p.get('payer') == self.nodes_keys[from_node] } - unpaid = self.subtasks - payments + unpaid = self.subtasks[node_id] - payments if unpaid: print("Found subtasks with no matching payments: %s" % unpaid) - self.fail() + time.sleep(3) return print("All subtasks accounted for.") @@ -402,8 +478,8 @@ def call(self, node_id: NodeId, method: str, *args, port=node_config.rpc_port, datadir=node_config.datadir, *args, - on_success=on_success, - on_error=on_error, + on_success=catch_and_print_exceptions(on_success), + on_error=catch_and_print_exceptions(on_error), **kwargs, ) @@ -446,6 +522,9 @@ def run(self): self.fail("A node exited abnormally.") try: + self.retry_counter += 1 + if self.retry_counter >= self.retry_limit: + raise Exception(f"Step tried {self.retry_limit} times, failing") method = self.current_step_method return method(self) except Exception as e: # noqa pylint:disable=too-broad-exception @@ -456,54 +535,6 @@ def run(self): self.fail() return - def __init__(self, config: 'TestConfigBase') -> None: - self.config = config - - def setup_datadir( - node_id: NodeId, - node_configs: - 'typing.Union[NodeConfig, typing.List[NodeConfig]]') \ - -> None: - if isinstance(node_configs, list): - datadir: typing.Optional[str] = None - for node_config in node_configs: - if node_config.datadir is None: - if datadir is None: - datadir = helpers.mkdatadir(node_id.value) - node_config.datadir = datadir - else: - if node_configs.datadir is None: - node_configs.datadir = helpers.mkdatadir(node_id.value) - - for node_id, node_configs in self.config.nodes.items(): - setup_datadir(node_id, node_configs) - - self.output_path = tempfile.mkdtemp( - prefix="golem-integration-test-output-") - helpers.set_task_output_path(self.config.task_dict, self.output_path) - - self.nodes: 'typing.Dict[NodeId, Popen]' = {} - self.output_queues: 'typing.Dict[NodeId, Queue]' = {} - self.nodes_ports: typing.Dict[NodeId, int] = {} - self.nodes_keys: typing.Dict[NodeId, str] = {} - self.nodes_exit_codes: typing.Dict[NodeId, typing.Optional[int]] = {} - - self._loop = task.LoopingCall(self.run) - self.start_time: float = 0 - self.exit_code = 0 - self.current_step = 0 - self.known_tasks: typing.Optional[typing.Set[str]] = None - self.task_id: typing.Optional[str] = None - self.nodes_started = False - self.task_in_creation = False - self.subtasks: typing.Optional[typing.Set[str]] = None - - self.reconnect_attempts_left = 7 - self.reconnect_countdown = self.RECONNECT_COUNTDOWN_INITIAL - - self.start_nodes() - self.started = True - def start(self) -> None: self.start_time = time.time() d = self._loop.start(self.INTERVAL, False) diff --git a/scripts/node_integration_tests/playbooks/concent/additional_verification/playbook.py b/scripts/node_integration_tests/playbooks/concent/additional_verification/playbook.py index d005d48834..010979c638 100644 --- a/scripts/node_integration_tests/playbooks/concent/additional_verification/playbook.py +++ b/scripts/node_integration_tests/playbooks/concent/additional_verification/playbook.py @@ -103,6 +103,8 @@ def step_wait_settled(self): self.next() return + time.sleep(10) + if datetime.datetime.now() > self.concent_verification_timeout: self.fail("Concent verification timed out... ") diff --git a/scripts/node_integration_tests/playbooks/concent/additional_verification/test_config.py b/scripts/node_integration_tests/playbooks/concent/additional_verification/test_config.py index 5065895562..9191ea3e4f 100644 --- a/scripts/node_integration_tests/playbooks/concent/additional_verification/test_config.py +++ b/scripts/node_integration_tests/playbooks/concent/additional_verification/test_config.py @@ -1,7 +1,8 @@ -from ...test_config_base import TestConfigBase, NodeId +from ..concent_config_base import ConcentTestConfigBase +from ...test_config_base import NodeId -class TestConfig(TestConfigBase): +class TestConfig(ConcentTestConfigBase): def __init__(self): super().__init__() self.nodes[NodeId.requestor].script = 'requestor/reject_results' diff --git a/scripts/node_integration_tests/playbooks/concent/concent_base.py b/scripts/node_integration_tests/playbooks/concent/concent_base.py index 2f72a7737a..a816a84696 100644 --- a/scripts/node_integration_tests/playbooks/concent/concent_base.py +++ b/scripts/node_integration_tests/playbooks/concent/concent_base.py @@ -4,13 +4,17 @@ from scripts.node_integration_tests import helpers from ..base import NodeTestPlaybook -from ..test_config_base import NodeId +from ..test_config_base import NodeId, TestConfigBase if typing.TYPE_CHECKING: import queue class ConcentTestPlaybook(NodeTestPlaybook): + def __init__(self, config: 'TestConfigBase') -> None: + super().__init__(config) + self.retry_limit = 256 + def step_clear_output(self, node_id: NodeId): helpers.clear_output(self.output_queues[node_id]) self.next() diff --git a/scripts/node_integration_tests/playbooks/concent/concent_config_base.py b/scripts/node_integration_tests/playbooks/concent/concent_config_base.py new file mode 100644 index 0000000000..093f9fc722 --- /dev/null +++ b/scripts/node_integration_tests/playbooks/concent/concent_config_base.py @@ -0,0 +1,8 @@ +from ..test_config_base import TestConfigBase + + +class ConcentTestConfigBase(TestConfigBase): + def __init__(self): + super().__init__() + for node_config in self.nodes.values(): + node_config.concent = 'staging' diff --git a/scripts/node_integration_tests/playbooks/concent/force_accept/playbook.py b/scripts/node_integration_tests/playbooks/concent/force_accept/playbook.py index 28cb4321e7..2e9045aefd 100644 --- a/scripts/node_integration_tests/playbooks/concent/force_accept/playbook.py +++ b/scripts/node_integration_tests/playbooks/concent/force_accept/playbook.py @@ -57,6 +57,7 @@ def on_success(result): 'Concent request failed', 'Problem interpreting', 'ForceSubtaskResultsRejected', + 'SubtaskResultsSettled', ] sra_trigger = [ @@ -74,7 +75,7 @@ def on_success(result): if log_match: match = log_match.group(0) if any([t in match for t in concent_fail_triggers]): - self.fail("Provider<->Concent comms failure: %s " % match) + self.fail("Force accept comms failure: %s " % match) return if any([t in match and 'Concent Message received' in match for t in sra_trigger]): diff --git a/scripts/node_integration_tests/playbooks/concent/force_accept/test_config.py b/scripts/node_integration_tests/playbooks/concent/force_accept/test_config.py index 87169f0d75..bd6f976268 100644 --- a/scripts/node_integration_tests/playbooks/concent/force_accept/test_config.py +++ b/scripts/node_integration_tests/playbooks/concent/force_accept/test_config.py @@ -1,7 +1,8 @@ -from ...test_config_base import TestConfigBase, NodeId +from ..concent_config_base import ConcentTestConfigBase +from ...test_config_base import NodeId -class TestConfig(TestConfigBase): +class TestConfig(ConcentTestConfigBase): def __init__(self): super().__init__() self.nodes[NodeId.requestor].script = 'requestor/no_sra' diff --git a/scripts/node_integration_tests/playbooks/concent/force_download/test_config.py b/scripts/node_integration_tests/playbooks/concent/force_download/test_config.py index b9fc85d0e5..dbd2d21b30 100644 --- a/scripts/node_integration_tests/playbooks/concent/force_download/test_config.py +++ b/scripts/node_integration_tests/playbooks/concent/force_download/test_config.py @@ -1,7 +1,8 @@ -from ...test_config_base import TestConfigBase, NodeId +from ..concent_config_base import ConcentTestConfigBase +from ...test_config_base import NodeId -class TestConfig(TestConfigBase): +class TestConfig(ConcentTestConfigBase): def __init__(self): super().__init__() self.nodes[NodeId.requestor].script = 'requestor/fail_results' diff --git a/scripts/node_integration_tests/playbooks/concent/force_payment/playbook.py b/scripts/node_integration_tests/playbooks/concent/force_payment/playbook.py index a273b95d37..dab1d64328 100644 --- a/scripts/node_integration_tests/playbooks/concent/force_payment/playbook.py +++ b/scripts/node_integration_tests/playbooks/concent/force_payment/playbook.py @@ -102,10 +102,11 @@ def on_success(result): int(p.get('value')) for p in result if p.get('payer') == self.nodes_keys[NodeId.requestor] and - p.get('subtask') in self.subtasks + p.get('subtask') in self.subtasks[NodeId.provider] ])) if not self.expected_payment: - self.fail("No expected payments found for the task.") + print("No expected payments found for the task...") + return print("Expected payment: %s" % self.expected_payment) self.next() diff --git a/scripts/node_integration_tests/playbooks/concent/force_payment/test_config.py b/scripts/node_integration_tests/playbooks/concent/force_payment/test_config.py index c1e8bc2644..d0a99c013f 100644 --- a/scripts/node_integration_tests/playbooks/concent/force_payment/test_config.py +++ b/scripts/node_integration_tests/playbooks/concent/force_payment/test_config.py @@ -1,8 +1,8 @@ -from ...test_config_base import \ - TestConfigBase, make_node_config_from_env, NodeId +from ..concent_config_base import ConcentTestConfigBase +from ...test_config_base import make_node_config_from_env, NodeId -class TestConfig(TestConfigBase): +class TestConfig(ConcentTestConfigBase): def __init__(self): super().__init__() requestor_config = make_node_config_from_env(NodeId.requestor.value, 0) diff --git a/scripts/node_integration_tests/playbooks/concent/force_report/test_config.py b/scripts/node_integration_tests/playbooks/concent/force_report/test_config.py index ea00fea85b..c036ce147b 100644 --- a/scripts/node_integration_tests/playbooks/concent/force_report/test_config.py +++ b/scripts/node_integration_tests/playbooks/concent/force_report/test_config.py @@ -1,7 +1,8 @@ -from ...test_config_base import TestConfigBase, NodeId +from ..concent_config_base import ConcentTestConfigBase +from ...test_config_base import NodeId -class TestConfig(TestConfigBase): +class TestConfig(ConcentTestConfigBase): def __init__(self): super().__init__() self.nodes[NodeId.requestor].script = 'requestor/no_ack_rct' diff --git a/scripts/node_integration_tests/playbooks/golem/no_concent.py b/scripts/node_integration_tests/playbooks/golem/concent.py similarity index 80% rename from scripts/node_integration_tests/playbooks/golem/no_concent.py rename to scripts/node_integration_tests/playbooks/golem/concent.py index 6586d21ecc..fcc5e16818 100644 --- a/scripts/node_integration_tests/playbooks/golem/no_concent.py +++ b/scripts/node_integration_tests/playbooks/golem/concent.py @@ -5,4 +5,4 @@ class TestConfig(TestConfigBase): def __init__(self): super().__init__() for node_config in self.nodes.values(): - node_config.concent = 'disabled' + node_config.concent = 'staging' diff --git a/scripts/node_integration_tests/playbooks/golem/disabled_verification.py b/scripts/node_integration_tests/playbooks/golem/disabled_verification.py new file mode 100644 index 0000000000..f2a7128eef --- /dev/null +++ b/scripts/node_integration_tests/playbooks/golem/disabled_verification.py @@ -0,0 +1,18 @@ +from pathlib import Path + +from ..test_config_base import TestConfigBase, NodeId + + +THIS_DIR: Path = Path(__file__).resolve().parent + + +class TestConfig(TestConfigBase): + def __init__(self): + super().__init__() + self.nodes[NodeId.provider].opts = { + 'overwrite_results': str(THIS_DIR / "fake_result.png"), + } + + def update_task_dict(self): + super().update_task_dict() + self.task_dict['x-run-verification'] = 'disabled' diff --git a/scripts/node_integration_tests/playbooks/golem/fake_result.png b/scripts/node_integration_tests/playbooks/golem/fake_result.png new file mode 100644 index 0000000000000000000000000000000000000000..4912f7f124b64968bac623a363dca7ea3c748cb6 GIT binary patch literal 950 zcmb`GziX3W5XZlHV<84oR5IzHxELsic(V{CwGC*AlBJu|4V@I1;uaV64{*3a&`l6T zaCGhF3Azf7j)EYf+xdC+(k?!bkbCd$`|El0Y_YR78lD|W8qK$7cctEx-#-U^zHk1z zy(@L+7q^z1l#)p0}X~)gNEX}IXQn{Old74*KK{MPUEYhNyC}z_w!!j)kjjUR^Ram7J ztf&SI(WpcgJ&3^u8Z4Hp8EQ2r+|#|9KWM2UJkq0@T~yG_@J!FbJ))TP3a|9S9(f>> zZQ{T`B5;Umzz~f}M$rK=*g%6-ExFdF=43>*An^w+Rc2(7D|S&qGb*FVk)#O#U5pbn zZi$jSkjXZ2pb5h`L^WWDW)oTHfEa9`t6fo>rcKQ$Zj2!D2Q5`rae-gHEkE#roV>ri z`%qdxXrJEmjrDW9P}k&yMGKR)+Hx-#g$d+_AXqt)yEZocvA_tlS|E?*jqCyNW+ z+Hm}Iy6V<0E?!Po$37a)nCebX7(Ou^PfoFT^rPb;4-`ttKy>ajHKM7!4BLDyZ literal 0 HcmV?d00001 diff --git a/scripts/node_integration_tests/playbooks/golem/jpg/test_config.py b/scripts/node_integration_tests/playbooks/golem/jpg/test_config.py index 5a67824744..10e496aed9 100644 --- a/scripts/node_integration_tests/playbooks/golem/jpg/test_config.py +++ b/scripts/node_integration_tests/playbooks/golem/jpg/test_config.py @@ -1,6 +1,17 @@ +from scripts.node_integration_tests import helpers + + from ...test_config_base import TestConfigBase class TestConfig(TestConfigBase): def __init__(self): super().__init__(task_settings='jpg') + + def update_task_dict(self): + self.task_package = 'test_task_1' + super().update_task_dict() + self.task_dict['main_scene_file'] = helpers.scene_file_path( + task_package_name='test_task_1', + file_path='wlochaty3.blend', + ) diff --git a/scripts/node_integration_tests/playbooks/golem/rpc_test/no_concent/__init__.py b/scripts/node_integration_tests/playbooks/golem/json_serializer/__init__.py similarity index 100% rename from scripts/node_integration_tests/playbooks/golem/rpc_test/no_concent/__init__.py rename to scripts/node_integration_tests/playbooks/golem/json_serializer/__init__.py diff --git a/scripts/node_integration_tests/playbooks/golem/json_serializer/playbook.py b/scripts/node_integration_tests/playbooks/golem/json_serializer/playbook.py new file mode 100644 index 0000000000..83abffcd7e --- /dev/null +++ b/scripts/node_integration_tests/playbooks/golem/json_serializer/playbook.py @@ -0,0 +1,26 @@ +from functools import partial + +from ...base import NodeTestPlaybook +from ...test_config_base import NodeId + + +class Playbook(NodeTestPlaybook): + def step_check_bignum(self): + def on_success(result): + if result != (2**64 + 1337): + self.fail() + return + print("transferring bigints works correctly") + self.next() + + def on_error(error): + print(f"Error: {error}") + self.fail() + + return self.call(NodeId.requestor, 'test.bignum', on_success=on_success, + on_error=on_error) + + steps = ( + partial(NodeTestPlaybook.step_get_key, node_id=NodeId.requestor), + step_check_bignum, + ) + NodeTestPlaybook.steps diff --git a/scripts/node_integration_tests/playbooks/golem/json_serializer/test_config.py b/scripts/node_integration_tests/playbooks/golem/json_serializer/test_config.py new file mode 100644 index 0000000000..b4726aff63 --- /dev/null +++ b/scripts/node_integration_tests/playbooks/golem/json_serializer/test_config.py @@ -0,0 +1,13 @@ +from ...test_config_base import TestConfigBase, NodeId + + +class TestConfig(TestConfigBase): + def __init__(self): + super().__init__() + self.nodes[NodeId.requestor].script = 'json_serializer' + # if you remove crossbar-serializer flag below, test should fail with + # "WAMP message serialization error: huge unsigned int". + for node_config in self.nodes.values(): + node_config.additional_args = { + '--crossbar-serializer': 'json', + } diff --git a/scripts/node_integration_tests/playbooks/golem/lenient_verification/__init__.py b/scripts/node_integration_tests/playbooks/golem/lenient_verification/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/scripts/node_integration_tests/playbooks/golem/lenient_verification/playbook.py b/scripts/node_integration_tests/playbooks/golem/lenient_verification/playbook.py new file mode 100644 index 0000000000..98d797171f --- /dev/null +++ b/scripts/node_integration_tests/playbooks/golem/lenient_verification/playbook.py @@ -0,0 +1,66 @@ +from functools import partial + +from ...base import NodeTestPlaybook +from .test_config import NodeId + + +class Playbook(NodeTestPlaybook): + def step_wait_for_subtask_failed( + self, node_id: NodeId = NodeId.requestor): + def on_success(result): + subtasks = {} + if result: + subtasks = { + self.nodes_keys.inverse[s['node_id']]: s.get('subtask_id') + for s in result + if s.get('status') == 'Failure' + } + if subtasks: + print("subtask finished") + self.next() + else: + print("waiting for a subtask to finish") + + return self.call(node_id, 'comp.task.subtasks', self.task_id, + on_success=on_success) + + steps = ( + partial(NodeTestPlaybook.step_get_key, node_id=NodeId.requestor), + partial(NodeTestPlaybook.step_get_key, node_id=NodeId.provider), + partial(NodeTestPlaybook.step_get_key, node_id=NodeId.provider2), + + partial(NodeTestPlaybook.step_configure, node_id=NodeId.provider), + + partial(NodeTestPlaybook.step_get_network_info, + node_id=NodeId.requestor), + partial(NodeTestPlaybook.step_get_network_info, + node_id=NodeId.provider), + partial(NodeTestPlaybook.step_get_network_info, + node_id=NodeId.provider2), + + partial(NodeTestPlaybook.step_connect, node_id=NodeId.provider, + target_node=NodeId.requestor), + partial(NodeTestPlaybook.step_verify_connection, + node_id=NodeId.requestor, target_node=NodeId.provider), + + partial(NodeTestPlaybook.step_wait_for_gnt, node_id=NodeId.requestor), + NodeTestPlaybook.step_get_known_tasks, + NodeTestPlaybook.step_create_task, + NodeTestPlaybook.step_get_task_id, + NodeTestPlaybook.step_get_task_status, + step_wait_for_subtask_failed, + + partial(NodeTestPlaybook.step_connect, node_id=NodeId.provider2, + target_node=NodeId.requestor), + partial(NodeTestPlaybook.step_verify_connection, + node_id=NodeId.requestor, target_node=NodeId.provider2), + + NodeTestPlaybook.step_wait_task_finished, + NodeTestPlaybook.step_verify_output, + + partial(NodeTestPlaybook.step_get_subtasks, + statuses={'Finished', 'Failure'}), + + NodeTestPlaybook.step_verify_income, + partial(NodeTestPlaybook.step_verify_income, node_id=NodeId.provider2), + ) diff --git a/scripts/node_integration_tests/playbooks/golem/lenient_verification/test_config.py b/scripts/node_integration_tests/playbooks/golem/lenient_verification/test_config.py new file mode 100644 index 0000000000..a9a681fddd --- /dev/null +++ b/scripts/node_integration_tests/playbooks/golem/lenient_verification/test_config.py @@ -0,0 +1,24 @@ +from aenum import extend_enum +from pathlib import Path + +from ...test_config_base import TestConfigBase, NodeId, \ + make_node_config_from_env + + +extend_enum(NodeId, 'provider2', 'provider2') + +THIS_DIR: Path = Path(__file__).resolve().parent + + +class TestConfig(TestConfigBase): + def __init__(self): + super().__init__() + self.nodes[NodeId.provider].opts = { + 'overwrite_results': str(THIS_DIR.parent / "fake_result.png"), + } + self.nodes[NodeId.provider2] = make_node_config_from_env( + NodeId.provider2.value, 2) + + def update_task_dict(self): + super().update_task_dict() + self.task_dict['x-run-verification'] = 'lenient' diff --git a/scripts/node_integration_tests/playbooks/golem/multinode_regular_run/playbook.py b/scripts/node_integration_tests/playbooks/golem/multinode_regular_run/playbook.py index b5a4d9f080..26edb84257 100644 --- a/scripts/node_integration_tests/playbooks/golem/multinode_regular_run/playbook.py +++ b/scripts/node_integration_tests/playbooks/golem/multinode_regular_run/playbook.py @@ -24,7 +24,8 @@ def on_success(result): def step_verify_incomes(self): paid_subtasks = reduce(or_, self.paid_subtasks.values()) - unpaid = self.subtasks - paid_subtasks + all_subtasks = reduce(or_, self.subtasks.values()) + unpaid = all_subtasks - paid_subtasks if unpaid: print("Found subtasks with no matching payments: %s" % unpaid) diff --git a/scripts/node_integration_tests/playbooks/golem/multinode_regular_run/test_config.py b/scripts/node_integration_tests/playbooks/golem/multinode_regular_run/test_config.py index 95deb99bd2..b435546b9a 100644 --- a/scripts/node_integration_tests/playbooks/golem/multinode_regular_run/test_config.py +++ b/scripts/node_integration_tests/playbooks/golem/multinode_regular_run/test_config.py @@ -11,4 +11,7 @@ def __init__(self): super().__init__() self.nodes[NodeId.provider2] = make_node_config_from_env( NodeId.provider2.value, 2) + + def update_task_dict(self): + super().update_task_dict() self.task_dict['subtasks_count'] = 2 diff --git a/scripts/node_integration_tests/playbooks/golem/nested_column/__init__.py b/scripts/node_integration_tests/playbooks/golem/nested_column/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/scripts/node_integration_tests/playbooks/golem/nested_column/playbook.py b/scripts/node_integration_tests/playbooks/golem/nested_column/playbook.py new file mode 100644 index 0000000000..c09bf58978 --- /dev/null +++ b/scripts/node_integration_tests/playbooks/golem/nested_column/playbook.py @@ -0,0 +1 @@ +from ..regular_run_stop_on_reject.playbook import Playbook # noqa pylint:disable=unused-import diff --git a/scripts/node_integration_tests/playbooks/golem/nested_column/test_config.py b/scripts/node_integration_tests/playbooks/golem/nested_column/test_config.py new file mode 100644 index 0000000000..eb1764f30b --- /dev/null +++ b/scripts/node_integration_tests/playbooks/golem/nested_column/test_config.py @@ -0,0 +1,14 @@ +from scripts.node_integration_tests import helpers + + +from ...test_config_base import TestConfigBase + + +class TestConfig(TestConfigBase): + def update_task_dict(self): + self.task_package = 'column' + super().update_task_dict() + self.task_dict['main_scene_file'] = helpers.scene_file_path( + task_package_name='column', + file_path='the_column.blend', + ) diff --git a/scripts/node_integration_tests/playbooks/golem/restart_failed_subtasks/__init__.py b/scripts/node_integration_tests/playbooks/golem/restart_failed_subtasks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/scripts/node_integration_tests/playbooks/golem/restart_failed_subtasks/playbook.py b/scripts/node_integration_tests/playbooks/golem/restart_failed_subtasks/playbook.py new file mode 100644 index 0000000000..882411ab96 --- /dev/null +++ b/scripts/node_integration_tests/playbooks/golem/restart_failed_subtasks/playbook.py @@ -0,0 +1,56 @@ +import time +import typing + +from ...base import NodeTestPlaybook +from ...test_config_base import NodeId + + +class Playbook(NodeTestPlaybook): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.previous_task_id = None + + def step_restart_failed_subtasks(self): + def on_success(result): + print(f'Restarted failed subtasks.' + f'task_id={self.previous_task_id}.') + self.next() + + return self.call(NodeId.requestor, + 'comp.task.subtasks.restart', + self.previous_task_id, + [], + on_success=on_success) + + def step_wait_task_timeout(self): + def on_success(result): + if result['status'] == 'Timeout': + print("Task timed out as expected.") + self.previous_task_id = self.task_id + self.task_id = None + self.next() + elif result['status'] == 'Finished': + print("Task finished unexpectedly, failing test :(") + self.fail() + else: + print("Task status: {} ... ".format(result['status'])) + time.sleep(10) + + return self.call(NodeId.requestor, 'comp.task', self.task_id, + on_success=on_success) + + steps: typing.Tuple = NodeTestPlaybook.initial_steps + ( + NodeTestPlaybook.step_create_task, + NodeTestPlaybook.step_get_task_id, + NodeTestPlaybook.step_get_task_status, + step_wait_task_timeout, + NodeTestPlaybook.step_stop_nodes, + NodeTestPlaybook.step_restart_nodes, + ) + NodeTestPlaybook.initial_steps + ( + NodeTestPlaybook.step_get_known_tasks, + step_restart_failed_subtasks, + NodeTestPlaybook.step_get_task_id, + NodeTestPlaybook.step_get_task_status, + NodeTestPlaybook.step_wait_task_finished, + NodeTestPlaybook.step_verify_output, + ) diff --git a/scripts/node_integration_tests/playbooks/golem/restart_failed_subtasks/test_config.py b/scripts/node_integration_tests/playbooks/golem/restart_failed_subtasks/test_config.py new file mode 100644 index 0000000000..ed0e35cb55 --- /dev/null +++ b/scripts/node_integration_tests/playbooks/golem/restart_failed_subtasks/test_config.py @@ -0,0 +1,25 @@ +from ...test_config_base import ( + TestConfigBase, make_node_config_from_env, NodeId) + + +class TestConfig(TestConfigBase): + def __init__(self): + super().__init__() + provider_config = make_node_config_from_env(NodeId.provider.value, 0) + provider_config.script = 'provider/cannot_compute' + provider_config_2 = make_node_config_from_env(NodeId.provider.value, 0) + + requestor_config = make_node_config_from_env(NodeId.requestor.value, 1) + requestor_config_2 = make_node_config_from_env( + NodeId.requestor.value, 1) + requestor_config_2.script = 'requestor/always_accept_provider' + + self.nodes[NodeId.requestor] = [ + requestor_config, + requestor_config_2, + ] + + self.nodes[NodeId.provider] = [ + provider_config, + provider_config_2 + ] diff --git a/scripts/node_integration_tests/playbooks/golem/restart_frame/playbook.py b/scripts/node_integration_tests/playbooks/golem/restart_frame/playbook.py index 94c6f42425..684245b561 100644 --- a/scripts/node_integration_tests/playbooks/golem/restart_frame/playbook.py +++ b/scripts/node_integration_tests/playbooks/golem/restart_frame/playbook.py @@ -1,4 +1,3 @@ -from functools import partial import typing from ...base import NodeTestPlaybook diff --git a/scripts/node_integration_tests/playbooks/golem/rpc_test/mainnet/playbook.py b/scripts/node_integration_tests/playbooks/golem/rpc_test/mainnet/playbook.py index 5fc3b28f69..a230ea6b81 100644 --- a/scripts/node_integration_tests/playbooks/golem/rpc_test/mainnet/playbook.py +++ b/scripts/node_integration_tests/playbooks/golem/rpc_test/mainnet/playbook.py @@ -1,5 +1,5 @@ -from ..no_concent.playbook import Playbook as PlaybookBase +from ....base import NodeTestPlaybook -class Playbook(PlaybookBase): +class Playbook(NodeTestPlaybook): pass diff --git a/scripts/node_integration_tests/playbooks/golem/rpc_test/mainnet/test_config.py b/scripts/node_integration_tests/playbooks/golem/rpc_test/mainnet/test_config.py index 2f894ba2b3..e7de7de42f 100644 --- a/scripts/node_integration_tests/playbooks/golem/rpc_test/mainnet/test_config.py +++ b/scripts/node_integration_tests/playbooks/golem/rpc_test/mainnet/test_config.py @@ -1,4 +1,4 @@ -from ..no_concent.test_config import TestConfig as TestConfigBase +from ....test_config_base import TestConfigBase class TestConfig(TestConfigBase): diff --git a/scripts/node_integration_tests/playbooks/golem/rpc_test/no_concent/test_config.py b/scripts/node_integration_tests/playbooks/golem/rpc_test/no_concent/test_config.py deleted file mode 100644 index 9ab639dbfc..0000000000 --- a/scripts/node_integration_tests/playbooks/golem/rpc_test/no_concent/test_config.py +++ /dev/null @@ -1,8 +0,0 @@ -from ....test_config_base import TestConfigBase - - -class TestConfig(TestConfigBase): - def __init__(self): - super().__init__() - for node_config in self.nodes.values(): - node_config.concent = 'disabled' diff --git a/scripts/node_integration_tests/playbooks/golem/rpc_test/testnet/__init__.py b/scripts/node_integration_tests/playbooks/golem/rpc_test/testnet/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/scripts/node_integration_tests/playbooks/golem/rpc_test/no_concent/playbook.py b/scripts/node_integration_tests/playbooks/golem/rpc_test/testnet/playbook.py similarity index 100% rename from scripts/node_integration_tests/playbooks/golem/rpc_test/no_concent/playbook.py rename to scripts/node_integration_tests/playbooks/golem/rpc_test/testnet/playbook.py diff --git a/scripts/node_integration_tests/playbooks/golem/rpc_test/testnet/test_config.py b/scripts/node_integration_tests/playbooks/golem/rpc_test/testnet/test_config.py new file mode 100644 index 0000000000..625f51d6de --- /dev/null +++ b/scripts/node_integration_tests/playbooks/golem/rpc_test/testnet/test_config.py @@ -0,0 +1,5 @@ +from ....test_config_base import TestConfigBase + + +class TestConfig(TestConfigBase): + pass diff --git a/scripts/node_integration_tests/playbooks/golem/separate_hyperg.py b/scripts/node_integration_tests/playbooks/golem/separate_hyperg.py new file mode 100644 index 0000000000..3e8c9d70ec --- /dev/null +++ b/scripts/node_integration_tests/playbooks/golem/separate_hyperg.py @@ -0,0 +1,8 @@ +from ..test_config_base import TestConfigBase, NodeId + + +class TestConfig(TestConfigBase): + def __init__(self): + super().__init__() + self.nodes[NodeId.provider].hyperdrive_port = 3283 + self.nodes[NodeId.provider].hyperdrive_rpc_port = 3293 diff --git a/scripts/node_integration_tests/playbooks/golem/task_timeout/playbook.py b/scripts/node_integration_tests/playbooks/golem/task_timeout/playbook.py index 5e6e709028..5e0da98f14 100644 --- a/scripts/node_integration_tests/playbooks/golem/task_timeout/playbook.py +++ b/scripts/node_integration_tests/playbooks/golem/task_timeout/playbook.py @@ -17,7 +17,7 @@ class Playbook(NodeTestPlaybook): * afterwards, the nodes are restarted, this time both as unmodified Golem nodes, * the timed-out task (with only one subtask successfully finished) is then - restarted using the `comp.task.restart_subtasks` call - the result is a + restarted using the `comp.task.subtasks.restart` call - the result is a new task which contains one already-completed subtask and another, failed one, * the Provider should then be able to pick up that second subtask, thus @@ -29,12 +29,15 @@ class Playbook(NodeTestPlaybook): def step_wait_subtask_completed(self): def on_success(result): if result: - statuses = map(lambda s: s.get('status'), result) - if any(map(lambda s: s == 'Finished', statuses)): + statuses = [s.get('status') for s in result] + if 'Finished' in statuses: print("First subtask finished") self.next() - return - print("Subtasks status: {}".format(list(statuses))) + elif 'Failure' in statuses: + print('Subtask failed :(') + self.fail("Got status 'Failure', expected 'Finished'.") + else: + print("Subtasks status: {}".format(statuses)) time.sleep(10) @@ -66,7 +69,7 @@ def on_success(result): if not self.task_in_creation: print("Restarting subtasks for {}".format(self.previous_task_id)) self.task_in_creation = True - return self.call(NodeId.requestor, 'comp.task.restart_subtasks', + return self.call(NodeId.requestor, 'comp.task.subtasks.restart', self.previous_task_id, [], on_success=on_success) diff --git a/scripts/node_integration_tests/playbooks/golem/zero_price.py b/scripts/node_integration_tests/playbooks/golem/zero_price.py index e4847d8b23..2fbc5939e8 100644 --- a/scripts/node_integration_tests/playbooks/golem/zero_price.py +++ b/scripts/node_integration_tests/playbooks/golem/zero_price.py @@ -10,4 +10,7 @@ def __init__(self): self.nodes[NodeId.requestor].opts = { 'max_price': 0, } + + def update_task_dict(self): + super().update_task_dict() self.task_dict['bid'] = 0 diff --git a/scripts/node_integration_tests/playbooks/test_config_base.py b/scripts/node_integration_tests/playbooks/test_config_base.py index 370a039b6b..e7a94b8fbd 100644 --- a/scripts/node_integration_tests/playbooks/test_config_base.py +++ b/scripts/node_integration_tests/playbooks/test_config_base.py @@ -20,16 +20,19 @@ class NodeConfig: def __init__(self) -> None: - self.concent = 'staging' + self.additional_args: Dict[str, Any] = {} + self.concent = 'disabled' # if datadir is None it will be automatically created self.datadir: Optional[str] = None self.log_level: Optional[str] = None self.mainnet = False self.opts: Dict[str, Any] = {} - self.password = 'dupa.8' + self.password = 'goleM.8' self.protocol_id = 1337 self.rpc_port = 61000 self.script = 'node' + self.hyperdrive_port: Optional[int] = None + self.hyperdrive_rpc_port: Optional[int] = None def make_args(self) -> Dict[str, Any]: args = { @@ -45,6 +48,12 @@ def make_args(self) -> Dict[str, Any]: args['--log-level'] = self.log_level if self.mainnet: args['--mainnet'] = None + if self.hyperdrive_port: + args['--hyperdrive-port'] = self.hyperdrive_port + if self.hyperdrive_rpc_port: + args['--hyperdrive-rpc-port'] = self.hyperdrive_rpc_port + args.update(self.additional_args) + return args def __repr__(self) -> str: @@ -88,8 +97,11 @@ def __init__(self, *, task_settings: str = 'default') -> None: self.nodes[node_id] = make_node_config_from_env(node_id.value, i) self._nodes_index = 0 self.nodes_root: 'Optional[Path]' = None - self.task_package = 'test_task_1' + self.task_package = 'cubes' self.task_settings = task_settings + self.update_task_dict() + + def update_task_dict(self): self.task_dict = helpers.construct_test_task( task_package_name=self.task_package, task_settings=self.task_settings, diff --git a/scripts/node_integration_tests/pytest.ini b/scripts/node_integration_tests/pytest.ini new file mode 100644 index 0000000000..346aa00b05 --- /dev/null +++ b/scripts/node_integration_tests/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +norecursedirs = nodes playbooks rpc tasks diff --git a/scripts/node_integration_tests/run_test.py b/scripts/node_integration_tests/run_test.py index 0b6fe98beb..e63b6a5231 100755 --- a/scripts/node_integration_tests/run_test.py +++ b/scripts/node_integration_tests/run_test.py @@ -111,6 +111,8 @@ def override_config(config: 'TestConfigBase', args: argparse.Namespace) -> None: else: node_configs.datadir = datadir + config.update_task_dict() + def main(): args = parse_args() diff --git a/scripts/node_integration_tests/tasks/__init__.py b/scripts/node_integration_tests/tasks/__init__.py index ce3961a8e2..af8fdc4706 100644 --- a/scripts/node_integration_tests/tasks/__init__.py +++ b/scripts/node_integration_tests/tasks/__init__.py @@ -23,8 +23,8 @@ '2_short': { 'type': "Blender", 'name': 'test task', - 'timeout': "0:04:00", - "subtask_timeout": "0:01:30", + 'timeout': "0:08:00", + "subtask_timeout": "0:07:30", "subtasks_count": 2, "bid": 1.0, "resources": [], @@ -106,6 +106,42 @@ }, 'compute_on': 'gpu', }, + '4k': { + 'type': "Blender", + 'name': 'test task', + 'timeout': "0:20:00", + "subtask_timeout": "0:19:50", + "subtasks_count": 1, + "bid": 1.0, + "resources": [], + "options": { + "output_path": '', + "format": "PNG", + "resolution": [ + 4096, + 2160, + ] + } + }, + '3k-low-samples': { + 'type': "Blender", + 'name': 'test task', + 'timeout': "0:20:00", + "subtask_timeout": "0:19:50", + "subtasks_count": 1, + "bid": 1.0, + "resources": [], + "options": { + "output_path": '', + "format": "PNG", + "resolution": [ + 3072, + 1620, + ], + 'samples': 32, + } + }, + } diff --git a/scripts/node_integration_tests/tasks/column/assets/altocumulus.jpg b/scripts/node_integration_tests/tasks/column/assets/altocumulus.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6a64d56f77d60bb8640f917f231f72492a1fa77f GIT binary patch literal 113195 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%0ANTo%-D; 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

4-(D`CO$Bkr|UMC$dW{agA!T+Hda literal 0 HcmV?d00001 diff --git a/scripts/node_integration_tests/tasks/column/assets/column.blend b/scripts/node_integration_tests/tasks/column/assets/column.blend new file mode 100644 index 0000000000000000000000000000000000000000..fa341133b61e930ed629fd7011b165eee3c23b39 GIT binary patch literal 435592 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 MoyryQ^vK_T0F9P9hX4Qo literal 0 HcmV?d00001 diff --git a/scripts/node_integration_tests/tasks/column/assets/jello.blend b/scripts/node_integration_tests/tasks/column/assets/jello.blend new file mode 100644 index 0000000000000000000000000000000000000000..bdd3f5bece9c515d461c6d856341ab3235aa1488 GIT binary patch literal 466716 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
E4uJ-8eNzm5l5>C0qz{7$< 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%0ACyJIBbK#ap}lcN{rR@+aTvcF_a$>Hc&#|IrKN zxrWhN)4PotoRcmi-}BN<{3gmfT@U|L-{o5LY6U2_Q}{hSZKk}t(IY3M<5sO%b=E5S zi8rsL_3ELu{2fQ>r|^4Rx}oRhp5uCN=((vU_1@TXL(h%*Pd#;G?*+Vb4sWWLc2S?r z_?7QAa=eOsc2H*OJ(K#KM}At7XAyb>>Gt;!Q~Rg-TupyWGJ7-=z#(00yEJ#=-zRyj zB~NCf+o1LcJDg6wr_ej6_ng(WhPcF=HnbzMNZ zZN#5N_%_#A>uZiu&Pk`=M(MzQT{jc2^>Sm^T4sjw)tu2f76^ZYTcHtN(#WTgjPP*F zA9GIOYgoWTdv^CvcWVuI1A2C)+ov8N0bS{M!cPQ_C+v8}^CaBeX%%oATHl>k<6e#X z4#XGtYFsCiRv5fD-4?hVaD2KWzpDvbP1tI}RuguLV@{xq+v7SBI4PY<+^Lj*D&=d= zCUWm4$K)K;-=~u9G~iDB9?xF_x^SOP$er=s#p%1#U3uP(bY}o}=dF7Xe_PV6CFMOy zcQ4@Hgq_J-_aV)FNq;}!EOJo2?+^6he*o}6+z%qh2lKo;{=4$-+2np3az6+EI$%AZ zUQt~)xWv2Tx;u5dM>>~MA3_Tr3OuZq{yg000}lrt0h|mxvg)7S^eEhq1|9=kKzSRf z`D(Y}#I%XL`bn>{Hv?OM0a|sETXrwXU7fc2y{OkV;NE&k zM30>0{Lf4~$!8aFk0b4FV1N-CqWnF?UyOgR^NKOr$GiIpyM&&+4Uhnh-{XNx3Dk0q{cLMee&Z(uLzL0PK)nOL?yY27uQCZvfs1oXo830TR%~tb7wQvKL5z z#z5nHZ)Qm^kbo}FcZIFb@f#>z>q2Yd-1KJ8^Ao}J4@qwU-bxv71Kv&!nxF3ggx%lC z^IgDoz`KF>0Ph9fN8J0#>jOML=(0{^zrRcR6Z+u8gnfiG9|f+#{W0L623B^KLLIU{0#Uxa5JDa`U~K% zfL{W?0)7qr2KZ~>x4_>3e+&E_@b|z!0RIU56Yx9WpMie?{uTH=@CV@EfPV-62>b`| zpTK_s{|)>P@V~(S0jXH@mEdXv^(5AI%I6>yubhkO3l>%E%rt@L$2ScbQtQMz%b zbXz9K?SSKf6L|0TU5^Ccst-4$6T2RjPU5{az#T|$+=jc%^*d7bDTJR2oQ7NDsX1{c z{HJ$4+W1%V<<7iw7vQdhYwp~Q=NZ7=dFLKoDyuiG#eYxWUex2>UB{<0Nq-;UzJ%Qm zI19Kx&_~z>c`on};Gx7l%;l|4=ixq| zvK~$zkHGy%;2N;2c8jmjFOS0iXkdW3^q6YhgNI3f0k9F+1oQ)&fi1uQuocjFY~#5d zcr0)sumczbE&_G}yMV_5yMZCJTcF;Hz*5&1 z@TqW*@M>-+Wh~PUt;K_+tanA={0K5=*5%DhuUIM%nxSD==8Srx4uK->Nyb5?V@EX!z1H2Y^ z9q@X>-T=H2coXnu;9B4<#Jv@G8}N4E9l$$*cLCP{?*`rjycc*M@P6O}y!S!M{gB7% zy!0pdKMZJGKH~AxI1ac^KT7(K0UrlG0elkpQ{c~lKL^9{3BwJ_mdr z_yX|7E@23bQE&PZWAkM|v}v#B30xumuK)v35ihEZ*;mO+<60l5uaV!^fo}lc1il5_ z2z(p(4)9&zd%*XBzXW~&+ywj(_!019;3vROfuGUFpHsJ+asLANE8v&FuYg|zzXAT5 zI{X&+8{lv0pT7hCp74JF{*kbM0)B`4pMie?{uTH=VSfPrjW#U<{|@{S_z%MV6ZkLS zzk&ZD?f(M*2c&NBF3=710KLE};5NW&h^gav-WIqWaC~=fI)Sj;18<@}C-OW%ekXOu zIIh8e2SEGz$%NgJcTNFL1x^F*1e{KI>~D`qckX^fx(n&=3fv7i1Gqc!_W;(C_MX7K zx;0PA`hIM>cX#Z&XA*ZG;J(29fU|h#{(#m&AN~gb4+I_rJQz3|I0skRy+^+e!FKm)i87zUc8839ItG5q7*_aNuhX##)b(ak&HZTxqoya6cf(@bse zq939UCLOP})tg%M<>mO#Chh>wDPS6y0cL?Y^0@+-r<~w5y3+#Ti@dkg{W$Y%%lId$ zi`uR^x6f34yMGVz{z~@{rJa}#b?-@sdH2b#`^BU${i!iJF+GKJPwhSs|aHae9 z-su_L>fd@_MqAX!&!qfk5%+B1IpqCZ{LcgS;1>S4ity(HFCeV6@rAfwMEr|^m$)w7 z=`!m7QruUQ_GQ4!fmZ;p1YQNa8h8zG4e(mvb-XLg_IjRg0Nx0kMjmhK-kaV`KG!lg z-vYc9cpLEc?%nAfq6{po|< zm!uDMKR*3Q_oeB>-A_m#>3(ARX!n!S$GRKoK;p{5Bvr2IqGvh?f5*;F92Tzz65+3xPf}L=Wnn3T79m5{R;8>Xzy34 z?*PxQ@%%dQ4d9#Z|B3W1>T+B3*1b)U*s}>LO=9U z_G;GHPk8IIBAmdOwe*GHTNOtKXKQGoQG(0 z?Xjsx?~5lDzY81FbC@wZl)8J)Pdzoq*GM|IWZ&oDaC1JnjaZ0Z6`a_nv3uve>6x&g!(5 zwD;uQd-Xh%epS6!r+Yj7vvL`$)0z11liPY8Sqln?~%kk3V1Z>9+UIFfN=G(=D^0D7v?^OhjM(x_4m9898|_p zaj`dE&OCWwet!+plT&Z8J!hoNwfwZFZy}EXU@M^gWgE}!z+-_6fgQjgunma}Tm@s% zoh|})((Zlc1>x14=S#IKR&(reglW#-860&I@9zeNfD3!XWAvsyyniwNy`;M%GTbh| z`?5T~n)@$L`+8o1B>I)9=F70`BdIIk}5qJ{sH+Wvw^O`i=a}Dw#mCfl? zU<4Qi#(;5PqUW_~lJqU$a^5)rOi}JMa2ScvOwa3c+v5G%o;Reqo;MPXtPGg%c~g#$ z^|Qcxi-6YY5_v86yqS^fO$Tvb+I_9vl25JlyoH^w-6tBOLwTIeMv8Qpyr0ZFPvI%N z^wgef)6>Z7>G-b%o&l^7|4iUnz_Wqp0MBJjKdVy|m{Yd42Sv&x!b#5&m-8_%$RJ@qS9L!2e1>`0!Oc zUkyBoJ~$gGSFF?P(rY|c@2=L!HLT;;lJ0fn_j>Yv1MW8h+bH`@Jl_ml3!IH~O!!bZ z>Mi({ex5XM#l6fNx(CnL&)%D2uDmbL70rEU8`8dw=i7mI0BYMidA3M%%i)Wr{15(J%F?! z=Jc`f{u${D^wSr;mZJT)=iM*mJY&s$7~H1*4BYT#%D6Liyn%PVLfWqaUjx2Q{5L4? zo9r*&BF`Iff19xH@ceGiN2)x&N7(mC|ChiIaHDZbKLmdC|Cz@}t2`b^o<9bDLODOZ z)%W0OiT}ClEFHziGCurxy1C~Q=@;ao{c=FsBDWEqm-N2`enp*5W4@?wHE#xxn8w}* zZ9|;Gf5Y=^q^J?DxxpSzp1%bKELl~*A)f^P7TCr+1;6(9e5%@m{*Ls2?{vrtsjKq- z2lD<$=O5$pPo()B{(tWIbe?x9{R{qo1%wxWPhb22_rLM{cbV^LXx`7@5;yFuIuyqi+b1B(`|Dat)H`zFyF!TIwReVe2xcB0B#SQ2%JQjYk)fdC*%Jv?Y|?> zQ-GT&^Hl1idGI8p)UhYZe@427yhTTAooa4L{(LHVoCb(~xKr=vGmiQKdgCvqJCn~{ zfV%>Rk#66HF}xdP%#-&r^ZX3nyE|}?-YCWW+NAr9P zZ~?H<^%5`p&9tfao2kF|TWK@%r5g>wmfmltf!^<=t-U9vZNPTmvA~7oC;DYaZ^#e^ z@r!r8i28{4+R1YlAQ{5rc`kIe8rbrhsW+2ABorfGdD`U;$VJmVjm8 zAh1GShk(PB@nqmBz*F%*4R|{4XD||1;(i8cp9wsRK6y6q9N@XY^9Z|&bYEefKOgsZ z=$jW%pBMIiH@%4WUJOV#@Dk!)O8TpTmjN#aUeWtKWY6DEuk3wndKKwj4ZMcDo=m@e zrTe;cP48c(*Y>_4y$*PN@9okXfH#u&n}9d-{n=! zSMS%;b-j{<{SX@NhqU5{o=;~oPU?Hft%G*cTr9L(Z+bW7Yd*b)y!Q6|2)y_s-u;o^ zJ%@Mo-g}ArHETuq>wVPu{nY0Jy>Ck&dy%OnZtiX+8+bLnJ0t&enOg`(*Hl}eHB`EX)|BtbXwa#C(X^kFMz)SehK^v z_%)#Utb8<&enUMT$(zz){x$vkTi|biza`(l>-~B9d&>BS-kZr+vbFD~ySuNE`O=1e z0)9t6{|x*K@UOt{3Ht-^Z@|9;e+2#m_)p-!fd2;m2l!v$|A4fL{RZd;dVpSF6>ytX z@Tb6WxNi&0^?o0%WYqh1tA3G=U-eh%gjGR9-+mQ*CuvT?zlP@>fRlkc0;d3{0;d6Y z0!|0+O!~W!_O3kdhW`xU?)dLPd9R|3wLI@h+`WK%uev#%iT^&pUi#p^q`e<-7I1&! z`+x@k4+I_rJQz3|I0sm_>X&If={5l8u6k8^$SUz5k4+C<^^^24!p;NE2Odt|k6869 z_wgfFU59+}UFlJTJsOZrf!4@l$nOGRBk4Es><2dE-okSL*ou1_upM|T?_CJ&00sf^ zvKR5(N!%{ralmfcJH&GjaPcbbA?U^k+Xw6?>=M#Hp68{&6L3EfkgV%T_#41w)OVP$ z^9gI>9|1;zF<=~+049lV@w|N1uaQ2NeWd7}LVt6B_$go-m;q*iIp7LFx(w+tq|cbg zzW^)(OTaR45Llt@hXC#Uhj~63cna`T;Az0qNpmIe4B(l-vw&v<&jFqbJP)`Ecs}p~ z;Dx;NBH+cqOMsUWb~W%a;N`$82zw>)D&W8fHwkfBL2<5wZL0| zw*qei)Q@i`{vGc3y533jk?^SW3zF&o23q4ctjyndT|a^K^-hlib^+vb-74{ue+}L| zKD~R@@##Ipy%%^N@P6O}zz2a3{lCV}0z8VOYq-^&WM(o>0t5^0?hZi*cM0z97Tn!+ zad&rjUB_Y3UEJLkXZg?V&V*gqeg7}db5h+Uw{A&SSC3i%5x=;TsQ0?S?7wm>Tuow2hemDRJ;Sd~#Bk1xy{J`~(@Dr3~W9bo5wC7}sRkP1?y3OGJr!uVuYxQOl*M9>Nto>%eR%F|5Da#3Sj_iQVRUm^7{3BH zc)*KWEOCcWhz)Uh?&1(GF4tiY4<=&Q23_NGodCClkcjJWNX&H-NXqpS^58Kf}NDmnxBV@9Ks?3nZ5~8wN+$tM(*&zq|=7e038}cA8 zFJ?Z-4+Wqg{t7{1h=3wc6pGx0 zD*meBr#jStnh=S5`Bh1Fo1e!uK+?zwBO08Pp-jeH9$Zu_V zq1tfY7C-Gk){Y$_kJ@v65VM0NLUpvf;#qk`#du}Z;ZC@9hAz+*WDKR7AzN0)=*D=c zw4)K!uPE%hN5|X4@|wC%TT8ss50md%X)BWIZ@6bv4}ZO(5A=n8&>sfCKo|sr(RB#s zP#A`s;UHxi$x56wtjZaIJ|p4VxDtpRr55q2s}gXu5>yO|J48#4=?$7=6*tlp8@cjRH9xv<*mbhJ#4`LM)X)g z*iBFnJvJkM3v7jL78x6r_eQwdZc&=u(8<(y2mW{BXBXyf*aLgPhu?h`P18*Mc^iL8 zhx8?FJg56DP1OM--T*BiT3=JgK$@fg?V#bty9oKxKRtxpi&_w}M1Qkh{c}z?LLIhP zv>;Yn(XbJ>-yf1Bjcv@)4Bf9kWXKo>z}#)1@l+TqnO9wIM*jI zPf|{&;53|pv)KKH+d0hhZ~-pDCAe&{X}?>7HCeU$2YyB0E0|Z|8pvGlb^P6cn{W$m z!yUM5u^Zv!xw(hm``A5zhwum)=a)icjpS5l3~X0X40e=*Ro}ywXsR5 zS|{Xn1{qK4g8!~oX>;GIZdR8TWtA}*x7OVnTkBz!?|!@LY4!I%a;sj}I9hM3^h@Jv ztX<|&jLY*VYujY4n}i9|`k=pmJ%BuyeYx*Pe6ogC+PkIHe@{R>Ej@1iaT@>wVGs<4 zAutq%!EhL1t*Ay?<7uO;@wL&`1lkzVG1i(;8)r?VjmLcgOaxh%HVJbwOo7wL|BkYn z3e#W?@lMB_0a5s!iB7X%Hh$+|&V_kg&xZxD5Efa(wZ+!NT2WR{QkzMuTP>kXmg2Sy z|I0D`aj!t`N<(&_T7{q0xKD#Qw3)Ll;cAUFRIMey*IDCg>ybGMzZ>vpmNR1(_}v7X zVGBrJRpX%@Mmn|<&o zZ%wCuARm9UmSNwPlsRKbxcvg@Xg7W(o}+LKS;s;82a(M9B%tNBX|fhRM4hmPsFQF? zbb>Q*7JkG3IXDj&;38ZCNzY}>-{B9q0#{)RVXl!6*WrdWS#-H3;a%}xd7hl-^}pPd zx=9*(v$8erfBH$P-6G!Ga0l+fJrLdQ!vlDT3~AeY^Ih}^y&l68Yh3k|JbPwMsd@jW zY`1!DO{Kj+#!G8z?G^GQ?SEpvhBwG~Yfbb2k&#w=N7=o{zw}`~5Wl=bKay9UxZVP+ zucxQ*U-=+mBwq0+vLwyvc^{{z9;c@sGwx!|pauVHc=792pNWG(ePm?R!v58t)PM2o zf8NbJ)okos0k#tvItOCTL60Cz3;2w2-%Og-X4XZUEi>=-ELt%47O;Z@oVKjQ%bGr} z-QWQ)#DWkAh1kf8192e?;z4}kP5|;QmhlqF`-I#l!tFMB8P0WL!X~k0(_(#-F1|;I zH<>NFmfYr6DIldShnC8gNJ|ZAY&o^GgiiNz<)W_Ul5Pogg?ATrnIVh7N@&?j zk(Ic!L3Udn?sIE7xXuYaR!7@`x0und$z?OY!E@W}^l5^b^WzR#NA~ zFY5%bt52Ny(Xjxs3PK@Uel3KR*4TdKC$Cl*_XsFLxT3^U%vOjs2TB#kt^~T4ga)*4 zKJH6FX+8BFO`MUeoUUVw(2{)+0dqW>vQT%?LPoi@XtuJzAeN6+xltxxRTQS~B#mJFjl%2fi zi);OHllkWX$Qo!Xp(XiOS^noRt{P;M{X%A)mo|Ga`pTG}|9vcZUzz$c#3u5KsiDXn zMjXQ-DdPzv&}k%up~WcN((xTV+E!80*Q%{Z>TtlD?~XIfZ~fgkyf9gnUP&|@M@!frB5vB_%xuk+L#>-Y|TQ*C8g75}yS zmpqt89!!ToTbMS(CVdum4Io$A32AGkt(uAZERcEN2)?nT3}$mb2Y++PAD#5dccIiz zd0$qh4Xa}-k6z_Tb9vItnn(Q2hXt?@7TGG$|2OX!bH4;#m%@JRmT|uvR=`SF1(#`8 zR}<$NTScB}HgSW<4P|%3T4b$*^{@dy8}YvhHp3R!3fo{i?0}uH3w-Q|up?tP@$P}W zuupV=1GpcALvR?5*ecWB&{yE!5AY-W1V6(s#3}tcA3G~#xan8yjuPe=94C)_?6%y2zr!DJ1+Kz1xDGeqCc53SRncxE z{|>s`CEj}=<6ZZ;e}Mf%cm$7a)iv4Mbd>8SAZdLHqVqF&4lm#(yn;XBHN1hh@DAR? z2lxn|;4k9-Y^$o#L@QpRN{0Xlgdnhh6>Ja;c5r|bT;K)|cp(;qKq$n9I1m@YARfer z1dtFCK{zCaB#;!6L2^g|DIpc4hBS~C(m{I202v_@WQHt|6|zBg$N@Pa7vzRKkQeen zekcG1p%4^?2q*$Yp%@g05>OI+Pzp*z87K?opgdH7ickqELlvkB)u1}mfSM2qwV*cC zfx1u+>O%u)2#ugIG=Zkj44Oj=XbG*LHMD`Y&<@%|2k02g`~qfY=mK4#8$>~O=m9;U z7xacc&=>kae;5D*VGs<4Autq%!EhJ>BViPbhA}V}#=&@)025&nOol1&9ZZF3Fdb&V zOqd0;VGhiNc`zRqz(QCAi(v^Yg=MfjxQ6j;uHf1Ru3)o`_ObIR9+1mMd(z2LOb+8^bz(&{vn_&xVg>A4McEC>9MSQ!1nIixnJF9r&i6hd8V{fpGrPo5A zTG-Vx?DnCP*~hMpy|lk(JA0aMp8b?fOT7;54|B0xAB0127>>aA(P@d&>S_ZBmj=>C z%lLu#e?<0A@H6}Zzrs;C2FKw9oP<;OJ&mk0a29@pb8sFm;C2x%!Da4$hd;Q!0$1T0 zT!$Mlp7!k~dfnpsHgfL(V>aq8VeY|w?jHm*j|z|AF+72%@C=^g{sMn5;T8M|ui*{6 zg?I2ixVH8I^CNtMzu+^l0Y+({Lx7zX&=6#2-jsVQ*dQ3}-~cDMzzrT=2408-ArK1b z7(0rMj5x@Mi=Qxv2k{{RB!on`g+pRUg5RXrC4=PLr@%DFj#$^ib)=<$N`-xDNW*}jOKt^O_g3S2I0$I7vhTrUv19Czx$PIbyb+o*g`3RTaUXQvgeKGpLcDE`B zg(dXe zPra>Ay{)fRw0l$~+@-&8TyMZTzX5Iy47bYot%6$?#@)>N+)#_n&Our$+#6|C?Tz_< zpsgdG>QIAp)`Uo?1+@uV2c$nN^6PS459;IAz}`@6h(3+%P56drg3KnybJW=0l(Cqm z+&AUE8FAo(|E3^1G{bBTEubZCtuR|d8@ue&Z?3hqx8V6}LHHJiymrJV^GfNpmVAG; z#J(l=^))kX^|kh-u><+u(H^4mP=-tS=IKNj|95?7u8V`{--Y-j-+in&sY1p)|1Xgzibw9cx@t=aVhkXxv)8yb73BC^I<=`I$gk93mwce7ZxCAAuO`D*B0Yv zmc6qU5!gXnLflJ0isYz3g#k42v+^?OnAUuoHGs zCcEw3v_0su7dM%&-G{j!4q$f>nTOyo9Kr5;Oj+F``_yIbRpv(zGZ%b^^%Ug-qsUw7 z0Y;HNxB3D3KjQx^*1)m=nP60$hYk_`eLl+j|=Csz0#10$1T0T*s{d`FR8Lro9*SK<3V*-`v}% z2e0X?j2>?FF)4+6NGy+()Qa_JM!99u)1*zo&RG za>d_}82dj7?_OJ>Ouy1^JqNhxwlPz3d>js6y(4y^#6{pWrX}49YPK zw_)f$j5xlG*=P<~k2IWo9gf@Ze{tjeM0``#2rU!vB!A;4gcK#<8PXlBDS|+#Kt2aK zWDSqGPTGQ<6>Ja;c5r~K$8JU%oLsv=z5(5s9`K@DEC_*6hz)TdE`&imkUfv_F)L#x z;OLZukVwL6Beigctp6It7~m-M9`!GJ|Mz~1#K;ic%>5LpsYhm>B_S?pbCWt`9AGr% z#JCM4bI8tq*=;?BxW*8d)C+$;^PZY)gz^83&ecWWF;< zODe~Bo)HOCMx{n48GrY&i(CA^=6OkjJ~IB67Qg8ry<;MAnC*S32JACBCK#)UsGIE*ECw%7UH2K&KPPfDelLfo1P?qOXzSkq!CGOW<*4%fayd@u!1WZM)gpqQl zf8m&hhH@`{{An;{$j<9*j_F2vvZGTD(k}DaDFbF8BV6UgE*Io>%;Z~qCOI%Orp)q0 z>zEfaALIvfK3n3Fb*{6t0uJ-+8kw_wNPQ@XA0Io|5ox4J<^&62XX?#51mcf?B2W~H zL2<_%qwGpJ=Ax&Jck%sBe^Xkj5aJMM`SCMLD~T-s*r$(c(Mj|y1=24~N}FNoHV@rq zYNfH4(}&6sPg%!&qda84fz0=2Wmmn2Q$5Pz$CT-RmQ3BtV_yL(f}D|339~XtdtHTk zQPr`~XdA0>T^(vbO^AeAP#fwv78zqBlK#5rAmt%?*2B$wC&)Wup;jNcqJymckh23C zI2JRG#P}+8!PHIkjZ_WESSja5gl`OOncrwa*rp(Bdm99f{8u{mzwcRdzd6X9lAM_#Ym;RiatrKQQtquB%W22WvXwezwnJa? z&a5j^CX%idUSh37|1D7qJz|b$uoHtBoEq>chXL^BTRef z03FGTPL5Ty(|#QS(Scrykw-FSLVpdn&ZMV{Lrz67pO>!q=>}2A=ng&bpVG0$c!qkC zu3p^dB1~^E%S)b*wZ`)=I?40wkEaiQ`#RQ9H>IwwH=gAdq}MDn_A4R3zeDQu2ICo& zz1Pw&@)~&|r!koA#wqJ2@`v#X~q=Ar+*e>_`K2gaa_`7BAfb>lgd z?-9mijD7daRXXCUaSrJdnRPh|Q~Egm=OH(BQQrSP+KTxw-eJz&PH=1`pSBY3R^k=e z4mFYZWNg+GP?mK#=6BB|hs?(^E{WX9j%`{>PNqf2n*c`zRqz(QCAi(v^Yg=MfDR=`R)W9Y*;rcpPxYhU&} zxYcThTdje$=(^6aLt9UJHyFCGHj(kvja|~FF>~DxfZa-}>_^AFn$s!I zy6k5W*`g=!aQgw?DF^U#06*p$YjJZq5Ayyxi2XtArLW*`hZrL!9*>buvwXcyo<~DA zYudREbsnNkKSbC=gbi1*@mq!GH4X=%;7FEJQxEoM^O&G(PA7vIx9N_g5e$}t&k$)n|@z%C`Eg4B@4 z`2%g5_+`8f(t+9biS9qr_M2`}CTWfM*kV&r8eUUQGpsTkcPy;;bMtmv2x{+VYY=d>p8!q}AA1)c$UpJm5i z4#)|)AUEWJypRv_N0))LrDi(M7=2mEdx@`_ewI4uew(Q!x`WdEJ6${Tu}=b8EXj7q-E2vady?`rym z|AU{4{|7&E%8@@k=KF0FHAL!x^qqai2}sfx5I4qK_-K&5#FfxR_7%6#<#Z$&$6&r9 zI`68G7gZ?>S=Up|`3Lzb?q};`i_S4zE!MuJH_C;~D<)>{rLX zdHxw=ygXAi2%A-1XHCm>{9ZTwn)*Bnvb#>m* zx;gJ?**JBI5q09ZqeU6<+>MrHwyly65|`BT1$=kP_f>b~%KX2`PeNL|(jNPGH}pVW zO5Ov5n5%Eebx+dX3w=zT%ySD$kw3k$>jQnEAM{7B0q8#vd4pguc0*t&*TY~q=^5b+ zRU=^(jK*#Zj3rzor)73%~M} zVU6=CPZj+wkh3*f(_Xc4Jd3uIZ)$%Xd(P9wHwDjFl=dRVeJ%NFwzn@yJL8PbSLB%~ zPukS=PB}@;e111LU-PV)=kLirh9tDj8_CN}q{YW6V-~^}=joNPC>fX9jO;B&THfOJ zM%(IqqiuujumiW9u*><5GBM}6Kr zHO;vH0=en$$+*X_Tn7@qAo+O||2|GplQGO=*dIquLE2vOPnUhaC$N(-T({EDUcOai zUx|Ouo36`P%Sq=`b&B}>``}I+`6ef^F<*wfv)q@)-~B-GM}HR9>4CcFb`JUH;R486 zQ5P{U!DYhz4u3dTdv-b1uDA+xl~6&h zP(6rK?rQTsvJl1!HVAgbCT_8p^;zukak0k-dncxhPrEREoQS6xe&WW+aAWVmpN#E# zU18|oBKGf@R)m|^W z$WOB^i;KT7BWzO2Q1oHUmBat!+ax}^CM4WO>bvxVZ_w6;^8_YFND8g6Nq=>qMmR#AFWfSVn=36c=m#P@3Bt0u8+vAQ zWx$U9A3Ei5Wh77h&xqM3=5%Eu9jqVXyHNVWvQIJ>ese<}$V-@fkRJ+gUl0npB6upy z_X@R06(;@&SEwoiMUhtwKgFR0lmwrvFyBgn#`jn$?n^@%C=2DFJV-kry$ExRiZzAQ z_X@5;^exTvYbz2*CHz&!tb$n;s&QQ%vj%2O%t+#?g;^WwKwYSZTYd0xvac;#XL&C) zApaWTF8f2@*dtUUmqRsnJy%l3Oh*ZY)M;3?mMAhXXpZx zaO;X)H;4k+n1qfdl}C4 z2pGxzC>RZ6U@VM-@i4)a6-~0zjAo@CnR=uQ_=v{lIMPJIPXZq&QR?KzWWr1#&8B-c zUB*zp!+okNyKX0x^eN2mDdq?H2AYQ3beI9<$a8Z{EQe8N=h<3NR@wELxXl7N%VRd? z9GL4$rOk8Y(C52y^1R5njhuBOc`Ebk@=P=C5kIBFGw$8~bpz*p)|* zgL@U)_$AmE?nYY0{AHq?ML=snSNydd_hj+4CU0$d4aj~b);oII)^)Q>l=tq_KQoJ0KY{uaONlh}-%v|n4OR}$}5S0OzX_Ht(BHvBGy?XUxO!YAH_Td$6+hu@+UA)!YMcnXF%q_WNu8_;R9&h-tr3AfoGjR&BFJA zfBx?&_s`%tynvVR3jT!G@CM|(4=Jy=T)%_&@IkJz`-tpMAbV^6!u*Uqi(!-oIs`x< z$eH6om~u{p|I7#r_f|J!QXp$egSoeZ1DxOjH+aAcu^MAU-5ORzgVR zj!@x{7`r5p)Gd3><@^t~O6D%BlDorI3P|aeu_-wp!R=NVA(MNO%Iuz_vOrd3W^)(Svx{tcoAK!D$h?f4 zztWj!AqTqTL_e9I%Y`ZRR>~4a}Ml3Guo2pD|I3``SOwuJ4-KFpG(tal-Wy}e7+4d` zrlhACG=~<@5?VoPXaj9Y`#Fa^yWg?jv!vb*IqjhXq(ontkLt*EC+KX*Wlo21U5&V8 z&Y3oW``&>vg6!`%$d$D9)G^9L=KVrcDWf0q<^0jodI%>0vr5Wn&+pMLL?K7|7u_*? zKu_ZA1-+pU^o4%V9|lnO2Erg1?3UAn%OI;vv@CON!w^HBF}J~cpe*iXac3-@_n^cl z{U=XAIo-u6#H=R8&Br^bls=TSC857JjPRyTGCsko0rF}DagD?rPuUNKtm$rtnt`90gp;*qvv_7^yDOrfTg^eQxiAmpS@3a!|9p2P z-fbeQvcAAwSzn0%MYu0U))H9C{W4gNj1`zGVHK=~HSQ|SUq-`YLO|ThR zDUr#j7U_1VE$({SR@jEycI57Wo#0<@wTtWBu!?k!<2!Q?*Lz_f?1uvoMLEcNyMtUG za#z(4yQ}F(+|~8(-8J+d(D_IB2|a#>PBLC3Yt&e$ zgS_J)? zom*WX-ixHSF*;u&?B!@VzoX|Ll+zXRji(;}RigD_uPLR-?vZbdiZKHqO%$(DlN_|R7 zn_`aF)ie4)cZl~c+yis&zdm(`d1y4fPrZ5|brd)0pM`Mpwe*|J@f3Ns85g4sXs9>D zUarh@TOJX{^wWqu2Ner4FvH{m^dfVMq^Q@SnG=Rr*5{z=}yCXP2C^4_{z&<|@tR;41iWu_wW~2_&xS|{1$GoSy;-;|?TKC9FZLIUhEzr}Jd}_;WTVx(* z?AWb>Jni)OoEFa7K&ciyPju{Ql^!yXU!7>@lN!%mpvp*?Offo| zbN`%KtB2t(I%LK@3&=NJR!mtFAn{}ad6p{kK9+Cy?A+&ooRAB0LmtQr`5-?OfP&~y z$TLh;QA1T>PcQoT4i(`ktBOE!`glb>z4c4XBBIB;2I`P>cK8T-O2l)~(BZJ*W@T*J(gLHiSmp zH^z^gjobvYDfi8wIkX@@TS6?92Vm)0cWL z>w~|nFP@4%(>$@+)$M-=c} zhcFLge+2vQJ?31D%$xt<8A6&E_rYy4VMF+J1}CsjuOW)%;RtZPQocT4QFusiTZYy>oPoFzY+c%oag=m$iC2iyz4Gv zcL~3j;dic+>ce&OJxU*l`zvr2uHk;&Gf=<5^-ZpC!EKPS^*fk%;T~?ckvT(+pq@z@ z&Ahsg{R7JMp=TsxM{3G-Nb4=2(m{IUWq{bcOEY51 zbDRnH%#a1Ldf6)w(+=_7gr3 z>mKMB!YFJIZ|3~?mv8O@xEJ(J&{w)9Q@18lev>Ibvwu_wIfd~T;hn_yrTCeo7xBty zz!dyV!5@7=WE91ZoB<_$s@}|Uxq%&gxJy|x4&imH;@-)63FMSSkBd0;WjM%(d~^QB z3_C6`4&M;wcw7p8-=&ndtSW8j$@|#*r4E%Pzsf;*+~m2cPT#kJ_dIbqR7I~tRl-f` z)@JIDQDn}LL}6J()=P{O2$8Hd&5f$c4 z>S5N02G9^1L1SnFO`#byhZfKhT0v`Q18t!lw1*DR5jsI<=mK4#8$>~O=s~)AdZ&`N zQ_0(@(RpjuxnAhk8)WTiAM&Ix^n?Bo$upQjpQaD+PN&?b6LvabYhTa$fTg;v5RYD7)ca*)>0durp%fm324rogvReU+YYs^%=SzP(Y3F$}iu@ zwag!w?yT?DE&s5iUx4gU#N{)79mgM*J_7b*ymP??iC62}4=x+KE`AfdiUF`fa z=+7W)f_G_jcvEI`G;T(Y%DAA9UkCa+AHTRSk4`gnE;@{qO=!T1=<+b-O(c#<-j(0T zG3_OeRsX7^>F(EY_20^S&A*iQ+Hd5WX%qR(j2n3@`k3`&vUmMAGA4UBd?UkOXC`|$ z(xyo{(GnSTXj9C+^hbRB;*nYYe1nr-84q~Dx1Nt*LGs&+o%GMA8aiy{&XhMT+P|E2 zAa0WHrTNvQ|Lwj#CjRM$K09LWXBhW8WA07;cE#Mwyb(ryUgqsJl24X(u0$6Cozb_`fq((j3A0y8!7v_)*f75jHH(6z@ z?%VfR6nVH9nM?31?dnp@Wgz-42Y($|!Szb)SJA$#hBdGj*1>w%fZImcgul()%N&dO z8-vmwv9=kzt*{NY!w%Sq|JyeI@65T?F50Etum|>f50JhCv`+_&_GzE@2rZhw4}VY} r%NpGM_&dP+CI#=LgP8vF{tuyp^xH1cUzKq)i>+kE62U~=c)Ip^GSzjN+7 zcjje|89pw1_@q9w`=38#5?%T=O?x!7==lHE(FGkT^8Aj%oau$-g`Ak+F?o2-WDiO7 zJa*U-nniwquZ^#RuhTpw_K!1V#w2V5U;eZchr*9Tl5 zaDBk_0oMmyA8>uZ^#RuhTpw_K!1V#w2V5U;eZchr*9Tl5aDBk_0oMmyA8>uZ^#Ruh zTpw_K!1V#w2V5U;eZchr*9Tl5aDBk_0oMmyA8>uZ^#RuhTpw_K!1aN);Dhe2i*Gq!?nFz|6AYoPdPfmYx=t{Y|$UvyjEYcy!G>i zZJP_-`ro>?(Et0ouIs;~^KJdpUu@I&G~CnX*45npzx8}z$BxGg+q3=mzVd+n)-zk$ zl>OcM-+H#^w)f^$YfSrp@{9ZRN4MVIrtHr;y7j+xePHk29pnSQ(tq6s+kUtHx32BE zeu+NdE&a`>+g{yw>woLp|JGY>(e=jH^=+Hhx4E|8t^ciWf39Qte_zq>UB9Ny+25`I z2gv^a*rDIKY*CxDzgz!X-~JEWwNBUn`n-PYwX@os{oVTC`u5-PIWw-)FS$CPkI!4E zUskYGuc^CBzx~!t`g?Ehw9!VhQ){NX^}l6&;K6$~JKA#cj0O6ng8BNS!uk3|Q)~3> zoEm-9CD-Z`rUvxumhRt9w5F|9*>3%B8QZJ4B7SxC5`E(Ix%#NfEA-J<%+iPFl+jtK zkC;@g`zOrO$6P#LuUg>jH;~cVp~=evl_m{p6ER_fcBFdQP8S-f$rN@%xm=#Z>1@m)@l> z+PqW0<=Id5o1gqhU-rOz`qF!UrPtm5ihk|J9eUaIj~!IoqyAg$PnS^-{3N*%I{C@> z$$rU}rF!X|dd=#G^?B=`)$8tfSzmnDoBEP_-qvrp?|pswgCFTjx4x?{ToPFWNH)Jj z`PKR#)%vmBu=xnFq*&V#TqmmQp~Saf$$GtJ)kFHi+jr>K-Tk^z?}PWhPi?>+ebJU* z>X(+S&_`c2M;|}!;5-w`${MwZ>_hGSX$9%Alj{Df`m$~B z>NoyukAB@eZ_r)K@&X zL%*ct27UNsS`U~~r+~y%mKt?* z&b2G_(yCQ@&El=LIy-;;c75S(FX-3b`?|jBkq`8$<&WtTsSg-*p}o#dxMF6*%q3g& z@*D1@am3H{nTzkx$6vKTzjVfOJ+F47e%3($q82hj9?8Mk*6M!?710!9^Al?uiR)-> zuTy$%?P|T^hI{nP2{Uy6lqzb|=j&syn5P%b*`VjmUZsz`c$Pjgr%Lyan{L!q&cioy zQefG(*Y%qo|3F{+#HV!b)dyu>O13oWv8m{w%!v!Hx#cPHmFLJ;exYyP`3JpZ(N_Jc znp^eSHP7o^k2l(MlRm+V?@Rqpu=tK_HXk91sPU3Tw>MSvWBCMJ<*5UPCBEXZ{CImy{PU1z1IML0O{7S@8Il( zH~#E(@{f1*)sOy4zjNpB^(A+Q_3`;j^($uGs*}60UM+Ftkr15U_xj%wc$qTB=2kb9 zU4OTJW%Z48uGHtQd!FWYA5#Cmoz{GQsR!?WM_+o+JNlJX>!_cfrF%zR-N4L!&d;vP zr)Pn=wd?4aP(VMUZ)U(0YPnec;O@qIU;C@RW!LZZZEt+3uYdXzJ*VhKedLq{`Y}Du z%&?Fqdg<2xM3E!DbBB$pyY|*6^_4&0O=~(~n%n=HZ2o~hxnz0hnp+x-`97zwd}y~m z_vYvI0mCK)IJ+LF_Vz7%;8lIyQyPL4!ttITw+2vVIxBfFCF&_Oc7*SWd=I1o0dqcnGmRBi!ht9Y4 zV^28Mmz^`S;lh#mRNqrlGA9-UuD#_2-GA9NjIYsdFWmHeFtuMH>!Dh>&x}WM* zvM2hKlpd#^?aRuU5g0P^sz3|2{j5)(Ln9`)3B`tbMqV=64~s9m^=VW_`odvKuL`rSuu%uj_OEh(Ny!FRsIVra7wH3EoR) zE@`~7W>e$YgT~Z#Kk;lIr?>KvNAhv9Ke$JH2pIK~`+~CTx9Ek}Zlp2$Yr1!Q;i8kz z9OQEnbVCKT(PwnqxF>}^F+E{n{>w5Ld=k>H+1B^6Iw;uh53}1L$T3_GrONs+W9ec9R zdhfD|CG{&GdPBdx;WPdEt-JLbANW8&;ncpZX@AZ>&vLr;pAm`i=y}eCbyLcg(0c9~ z>cda*^*H5h-v$1>K+cT1h9M(!15RSfufMBd@{C{z_5P@CCwaRPub@pyxw0zQ@W7j2 zQeU!NADbI6>BRjO|LfNO1R*{0m>%A7d2<>sued?42;Qrg1n%SY{Q7ZuwGBsF=K4*E zKmO!i-n`kX8>h`%uOHRz6d!0$={?YQe%57yV|$#@+I63^%(I+s{bxjCI5H<*Rad?8 z9Jgpy>*2ieCk38mh--f5(qcz}_x{n6=OYeS3?|Phdy*~DOe#SZ8h2y3# zZS;*TYlvN~m?_=*A2U?Wgk8IzM1A$tK;I!_0)2*zG3q;04jY{tI4I&4&+dOwz$&d*{}Bt1IsOcvHI1)_j=t)}iS$fi{IqLn|M+$KnSC=CsWdGR z&m$Q)-TI#(pch^=r7m#ii~8&}k2DUycy8mFmghRBpPg>B|GBlR>X&VMOYe4KPs1NL zs|nNQ{crS@b7)>r>0t*k}?|)yl^9&a<4> zd+xP@z?lOw7an^;FQ1ZZLkF<~NB8d2H$3wR?HPTE z=b5JOSZR~E&Ldu&tb=cmARm zE!e7`*?(jpfA*?|4Nrfl2X22>&&rt{ves|#)!h+lT=CGydhovY_Isv#Tv|`>+?yZK zSN!}fdJg#= zHgu=;JRyeKZ{xXqq#eKQ#ZMYXUOID8wDEoVm}!B`NhN{o$uk2^G_#g(u3tgVloxG& zOJ8*7FZE%gr$?0)V^LJHJW9an)_)Zp8SgVVyRMM>vY#sn{Zd7Q~h?b>8X9v7xo_DuZxnEExE@jXL)OAZGhGjTiWiNk6Zt3Xh)B*{qyIp z(PypLMr-rCjQt(c=H5zu{k8ON?;8EmnKv_*w*K2m8b9OnPpxVkKCx6EJGHK{TTIXR zBJGKj%p}wDh##k0|D%9;#jItp`RlI4L&cI$tPD9>B}1nt*&kM{VyOt$}6 zf8wLR(7NyAw10m-?crHP?{?1%ktn17_Zl#K;h5Y-jiaV6&_~j{zP-*F9^h=wxo~9N z>POzuH$M9r?GxIqR|X%_d!0MNxX1So(ve5`;&khOG$YWs?)ZFW;yi+oM-TabWZRX5aKk( z-tsc-`}#<~_qDI}s~6uNvd#sqS@%5GyRgrY@pZiiUR2lnoZ+pt7sRdqR?uU;CZmm@ z=l2V53)A|{ZM46)DnzU)v=6l5hI?P8{^4zX!_yz=)8}r~{g+;=Uv&8rz4yQ|0Yg0T z7A;uUP`&c!QEh+1>E|pQmp_Nb7W~fmEZsl3N}q6ffMhLbFl4mspF9$W)2;vUU`w{- zdJUHDC!OKD?yeoQe`Bxy_y^zU_r3bDepU4?G)Guvtp9+nxp3&Hsr6&8BHLeDMs3F| zebi-h^)b}XTw1iwXh)jkkK?{u|Kk8F7SgF@*EMW?_4oQypZ-&S?z3<7bx(dkW5ntZ z@tS_poSchG>KaF1Sw;3Qr}qkK^f6b?*T-B&-;kNUyru2Ws^nQtxBfFCNgO>+In#Ib z;=4jwlZpe}xAPINxXg*u8z)So_Mg5Tl2df8UU1DV`i#0e>3zW#x4&Ee<2B9!pr2SU zuW`fEzck((SpCR*wAQeP)_&iky~P`%jv10rE01__y7fN^AX|!c=90S_*F5$Cy(9b~ z%`x`qDK1VYm*EfJ~c3OR9?V4mY)q31+KsM4SELnVQbjmss6(U z+)x<$;Nu|1kgcT$>b@IB+Cdi!7Yo-1Tpw_K!1V#w2V5U;eZchr*9Tl5aDBk_0oMmy zA8>uZ^#RuhTpw_K!1V#w2V5U;eZchr*9Tl5aDBk_0oMmyA8>uZ^#RuhTpw_K!1V#w z2V5U;eZchr*9Tl5aDBk_0oMmyA8>uZ^#RuhTpw_K!1V#w2V5UGs6H@q%=k=Ss-`^} zT6Fw>>om?4j}fuZN^^sCCiW1+-Lc+Otnw#`lW` z7EB*fG^AkA;AtGy`W07{751A}TFZY5%L@vt`jr<}*Ay0{O|K}eEh|s!lU7_&Sy)tC zS~@SSVrKq4`|W-;#f7Sr31Q+!I)jXkmLIR(Y)axZd;Ei%w%Xb5E-?+QT%u(N3PA zNhI|5+IJz#4WB7O#62>ObWh&hM>CxoBG2rM$r))>5R~&D(QyCY=SF^L*TG*sy{fP< zDfBfh&?zOAu}slAXkWJPU|c~yF!qd^G?dE`?O=QG5&TBj4WVc1v&S3dmtn7`g@5gP zQiQ$!D8k@VBJ6fe_K%r<&nLRUC!|LH2ESR`o$oVl9wR>ye}t}~D^urWkw15h^nbgR z`%L3>f;3DutL*OJD)*SvrE3{i)7XbwU8q;}kM*2_?1=8DFI?awW0_bAg ziuAn?wae8u-THw!c1R`q?IsQ6VB8t-W=+i+GdxGjoiu!0_V7uO`?wP0O2mG6VLx|J z^20N_TVfXhAka>0$1@i`c#fx~w8Inz6bXes$>s06^;Bd1rJo2POynqH{m(*bwE0=U zEBb{1t*Y>0^al;{+6V{diFFIl6tTV`?)^~zM*2(4S=GKa`{#^Jz*}_RgM?|o8(CR_ z4VuQTQm%ukRKC}p*Udl|TvT|XbzKSRv4@d$0xM7SaX<%+*OWHhdMmFZrGM1B<_$c)8 zxQ6-Ix3Tg7Ig-u{cKu(E9ZaVlKhm@_)HIHdtDr@}B#!LDqWs#@nj|DJ>;Aq`J!xcS zGD4H(SM%m|*#ISC-IMT?t(%=PmbEN9N7FICTxZ?)?<;KUs;F1c3v|#+rDwgS&zoLa zSlx7fvEKC@E}p~g^N0|9s$QTo^-}2BSF7{ODqBo3D1wmI_pAr-g5J;zG|-pzs;nxU zT~atFw>9)jLxwUm^^$g0^@4pPcFdh#TQ$3-%&BM-iLze3D2jFb`{|mo+4ksqn?G9H zoV|T%mGt9@L-9O=`9Tlpn~DK+$%)Kw0qv!ZafEl=3NnbMhZL(p~jzpm!Ea;7|Y2F*I9Pgtq(;UVp77?~f&oc{b?P56V zcedxfG!+VP`T2}?w9_WAGexqGM(B{kR*A^J#65{U9>`$ZDsxZ)fHrazvgH zUHEBTYzIH}JC)AeznI5kb}wIdeSDE|-IV>$CAtH7q!@)nsqEwCpXJ}q`Wn@k=Oe5a zMCR>(Pd%@J&wt1>_-X&tW#GySxd%t=|%~_?R7H3 z{&g2}#6FcgqDT83&42tpyZ?m4O}Bsh=V{ea$ z*nfaqEdEnT^yv3rq-(DCp2P1q#;tT|9G*&r*)izaF=8akc&yoeMdB8V9Yd4@ z_DMZN*G~0(lChV6O#ZyWDw^#lf9QH=Q;1#{nTyFpujlXf^*0b(`ztX<)wDnLI>zVu zqo7Ax~oseBX*PW~x$ZOA$e>2)|)@uu;;rY|U zbj`dT?6fg9>gz?V&@!w&QMZ5 zBhjK86KnFbi2tQ!u&nW9p)GV1d*E-SG{zfia7^QLjL*!7takrgYHFvVD7-2Xl}TQ~fC{GAzR{q~I=t49sUh@KPL zAGAlHjHLG&^tAVzabG-j^y`@@ROd8$icQa`0|UrDPe|?o+$M1v z$EjLALFfHU$8n-NkIpkGzK!X+kp88l^EA?v@0U|qeoOklM>=y{tNdaRm*po?|Kr#0 z@b$gmz0kkcrtXyac-#N|PyJ75JbpWqw+r3!7^mxc-QyfTch3<6#I>pad366ty7iRc zrz@&YeDG6v9Ju1Jz(@>upZ}~2=@{U3$@IStO7pJXSxf7a`yc$CiKcNoaYZKcr)abl z#vp4)0qbGvvOjauDIzDk8049JzF}p|W6vl4n3J~gONpHWVs3`;C7kDyL5=Fq35lUwr4b~@4@VEishcCDKcbzV#V-*Pbco=bIYBL zici5`o2uvPJvGkAS<|f7n#TDuh4pYs+g~^4U|Du~LQXts@z7ee6#3tH%Iz&R@NgK7&q~vh*WBL( zNFdtHrq)_oGGG6qpBrlz+b&pa?0JD~^eNB_bkIwskL*2ygw_qA7xaK$$QAlV^va!G zQe85wq_m`F9*9~YWWD$aEZS|(!s<(WpNElX&#m|Q-WE!LFW*kr25*Qi_B&8__PY^t zLNa3xzaFRO=J5Pke18BT^!CfK#$aX4vEzt8Hxz&OZ|^s`J*abgn33%|(?oqp_!2G;%cwHv+1T<{>tn0P+Z{`czi`XSrSOdo&3?+m%uu6e+F z_p!G$ggte}9DnL{Z)Yq?J8tLX-b*u>-%?NL+ZgBpBJCI7$C!im}#vV>%4vP(%65k^C z(1)VkRQph5!hL8<9>se_yHh+c-}YVxJPvw+4tlBdk-ohpwTmvx)Jy6Oy}$u|BYGX! zF$dah*2n5ctT&H2(4JfG^Sw2cfIi@Ig_mjhV0p(HSK?1j?X>FJsv=8De3#}o+DtZX zad_lpKRmlnG5St(j3LJq<`_fn#l@ajJ2$Vt^SXu@Vv-}wO#!p6qS zy*QC3!bto7o5pKHUrw0X@Xn8xQ{QMkn8tdjalU6wvtDc31(i3XpR!eJIN`+84LM)D zF|1+Nqd!Ui-DhtbeQLLBAFc23r@jrddxz_-xrp+xEW12G6Hi(^v{nuHI^f+{_;h;x zqCkWHyDts>rk>lkVeiM@^!Gm~HE{ZDeXh6LD@S-ASlY>mzdZk*(4^NUgnINH;bZ=F zQ_AX(nY6F|%Mn)?_q%2E@@^P*wD;rx{nL;);g<8gR}}SWSo61@1|A(MZ_cQDBzwn2 zzwl%*zs1h(7(+Erobmdf6c`v=**tr6@r|p!TcWOOSnHXUw>7TOXQJIy`%L87(qoL^ zYjTXS%swYn#~7f4Ud0N1q%Vi2ts4R_=mEWuEA)-%b%4hhvb|<~tbT+s2HJD$eT*^C z7hsH`_6aQSV2m;RZ`^Ct71lFEIzX>Hy0(@P`tvt+ta@ftRplqXfI(v~e6N<|Hg({= zBRh?~Kj8Nz_&$&K_&9-1bL=F?O6J%}zQ=?2kqidQpYfT)%PNyyEh9y&7+S<1vX@+* z1F=36FF<>Hj}5(YPOzcu6kkR(-ow{T@cxL-PITI7*pI|H?Y1T9yHjV??-{<)@TCZy z-PZ(t?}aPwG2(A5UzpWkfgU;5X#)4$u@C$y%qatG^q!So^C`e(De_dil%(63uEa9HtQm!(fAU24!z z{ARTG)#uJ@Sd`2A0yOQ!;_EWoWEY25Y& z`V*6}cZ{#;J>W?YE*mv`*oJ^O|;8zv1r%-|m$UOepEc4vCKulE}9h8~wS zw5xfte$AL)81j0*8Q$^T6^lZpZ~eo^{Oos{9FR4)cdTtb%jW`E4xdpXO9EfQ&Q7*g zNSldz`a3_%Z-y=EZ6|G`61Tt&%;C^(s&hDG(b98X&l_USixBys{Ll;co6<|X&vD@9 zmRm?!*u5=3_s04GV{7)$vOhNaXZd{!d?$fvt%vBJ6JCuLqAdM$ILexITkoHP;X|W; z&YSt%u%d7K`Mxjxv%NfMx2+z^*G}ztw6x^<=fTuJe@gxHsh0lv9O|ES**||v;|}hh zF}|36M%U2Wr*#cA{Buo+^}K=l=f2cGPnP}j)zm-#oBHQtW&ivF_0Jmh&#Pqr{L!S> zcBCACOsM{-*L}?I_RnnZ*pKk{q<==c*;GFmJ1Wgn23Lr+XoTQU`)Ay5O0R>_Kf~^a zT>mWlW3zvj-=D&FpkN1tX8+7HjzkYj|Lj?9e+B`OZM}c?ynbl(&$9~eZ`d;O*%xQt zUhf08`-kY~)IKbd-d5(%_VPeKwM1$;_KE31PId!4- zBfs^z{WIG&z9Tr``!Rn;yV+Df7#}iCQ}6qOm_H)~wc0=9ep7lKjQ$yRKjivn*&mzz zv;4j!zViq>AT;}DW%UpDV+t+(^SwpW{99^H+S)Erotg?ZYDJZKZ#~bAD!y`ExhwpZWRuHZn#~RM+nx?xa86-}by_2zKTnkF+qY5w{2$Bw z`4=>Q?nvv~>*f6Uy^|YuJU!vT&>2tu*zKQHv&RtkocI0cpV4kM)epu1N6aLifS5-i z1f$wN<9<_m9gO}Nc0c6$XW1W{{j>ai2>eb1?10ehpXm!n2_KgJ*>mF|I)8rU(CDAF zcecEE#m)P?x2}EJ7u{ay=hQwdlHOMOXM1^|pIS1t9DF}oT5|m}_J^T==J|6b_0ONn z{&}DB{Q2iYpKLhm=_^75k9r`)It7*I&)3NLb2quZ{UY_xDVF_V_uR5;N9P&6L+}0U zT_5us?IYDEKHU9b0*9vlhQBBMGuq9j`oX4fi1*&M%BRJ*UStSZ zj7v##OTpVWo^eD;_S%AM@{RlL64%hEsd^XI?I{`qs6|2Ek_FJ9C& z^xXN+g)+_^WaQ89SMmPsOJx5%hx%uJzY6=epO^c$*UI(nf+t_w@#FA=p+Af`EW_=e z*+b$x{O5l^`e(G8P4$EEA=5PVwruepEJ9GL{WI=2rPsmepJDeyu78&OvDrV%-*3k6 zD8mj2&Hi~XQW6L){c~`=^S8{~{PX8e4~_oW-VaB!Riq!bQ$*5RtACF7{bzf5pr2i9 z(eK8Zmi+wr1L~j8lKnG}HPrC|&z~_qSW>sTVce`$p-@3}i1pfuO^Jv6Q(d$dGe;Xk<)&3dx zo6_rG^v|&SA=f|4{@CoFueieaoz`gcO|yR<0t+P&TKne*4$b~KxcAWLpD*cpdMIOL zqxZgE;--m5%+e(3)0 zp^n!s3;pr6lS8cMb<{uiqyG6~Ie)%N&Yz=wpQJ0zpPzf@rqB~JGdUF8-4km#I?P{QUWMherRrkr`Zo4&eV9bhw=Br@c#4VrLXUp_CQu>cK45c%x|=hRG;{8_lF4_n)(|Y^8J`U zqup$(A8ZPTc<=4oBj10PA^6q)8FWqQbujv8*!_^}pJjiH{u#J?WWS9#!tt3owdIH; z8d~~i&oVJCQ$@A){yF%?q0v9@eXUc*i2p6fn78aLpNerLj($$w5i;uV&n8@S}aCtWulnpAU`S&0^Hl19oz2CbgC`5jH!ZZ_2q zwxlxqek=QD8A3M74!uCvlwJq2f6f`FXp*_3DMc>;aMK1Bl`=)eauQ=XY$gs&H+!G0;gCEt!G%Bkwj`7(b{ z(u4mQ8HZfxt>O%R(1X8M{d&XCAs2jVIlvEk@Ndi$`a>@CkH!yr@Na{EOFe*VwDN-< z{BtEg6 z>?zdJXgW7K^6-!Q-SNEsg)lf=#Dil*2)bw?mpedW!PiOZ?H}pDA1cija^A`?!nCjL z@o1UI^nff+cO%ZcVUmLoeX>Tso!7p7s&=?`n6{|XamLwDCt(!SEphQfizv$R0*OA+M*KEzWgv8%jsS9@`>O2&UGLz%4MzD!2I z<4E6OM3ka+(vGA^N0N9qS?F#sg0hKl58Y!wR*w{II7#WCwU>6;tR?fIN>zHx$6*)P z0d@(G5_W;TjGWmn+GV?-g*Ri7@3<31+-jF!OM+;2!F|Y)p`%^)XsH@w9naw;KgE#J zQ9H#H6t7g1@qk>wF0cdaf_4!0GL$ju%Y&Bs;`3>FcT)VDo$S&_OO5AAf4y7AQD53A z_hl4;u#3@-qP`rh%};GlCrF})+=ihYbFMGkj-}E+4mEA3@we^%fn8t+*aaHHUWm8l zuqnH6?`+jw#D$Hl=?DAT)9o^2zQA?~lCvZJCMhPgUBUiqJ4OxH_e)#VG0>oCe2U}8 zZ;vt77qY+lRr-L(-l%`(&WQ%kh&d(R)uTqFpL+JQ#{Ih|PkrIOlMB7Qhkc$7UJg%w zyJ82)^D;m4JW6z&K9FRsB7WxK__HLJWt}DY({r;k2F!gZRQ=X_KIVV?jxW;t9zV9B z=7EU~q(ejPWp&=KpZ!Jpo##EvI;7vUz3hc$^FQ}~=>NQ)bTD*cxhFqZnn5~b6nx!~ zUS4yt*Z1rQ%74=K>XGB>um0%eVWVIDg7ggz_(f&y&d1-{lkw4=9d`cVKbu3J464dJ z;a~6V81hN`%+9?4+yCWH_3Rsv zHSmFX7vLHp&ljwRb&PIJXW90+bBycfHj;^P{V$13$-O5@gVEdp4z8%bai+pjCn2Qg@RiA<^JbH z>ScIkqf@5<&5eXdSbc3z&(DnZ9igW(^$4CHhG&V;D$nT}{WbW|Upw;q?vu8V^Fd=S z$5Ih@${~`5{xr7vpq6@=#%1O+w!?~-$|0mgTqSm?s-y!Ghh1O?*af-6UPgTHSJE!q zsNT@A_pcnM>(tB&@*&K_je8D%%H=%W;T9!v8xZhv6{Gsuor$>~nOJAW`ZFclj+{vu z)w!?Q6lKTx9uruCz&t!>WtOH$oe$sz_+oBu&XIg zGG@ug0nih4;G27-;4|f!e0DuS2fpIxgr256lW&jVddLrS;Ctpck)J6~^3`>+^MMY0 zy?!eAOnH(|(^eQ)$PaYjD}GSuY05M8wDW<^A~OYXxXkvJ;0B0sQQB*{NcWmU&;la+3qRz2lU{d zGC$ks&mb51N8<-Q_~*_Q{E&-wE?WHoJ@~s_e)G}XpF%G9VF$JRpa*|1sXyd`Kbroa z2fzPhfgj|Cr5%8ynm_2l@43m_mGy^Qw7b#r2R-jXdKB7ewH^9Mcn2Tu_AK`!c# z+K$72K@Wb99Ctx3aF50hdhq+@^HIn}`(ySmivNKg{DU#>lOgz`)ql`~KPbn!kPH53 z`hy<)7$-w6@Q>!dpa(zf1G(@&C;M$7VvNVJ?hyWmeO!)w(GQ{@1Fp!AL+d;`+PXup zUSiz=VX#`PH~4GgTXz5-=C_Xg2Xx)xaChB-Mv$-r>;nIQy^OfyxfQs+7>M?fvC;)<_G4(uamE=E{>l?GKR`RzbM1rGDQ1&zwE`(`)Jx0dy)}Ddx-WA?V;i3e;WGdhy`XG z^wtnKp*`d{@7J4U5b<*xf2MZrT0e*4S!!AaMfhk(^*Yr+S2+CyTD_ok!_O@TZ+y*+02<8z2xJya=bT%|wQ zajxeR2q)CHcS#{f*c3$_imp%`8d1-@>mBwrwT$jo92w^>Bo_JgqMLmG2zhpqdZ52S zyeAPcjW|-w=jBIH0?+pF{G9hDTjO1g80Gi%5TiY1e!d^v75P2o$ZV1CO8&P-ns^xU z-dExuPO;y=y3!$o{mkbgP;SH#Qqc@ zbHfg>i)X2{ue7sJr*oF(8}U*^wa!_3ortfd#4U7ke{+_^Uwt2gfzkRwA!yUf*AYefp7QxtZ&pKhKgniU&aiZ++x*7syM-gXbz z1$Ka4&FD!|x`ut)cJ%m({XLmczfSHwnfKoj@5u~T-jg}6nfGM2=4EG8%zG&G z?XM#5$$Z&$U;R1TBk#%deIYmfIB(=Vncmrpyf;p^zbEsfXG=3khm7g+J(&seJ(>TE zd8+=^7yG4ue&lGvVB-^6rL{Ys-n}QI`1TGv-~HF-(4W(*GB0d=Z^s>U%Qm8z{_QdbGa~*9hfO5TdqcfM%j`5%8uh9g_Z4$*JDb z&wjFBy#g(_o5p)Ga$Ns|eNV=-h9I%NC-YqvFxo$q@5$I&;{B;wGNrlqWHA53d@%T} zSbxMk5zkeyZwC8bFi*7V`bax5AH0ql>UQlOZTBzPVq52f;ZAbjx(q!lu7&?o1X+DV1aX$EHyW=!IV>^JC(|k}! ziOdIMml|8%ElmZxzz(oWaJ8@t>?QJ@%5`LCLUEwpDUBp`3p*Qb<9bgxf9`@3-E;}@BG1ZSPAv%5H{(OpadRhj@d0ig$ zg~Ruy-y)skxLTvwa2iz-Qt$CAf^q~ZGF zA-vE}y+@f#IGa?1T=S4TJR?MZyub2PBAVx`2jf#oiQvm8a2Ox!I=ITy5^>cAtj87- zjPVM_FL)0Q;~I=xu-*+{QIrzZy)3!H$VZ82CjYAqAvf~$N2iPUJL5^Rfy$o|` z=TrM@9l!V~e*8<~-u{1Ikx6)d%5iRw7dSQ=V*KKv`{Ma&I~u=)wT11%TB#$w6mAJv z{$}aNmSp3SC0WNWBCb-kojB?X>;Sv?Wqot3H*H7#I5jy0$1l4zVLo3Uyl*=E!?5JuWFPK&c2x)f z!R>yu`l3ow2#~brjTsSXXCMNe3nlyTA^x3v!3O5O1rY)hH}SU4Ko|=YS?q`xf!HG#?d41OuzS!_owGIY<`|Tc+>yp z9XnnO?y4U)d-E8WbnNmU%kIM)S>55nVlcjcYIj*cE(4&wvZ0) z(H0PR`^e*bsvfbXu^;{I7{7Pi-T$mV+jCic`%@nz-xh0AEVKRJFHoLGeVvhF^q!F% zMc>AApUA=cd06(NUq6`s={bAS-(Atsc0WQXzWc?`qtGAz(4I#zoW^(Cw8--)F*ZIl zo=2JAU6S8f(v9-aICi;wA1BIp{&OZ}Rg_g$RF~A0l+Vy+B=qT}B{DCoKTVPEofkhQ zzC*K5#(TY`8{hr*d?%ihh5si)D=%N~KOS{hOjDUC&i#KHg~*?WEj)7Z$nOBqrQ-oU zlTPwYc}#rI$dqSt+rQHfI^@?&@|p4^pZeV;(1CC8qcT6qkL>U+D)a;$_=+W;DG!Xw zcb7m1zKxR4lxON`|85WH!1t}>Gv!IXfgLN1E9eP2@P#FxDbLK$&Ida11tlJ)JTpJL zo}dGtN9JeBlYF$o#%^Ky3+PNf>9?jlGe3n-(t&TIjGOW#-{I};e4qm#>WK&POg?b| zJV6IONv4_dWWqpMU6`*IrJ?@uJ_as$H~ISl;D`UJ{tAB3gFh_4^8~qrCC<_KK@a}o zE5vs>Ah%fZLl5YM{6P=?@cS1Tzi$n>$RBc4e$a#e+q*@(1iA1lwf-P~(1YJ|ui%H= zjZ%NeQS%2q_`_Z5jD6~m3%$cqj>->u@b9@z8s3gTLEQkw4@@|7iT62fu%( zXvZNJ`bR52=)pgDm*9t7=&#l{;17E6cY9OtL#{{S7FB;q4}QOlLoWEEAMGdF!D6L7VEX9awTVm$=F0EdAoOpR&tdM0Z+#1VSV!?V@w?x(ff-vp z2lO({jQBM5Te9r-?hPgQ;u;Vp0mk$lleW} zb|fX@g?0|7SghaEb>eMHX>1oCDTMm=HeEa8X4jf$=uI6gcMB1qy+HdB{EyscDBBUV zTWCkWmF))FW#n-ljSDfCD5u2Eokyos7@DTo_OyC#l=DUzhVK^bMsRC z*w@Dc#`-vEhv)Xt-Z>m4ffnzbF)y!=%aGPTcnyR(Bj2?dO<$wov-!h{mmyza5mF-i z3}ch32swaJv>&ho>;e^FFKQw}*6%p;H#-xfmueR6!XExz^* ze5gkrM}FnIT}icX_sjN8hAq^-(a)kezK@3X4Ze7&v~REj>;nIQy+oY*h{$)l^tyYq z8XF2;;(O^-;PJB1c&~Z1j9c3`rd`{f|L%8ipW|Ch9em`wU87TOZqMg_wEHDk{g^*; zqaLahHLlVh>^Rr+E`%TIoBQ1^@!qy-7qo|H|Ii+KqHLjIY~#H%Wc?B2 zJ?7={o(vt^!=Chw0X~~Qtav$%_k@(lcrP}o&F9-%6zop{GB@l1yZ9fH_LX)P`M2Hg z46t4JTuX|wAFD@-@s|6c_niUQ0e0~`BJ2WtiE?(yYOCKFfL*w5-J}|Y;)m*Y24Dx+ z1??d0C5lSl6$q6Y;{`LWJ+q6i@1Yal8CWF!^=?6AzK6weo*&`9yp6yZKiI#^v0Yo7 za*tN&;QwGk$ZbBKY!~PH!a@$c{j#tF?1J*bUWm8Vkn2lRcInxEgUz~XysebW6IS+h z$vF0PU0L{h_Fvo4_qzh0ErB9!bzfKSj(2(|TzZqauj`$y_j@1ew%Xj+)w6E%3)9<= zHTQL;zIS{>|HVHy_jPsezAOFFf4`X?`0^#%*A;rA;=w0h?EA^%!#pPkXkXXi1Ky#1 zU2AAxS0~!nwS)F`eRRfAnJ+!0@3@Wjbv@+n>r&n1pv9LwzivN2jx~+f9o2nZ*6$4b z-<<6i*bBDBabK7Etpd?N#JR6aj_ZGD`?{#Pr7;No9_JzcodL}MFdxLeDa;e`{Bf}I z931mPtDkZI^B6f1ua9-9ZoV%5%ELyu&Z~&|iC=a^Uyz z`z?IP`Cs5=dtBU%`j^YYhcREvV_upMB6OS&4y125^BLO#yk4{Q%z{@)iOdIMml|8% zElmZxzz(oW_-ScxX|I;fx3%QGF8x@QFWy@?^yY)G1MCu%^$qqCz4Ey^LA}gJ3c`YJq*zA6mWfsW)~ra z=N;mRO=|S~TKf*_3+w>9AYa%^Gv3qFsO?GHLHDx%dF_+CuBpE)Tl;cz{lAaWvMUG| z#)jv6z`dN}d|*A9PUVxyRamt!J)>H-c~}k%SNjE=%4;bia9jT`>CtGhqfOyS^)`##Iy)POd5}Y>vZzKNhU59Bi*Glz?#y#xbD5_y+r@ zFrLBsHrBgA&*6`Ga7PEPZ*=U)J8Xqk)^%;qTZb9@EY0vm+0X5C7>5XPw!f(V9?6IL zAFg-c54ETI&j;4Avy`~F8|^R3V~k@$nZ!%u7=(`Fn8RsJo6pz|n|y^uNQsPNVwb82 zIzUm_1$Ka4f+1lS*vn8S@Hq9Cubcg)rj0h^uM+{jgS0-1Q@LUE4zLT#344infa(Vy9A4L; z1yX#PwxOQ(R4M(9LA2kW!^xldh{gW}Wqf~|sxOBt>!)fwzMNX0n%l7{XVPE&?tBE&?tBE&?tB zE&?tBE&?tBE&?tBE&?tBE&?tBE&?tBKTHI8t?=h&^sqclP z7&}AlxkPsLYucJS1{pVb-)R~NL;kE7-v~;5=+f~3pGhbAo?zz=%x z&z1a;i+rQ;gC6|bBtPU%k@eLh?G9X!Kj^`~NAg21>RmK`(1YLeo~(CL4)h-!C4bO^ zf3W0-T$DQ+Kj^_ z(fC0R{$8>lf?VhwjUV*jpCb7o7yc8DqCe=tAC&x%>ydolM&SoN_@9ydkc)hy@q-@x z`y@Z)!v7#g^?%TVANL`5a1;7(A>yFy@9}=5Ul;v9Pb6w z3UoToXwf|Mo#%F%XVboHt&ZL>l;4T>WXtcNz9inC3;)95{YDbC?a%)_mH9k*-G9s> z*ZTl9lpe{4_lg|(_dcD?dPUxk{91{NyV3sQyVc^me-98ZfA>#@`@J8jr5>hnnfZ+E zu;Qh1uoQlm1}{kiP0$60{MNwJbB281RED^pXy|#i<4KJ3zUM?3s~p$`c7R>L4||C? z?66I|H@x?+(k|86*6+l>ZuGE_5DClWN6ek?i}%K|P?9?M@S&C;~fUy3Nm@$N?aHkt}bMSMSZ11l~R(7TtI<{kLI`-H2Z2sW7%I)N) z_RjT1NJ%x?ewG%S)aLVTEehBLc7R=wAM7RKpHThCx3ptymuW`4jxb+CCx7Qg%)B)X zcEERVIBva6<3rM_M28(%YO>X3ks|Hl^0gm6c(gSuPCi8D^Kf_R$Nh8 zSX5hDIxnqaX8t_;?SA>y)rB?H{jM%7Ev@KlNa#0y+SP^AYx*^vuY1=;z(wHufk0O6 zv_j2jEkq0O{V0`N3Kszvfdhd64S`PLu?`P~kY9i*))Cs(n z7?z>`Ejg!pTZE8@uveeY4Ek?-ix6`F@L>+%apd1ga{xZDrc|fI#ocIs8|4kL*1ME= zd97E5`_0V`r?p<4zM;lv^9S%cfv{q{)Ptq8C%a)BtxEOCHATPl&mXvB?&+`K>-&{Ot{)BS1Yc*8j6tpL6Q?jSL0qRVY2+$3FT_nY-*$MC;nci^_Xgn$ZOyPT!53mv%m@X zsjQK5=9kx$G@SC^E}P^qHFljYB#T>E6iovlrwNrV%(;TO<(wB^oamTO!BC*T5{ zf?Whozz_5YBjqfss3B*{rRA3@+>9YimOm%Itg@6=c*YvJr)9>MyHM}~i4(^dC-f_q zi`%xpzzMhjr|?f@`y~C!#zc`wIjf8F3*vGmLsYW-ISJ)zFU2VqocIoKy24Io#DEiU z0ZyKyWcwujN@b0dv$C+NsG_PYzkGT;i-gJYPe>?Ndnpc!g430DG8<071vvSSmhF@D zE0xt!&Wh@q+{&to>4nvCG*QX&PoP}WCd8Yoy%gX4&oW7(Tkt2b>(5RLe*!MRDcDuE zPtvdKT(){wLrsNxyV-aIr|N|G#}?M)7v$IEH=9ut1R3N{JbuCfoN|$134s%E0Z!p# zWcwuj%EDq?M#@=HTT@vZ=P1*Pb7K7C6Ux|aCS-I9F?TW zc(VKx63A0ks9v_?>io{H5>gyE0T15WIZmjM&vbc0xrPGf1+%kq+hA5)^b)?7EZ4%&5vi=q9x0plR%zo8U8EVryS+R zC?(EO-~?QNQ}86&K1sh)S#9M^U=EQie@o0;)=8WuD1{rX#5oF_fD3R6r^)t7`jyIR zEoV(tetC7AL(OFQCmVU@)8g0kd8%%)#?Q3)lXbmdjDrB+1YCfV=M>pKNxxEAt>r8$ zsj8}otJZ0@dY2G?0(q)sDEhA$XFfHT2`LVofD3T)pDNoY=~pVN6{qUzxR&S) zQOWY>)K06ett!f&UYM3MJ->zrB=IbN89I!UIB~=}ei~=owf_N5zy&x3Pm}GF^ea1; z-5;vC;uWYAO~-9GCBUDP0FSECAPY`>5A(b6Dj~&z6L0}e;nQXNB>hTdwU)D@l2_{D z^QmO{$0v}dT844|5%umZ<%Zg>^KIY+T!53Or);03Un!z&?F+BeRm3;UOq!qjyLj_d z%P>gdgmn1RL?l>3-~?QNlfRd2pQK*}2hTsv`drHD57TR_Ybwfk4XQF`r%IMT%gB>g zplF@2rlc_Dj4fsFCg&}fr=*Hs($OYaXmAOffD3R6o+;ZW=~tjf$b3AvE32TrI zr*tG(Lf`~kfK#}iY@eiGSy+tANI9ztOY`F&KPAgQDWP2LrC1T#j$G}fD2(DyK0BEa15UsNIQjd__DTAc z${H!>jH-&-*vCzflPv$pgmOjhB0N*#r1p0ib}}OdoPY~(3J#F%lk_WD73${MiZRYS&_$96y!VleJ7G&Qah5T!2$}plqL{U#YB?a@JOsme7=)rg$n* z6Y*sEvlGhIu28AON%f~JJDCv!PQV2?c?QY$N&1z_8Y!pTuTq&`QygR4#Ilm*m-|#E z#FwkR6q6-RNJW2_jRZ>woPY~(@}Do;C+Sxf7UMEf&O~N+R-6*!Z;AQc&?qC!e@i^S!+L@G{BEFHSg9}XV+Ah2$#bD>pQK-@tSD!^egrwm@{ftrf03^> zTtwpn_NO{)B3+-S#5v0PGETq+IQfUl_DTAc%8GI(vPOq|x!xwq-xBL|f0FCIYP~yO zEvyA6-~yb2!({s;{Yqs8PS#d9HxB1YmcJ$1@wcO_kMq7(^?*MC7vL05m+h1EDjui8EiTO5>ueXP#{(lGm)SF@BP`04L9S**;19R92L8Ha!(8oSPf> z;!3joml*k`CAGZrC%LYo?z`xt7M9|`3Ag|!{|4DUN&Hk+l#|9km4&%83+KgI^^)Z` zn((yIap!9-$MsQg>T4yE*T4z50H@$BvVD?%rLv-&wX<_&)l(^&jwj21@g-?v^W$G0 zv?(=6;xyQns%cK2H33e*1vrH_%Jxb6l_JU<|HyhrA6%)3>scpoN|ayLyR3NgwU*=e za$KO|bdHrsUIQoK0-QXXWcwujN@Ybk#dtjaHv^L8A6ZD>$f>ECmzJIQO9^jASywT> zouw2QH~|;n_7mL289czk+( zSz%Qih$YLf>XS%3+Iaj_2VuYoxBw?lCxH|2Q&~|?`hHfNBLX2QS^hBz=4&m-DRP{t zzK@`;voH^E0xrPG?~(13^ed};@EPmAarzPD0H=8U5d2vQ<_mrfx65(CCDuf`K3$1( z6gUAF;1v9kY@eiGsjT*L<`z__lWvtki6_lpSXon?R#B8TqNK32KoK5ebd1DF-Dh}) z13z#AF2E^#m~5YbZRcqPtJ-~?QNljj)OK1sh)S)Iz68^=cSWcdx8RHfpK z&ywwU2W5Rc%|Q-u0xrPGU;Z!S8>zrgWpylPC4X5owgw|v{t2;XrK)bm`THtP`{_dD{V*^MEN|yhUcr#QBKyiLAA2?at@lzaVffH~6 zPX1#BPQXuP&B+=*Zn*JaYI3P}2N~Wos)&Il;uwrPOgIz9VKO22Q{QIC;MO z{6gdVYSOQ)JbZ1Iv$nh>k)||R{);KwxExq50L5P>e`1{0DEE$3;v5A|zy&z@8wF0l zPh~aBNlU);MprI>@mQs4DxNGqt@u*5X*qFbYg1{q#7SKrILbyPBESi_0H<(=FN8ly zzfxJvavCd{^h$nt{)|HUU{ZmZM-H~|;n6zumu z11I38@|oq#uPrF4Q13P$PnJI;v21OMjfjF%R~wax04LxAoWix24CVF-_^GUBIV+3j zRhLYUtIn~kWcf!{<k!NijeSSwDoc2wM9cdZ~`vC$#bo2pQK-@%9!O$a3gE7{H?i<)%F2hS#aP4 zT!52*foz|oUq!_!p2piE#Kb94{zMyZiw+xPe|NPdjW1FEtjWL$xB#c%LfJk^zq0c1 zwOP*mvS}qWyv(hbKE1S-Kg+68I>(dc&k)(BjgK!|n=+S085eZ0QHcm}0xrNQTqoNn z=~pVNStfEswT=rqsfDFD{0X=KC;wvE zK1sh)S)P43I8Gs zu_fwc`Nt-ft*scZ$o=Cvlxp=S`iZLj4&Ve_fKzyhY@eiG`S(Q|KUp5Q>?b{Xf{9at zJXwD16N@H4 zmh@nh_cjU~n1K^;0Zzf7Y@eiGsjO-_jUhp9>@RgD%WsSY(vo_eQ>%=ElhblPZ~`vC zDZEU!PtvcN!ztdcwW~PA%P-%1P3ZgVD9aQJPTY>8eRA4T0-S&gaPlmd?UVE?$VRA^ zv#6vrt`Dpv%ReH{{Ghir>?mh}z|?7#`Q0H^RO z**;0Xa>gk#E{ON*+NwXr%ij{;)vk|%le&Ij#R<3oC;u~-8_yAdUvoGa8&=}jh@s*X zFMmty!?=s)!Tj9(J*99xlsHG>PrwB@1;YX-;HR=WmoxT_7|HUt#6FB=5+{@m{oM&j zu!O(~xB#c{s{$wBXJIie)pEwW5hGdtme_}ZeLY+t>$u=VwXhTiPQV2?d5*hM)H~@{ zDyv$~g2M8Ok~n^EBU%3JIP+8W=eTuWj}@nr9EgDvZ~;!?@d78{r?QH2=J2i&V|o|w zW1?jFbK=cVEda%L%JCEbeztYJ;5Y|b-~?QNlP6F7z8vrif4bfF+uEX>#v?|fbC37) z^2zeI#B+q8TrU7DXrF$J1WO2DQ^Br`Sa2)i6=vcvcC{zT+rEp7XAcW zfRm?C-~{~qe{2#b+9t*iT4LX}qTm!aKWz|;FGGj2Z;gUe7YAD41YCfVzeu)E@T=c8 ziPLQU>2RlY8;2~7Bn79q`7en#LkC5=L}{OX;y??WfD3R6&XDaB{OWBdoVcsQ8+h^j z$_;(f#8@{@oZq1+aVO0ooOmBb#J=LmowA+WkPDoE3vdb-%l1k7mFLe+IE|IR02Kew zK;V=(e@l!D8mQhePC*l4ffGM}Q4in*T!543YS}(XzY71QIh^9_+V{sPKK_;%7rY?X z3;t|wP!TvyRzadTZ~`vC$v;!JPtvbE-!zL;TrH~uPI2+KL_1#jo$#l<5|>m>JPE$E z37minaKi6B1Ha&x&EXVZQ@lS;@$t7rJHBSg)P>mRNaXp+osl9+au~TySrVx3r$Gyh) zdidR0ZqtG?4oS5oa^{eWxd-mV7FJ3!fg{p%Qk`_@Acn6eorLVeQ3#g{c2JebjD?r7&~5F6x4Q zjnn=8{%x$Aca(nTS#zY(@A>s7jPxmQ>Bk%IUA-;BZ)NC_p+&S-Cx&?%{|}&!PiOZ?Z}_E zDO<>UD?^4#T%<<(i}D!nf#j_v-f)J7Pz!i9<7DjHw@=j$Pd!WvbUe;Dqe--<+vMAC z8B#hBewezlWa|+=^S1)e?Eh!)O91Prs=p_T?SMclB9x*StxGHdg|bwT<~3b-#R8=* z+aEQg$x9QOCLs&7DD(*k?^C3Jf&~P^BG!dP0VxViXbV1BESpdPp$ceSTla!UzH{z9 zzgg~^d6}2wr76s9CwDvN+;h*l_uMmgX71Zr%Tr=6%s|rix>3pwH9o21s5GD#=mB~G zKJ;Z-iC)V(PkwSBS$)d$i`G_t{cBDh@ZK15!uQOa=Wlj366+yZZGG{IB*%|G_87g^ zTrrI@97<_km)j&wQM@kg=P%P|Y4bQX15=V=uBuGRw&=yg8*JWwO6w#0eBpZ8M2%y< zZKO0!nSMjiUU$DpBLIL*h3X%V$8Ji)p25ClVc*2=HHsaZBJsv2wVhjO*tai{(wEb= z$doe6dX28KvOeo})sjvAIkoe3v2RcAujY>}aq>BB*X&;f+qacs*S3AsZ{L82c@&KQ zFPS&6Z_AB*eucax)%rWP!wHw{n}s;YzV*?(;H&=tc|At5VtJW?sf;H1VJwn}edtT)uOeCYtdlxAZoXxDch{Ng5`TJW zE$`dXa8dT_>jLl>iDBP3&-M-VQZ13z%QeIcuR9j4JEMr=H>|%}=lcNzbI=Qq<45T- z15>g+g`D?>6J*_y*b6g|^r|oaK-oS( zOkL(zcPoDkarQawuUQ@ZL&9CnZJk5Q>Xh;um0noR>|3I}N)j^5`3Sk55I&UMCE+6f zaROo(9ZD>F>!-@upL;JA@a#u^4?MuDWzLRHei66;3 z{i!$Pz595z4~Y6q){y@=f4%*vQNORhKJ&Oy{}F$^-D}8WqQ5@#GowCzoS(mEjrxm> z`WKA)OZ@frOGf=le|=_?QUBS``r5Z&G3wvc`h@)Ki`;rOzF$1y(>%XWpLxx#XZ>W3 z*xNV$Gc$BO{3ZBD@Ru|t8du^sWyLRAC4Lh8CfpYSo@4f-YbfURzoaBR>f$mr;?I&exb-9-wr#Da1e$pwUUAvSfvcSW-oOv<8-|Fp@+W`1W zPZ)W#HGdxWA;r8OWO*(1H>+}d|F;wAFYJCWv%jp%7wxR>r)`M?NEKAHdDXqj{wHtK zaa3d;PplW{0eVTkB>ELSYkD7?Zl%|*b7@mP@zM31zm%>Uq+*kF{(8y7Uqb#H5RTMK zR7G*(eDd8($m1ltO8(Lf)|I7v`2oXnO#cMxOOPMSRCcBv82A%6SWA#pldJ222Nt2zmD%QHqaQ>8aocG$@=L7(D*0j5*cj>59 zpxxa_w$~k328NO4_IO0_{hb+@%DZVN?!!`HQq98d{zmo>B+j;LJBQe$+h2aw55?Zz zUHE#ao=Zsk<~%{W*<1FV3IC1D+;QGqOV5;B={vw$nkWG8Hy$kh@r5=Rz|lssyS#?u z*@R_%JOD};VfxoFEs-DS3{T6Xcg%(2ty2=!ZX? z_WDocjHd$eXLqKS^xsN;h-o6Hk2qjjPFP!A34#BWc3A{hm#|x z0`cj0{|ad?r{7#~CB47P_UVgGtsYPMuT!WJkQ3wrIoaoleUfpNJgpx&$$=-d%GWK9 zAg>%b6^JkArI33NsX2eAC3mjKiL<=kVLa)#=LJY2$O&?RoRZ7MK4Dxf43pDB;*?*y zY^kqlT0)0q`{{?}TsaL4zuIZFVm7y0uv0*cr9$N7ea;B}At%TMa>|Im3;EggVRCZ( z#G2N2KJl@(lMZ|Amu**01H-3*)Y8#FKXzMjd;zVZ*E&AdBPTAwc;aU-W`LX^7sv^4 zkY8p|n4IvPSX(>aR7Q26TsaL4e{Mmop*Q~c&>T7G8OP70%)oL=lb?nAN8C>W4)>YJ zkJOm&pC6@#D(rr8|934Z+ttH7=e;{jnTh&-(gQLi$;NN#eUR+6(e^{^RSVSpEbAph z93uY<;)dN}+Ii&rZeKX!!l!e}u9=qZu`IrHNR}MChErpYpIFuWwPzk!IO`~d)AFIJ zs^6aez)`>aPNjxzd+o6G7o1zQ>&d%KW%zxrcrbCm5x?wx{fhFnM6YTz;h#k~mi6ny zxW0vGZ6bWe;r!=`F4Njs@LxIp`e~o|?c)z#dd%c$jK6f&5ml#Nf8d(B?;O=bJoK#C z<%Fv5FMa>inQv7xUe$Sjf8(%A&U&)S>fUV)@v!Exd!J71x&NdG|F~0mHSth=-@W@( zO?}{iDl75B!>!)C4&C*p^aC5Gq>eh`jJ>O@^oQ=Qz3ZX1&wS0AKKpkyYaiKoL+^*L zUthh;$E|g@JyKJB*Tn7wv6#5$x;+yl%bunq_wU*7Kc881^f$*=9rw@&6#qAV`)6x@ zn0;&NF=g*l@_6r@qk0aR_RN|;%p0rZ(Ms~z`iIZ=)XXbU@;GtNnl)?w+ETUOp8NI? zjh@QSJ-?>>f_YWnJ^p=FM5Ai*yN~awsX4G`)3?SljWw04pH6Jse|68de`Zw^jcS(J zJ2A|ecv8abca*XZAMjJcOIJOYoK*Gm8GHAf+VcO_%-Q|=HRY@SMdM3{zy9{_BPw1! zBJt^xI}_*q>({+pT2iX(@R#37yY#@1R(pR!Xu6t6=52n3#>|;~syr*XB z-1#S|F##NJ%=2><4I(_B-9VL;rS~!Pb6PK~H_b4UCr=Kb%kM3DL&dbmADc53Y%?D3 zv)P{aFI;HgB6e$rI;Hub_xlt-WtKhePD<@U<2YTV-z`07MtGZJt(`4eVHkOsLa96v zCRrYJ&DDEPMblXKk}19Kk9E&p6!ctu2%n2@+CcK5_XrV_+*o!j4*j0AHK6yTYY0E= zxj2s;j3e_s>CEKc&hYN150hF#n}J@S2k0f+sPzJUg%TJc6}-2dZ-1NLHDM37T*aDc zEF&UZ4l)1@ec!biPe&^XM5D z&HD5Mhy3=-RfkMF>i+Z3`I52r0NWp@WrM^2B*qUjm|VX3k{;!6)yyXk8hP{Olux++ zz^fz}?>K_r0DA`gDG`rNSa#$7d$L{jzTa8z5pJoEn)~EXgKV9~@ikXpt$bF-mOw0lSOT#GVhO|&h$Rq9AeKNZfmi~u1Y!xq5_l&{fcGln{la&m z=Houc5{M-bOCXj&EP+@8u>@iX#1e=l5KADIKrDe+0Q3B(c@m;?&hPyEk;j~ef! zdf%SRZGZl8mbI0N?XT(c)B7mR7O2qotGe98oImeq`k=jHPV1tkI_rQ*lMXTg zBIGY57?Znk&&j#)H(jaP-+Z%<8{2Etd6}8Z`FVgre?{Qi6e z#b}=yW8SuH)cjB~FaXbi6THe_X*`FXgMD~8CxHhzw0DZ&IrIc?`Di~L;DDETS-0oV zbMSNm?EwyW_HQ(vLr?H5>jU(Y7$pv$fCFCRUo@UW&uPz(2RPtu+@kRudQN+{sG$Hp z0SCOsLzb)Y;LsDi%29qizyYuFP>tu%bMSNm?EwyWt3D;|NxNWk`DlN8fCJv<>-2ba z=m}!-U4A^k0dMJz8qcBU;Q8eVIN)V5Po!P&IkUvy9^ilnIES7T%q(%+tE4i_KR#20 z6_mV1^9g*6Q*+$0Tp1tmz~5G<=P~F4-yBE42R!iYCXEleXxAK1zz00=R|-DpqTMij zzyp7q;Davk&2fqL0T2AjGc^C83p{gt0w3_eUoH5ci+Ssedr=JV0S|n;S+@_mXg3TW z@W7uU_@E2>hJIjNpnbptzftf(*TFaD8SnuQ{H)-EF7#&3H{b&v_)A-K|3Me>3&RII z@TatDe9(pb!tenP{FQGnYv{SU(jJn&Bue9#5o;KS4(;DMhJ ze9(p7O*;bl10MKG&(!>bF4_&l2R!gs2|nmz{yFx|kRRZI4?hBQA-6Dmzylxh0bTGN zgx^iT@KogO`2S!DjU9bYMu|MCfajxpk7 zvwUaP67+p0Q}qYdTbc|#@bF!f9fY5_b%w^qxyM_Lyq~t20C9#1LPvmjbi1h*_5$|9{<^jwuq#-%&2>KM$rt;*&(s%6_D8 zBV4i{h;zOVXZvw-$#`{z5cCv7USFX$SYBpeN~~#A>AVRwa`Lbr&;#@W4xulVPv1nc ztX_}0|MlH8{ms7h>o`A)I()Kp`u|DHWgK8XW*YUn*7?i(6N&Tb%o+Fm)00cSQZkD! z2PQNDy)XdU_X0Dee0gu(NJz6@)cL(K-<~F>d>sg3lFa809sJ+nx?M4UR}eL@hp>OJ zhgq?Qu#?XHgzQhiF2YWN9>*V-ts$P@?|BdJBxuQd?O~(%Q;3rt+74#VD$E`N5A)9M z3c_Dt*uRHnXnx^edAZL`qd&?XZdy;cWDgMs+QUlv^#{J91s{C_MN@J8spwPh7w8rG((HVe^wPH0WsUO>DSP-R6}~7HutzUQ9`+D<)Vp+4O4~zn2*e)J>5(s5 zUn$|sfDFkpeb!;nI;E+UJ4eSvry9~&k&B@h=mB~`v(T5y_pYEZewCw_ySF_uT9dDmA; z_)=u@{^2pf^Ce6#CKWTEm-u$5WW6M*UD&}tQaXYd99ieG+~;$FVc%iTVc#>~(Ecv$ zKK2W6-;Vtc>|>yv*`!s?;PIQbP+*ew;G4Y?)d-?ZQmQm^d$ z&ShTv-fexZbOl`+FW+C*3tv4lj!js72g3F}KxroM2*^V(&;#@W)}SwyPv1l9EQR#* zDn9>OOU;)(J9m&&dFSUQeA=?~nQL;e8r>frUi$e`zCuYl6__t!dNHY( zd56#lNw8j4QNLjiUn9PvjHsY9WX&#pPzrXDnCX_&6@|W_#8E5(kpM=KUm!ewyQv8N z0{n-hye9y^!rZTi9|6AtRC!P1XtKNTA4*C}%Y*!f%td;gN1Q!Z=d%|Etp6bHiD3_c z2YZ-V9)O=dQ|zAH4<^$7yoYX3vVTq6&ZGDbUU>!i51NYW zKSZOd`Q09Wp%>@@dP#ms^ecMSys^J9YrV_onCX4Xbsqi$--ql+J^%;!3%C#AytiHf zxqr0(KzaB6u-p1%*$QjC&~rg^Q>+)h4kz2rey|xztlv?s|J*(kW+dgI7w7?cu`kei zfxa})W65v$#ai(H`vy1f)eC-jB}^}<2OWt6^|I7jVKo?lL0Ra9{Rj9dUbqrwNuQ0z3{h?<}Z(aV@=tg|0r%L)5YaMFp_LH z)j9Rz-{YKm11u+7LM3mjbL#EOs7E{IIrZJdIqv^Pkz!ak2)Iwg`)ha)3-58^J`wO7 z&maQ0A1o`ISrT+VXg|0A2i5t*5+^g-FSWlCa6jiIE@`aSz{7eSjQ>yan>gV834U(x z$?Ht$U+xbF{r<#_giH5>X^A~^M4zwE?@!F6w;uS479g*n`$0{`y&sHDHCo$44F$bG z56}zRfxdM9Dw5?t*P_ni_bJkPe|a*=oaMszd)SwR9^l_1k9voWN@&}44L#dnz0lmG z=Tb9EK3~e0A2KY*^uzpHlZu%Sqq)7xSugxM&X{-45F?cBH;~}o7QvqK(|wBdI}~0y zvYh#}bhKAVm0Z7@Tw{d~hTBAC$bX!G7)FN@=Un#pQUTvf;d>qaojY&-eJV%oZT(a^ z`p5N|btURM5T>W^#WQ?Rdldd4vDOzzd0>S657brF<`y-Sxlf`DW#AI$rtg zZ2h~>SIK*X*ZAK@25(-u>oR(n9U-v(D+mtlGh@u#DINN~Z%PIR;5l%DH?C9TIrJQC z|MyUULwi%YtZHK9Gg0q+#SbLcsEd-?bT9Pl!N=g@QdH&><59^imC zZnoyrp(l9DNBi*r2fU=9fCJt}!E@*dUVWJ#4{*S< zkJ0^e=s9?P{Q(YmQv}bU=k)JRdP~9m?hbIkTPb)BJ*R(uJiq}DcEF)0cr!-%^#C~F zfh~uggXgx#-+95l;5&(>AA3`cPvB!-oAZkG$oPN<{wl!-UErJZ1o(glzWs6CKIo!d zbG`r{@W7uU_@Ik+!|(wQ{LPiReb9ydGVKG}2R!h{eL~}dF7VBH1bn~)f0f{aF4{Hc z6Yv4=;IG<6trMV&c0=(69{3w254w=wIGJY{7ib^wz_-7v+g~MgAa`?q0w3_eZxnpc zMZ016fCv65-_z}bF7TIz@DF(4X9OQ~(QX(%;DKLxn{FR;A-|0w?E@b8+kUF?K^O81 z!v{R@lMiTo&;{RN_<#rgQo#pZj33i(L4SY;{zkzE-Eq?IFnquR-(I8n2VKZN$Nq`^ z0X**aoDXZyahTP^Q}BhG$Ro4i0Oa9p2Dy4lE%H_@l-fwOq%X=G^f7zju*-KDiSAG$UgDqh1n0_VVv4Q z_~|QUoZ>r@k@uZ0%TMh?dj(199rin2(F62?Zb4u430$vboi|;VC#z3+e$m>>uYb+S z1Kt~BTw$M>^SqCIr~=}B;bgV-#VhDF{baTNP2mdl&OTL_(#?IjP1009KJF!&C(ZPE zub;=U8B9uM-h|)Yd7kH+hzVQf+eS)9u^(f$H{%0rf4TGz_6_z7_AR+m+c(%f_?M=i z2fxe9#&p_aI)MuApyv&H>@j0hP0)80H;6rxID3W8C$B5az5x&ODD#aV{Fh|j;5k^< zuy1(J(u|$McI-jA zrUmBV*b9Tq-3A+XJiC;_nR``6+ckoY`4y5 zzoTQ&3yU%Tfrt5@xhVkO{7&uk3ub7#*nbLZMVF7KesIv=sWC2przUaEdx?Dh*go`) z8ejbfue=rpC`(gu@5iE1?Z4e(K|n9i1N4%-Mf59rE>`D=m$@MBL-D9as*K|F1^Zh!-_q}H5zwFt*4?alr2)BROpmqprzU>=2C6ceaDhE3h6(;o%>yLK#~+(B z6}Q%)?R_sk7o+$k%m-iWlJNOGw&y!F?;b%gCu6OhEm~m${Fz|*!IzfO?j8q{!9DhC)N55n(M_k-$QlI{l)d)5N?`CNV3c#E%S z0rCpEAJkOb`@!f`qqRNMP|yqX0KH^y*Ls1zRJ(lcnYF-Ko9Jy7C+~gUa7YM~mr8j^ zJ=@iUdG6Vv^dv*SuMk4iC=TV?lODP*57zh3x(`i`X+=mC03{z&Tu`f~NM|2lVQv0ilEtCt_R z0KOFFOGtg7UT!13+~%W_Ka*cCys@@p=>>X#UhKQIUZ5{64Ob63UqO1Q^ytYivl}Gq zeQwIn=kD`^NlyD=p67G)y=^z`Ms-`O0^xHDxxFyGFeUZmPtbHem&QmbfgOV$pqH%p z4bYdx&*jpm&G`QJE|m2}=b@Lo$Q@cwmsjpTqC5xah4bEeRVmNsw2zmpK1LN@@dI1;E7xHeL?xXvd<#b8x)`X=F z5IqIiC9m%d2YYAhfYRI46-F_7x%-r8$FXDld@dt?_Ky2}F8|7*pU;(AZnlnd}z1d@r^yqKO8ti*M_mHe>5-0By|6`qw zz4U_8nE$}T{I`D^fS+?eR?PRo_3r}}`nydmuSdkrc>BA>dR^=Mt)i6ocURPVMomPykat`jm2vD)m4tae)?E-) zlUnifm@m)+^paVl_2Qp5>`#u|_a#|c`Ss%7kL@V$OF|FOOR`t%1^Ut>gy)O*eM!#m zNb|+LAKOvp3-kcJ*lV?3pf5)+BlLYq&0l}>g%_S3$G=PytIz}Vk`=!J`qKT*|Gp%z zBYeO0MTyyO;ec|^qn_>;sD`fa_a$j?r$?VlP*DTZ53^&Mk}yOB3!@yBgQ0_S))&zO z^nw_#LwPw@5fxp2lZ9okCnV_?#FQdh5Iq|8TV=UP7UV+;64oB=>mQx%@wvY zCr|-@$538=WJ%!nYZvI>!L%h#{!IMBpX(U(!eY#S;9>sTf%p%~{Kxle*ZK2djY#86-lBs-;Gm&I|q{uzwdmHN$`RmR5G1^x?vSeoIM7qFDcnrPpIKDCf zo+){;&Ahj6B&1m{{5zPKZ}IP7y5C27_2Zl~DCY|HQ6&#Edz73rDCZA)aX3AH-u$}u zrnb(emPOX0113#6$OMRxzmQ;LNITHm($~g)*=-5G +@E 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

Hh0OF_?^2f)^P4LTyB~>Wq~PjRR1LT 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⁣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𝔙xq`+T&${`ZCBu$j?Q->!0Q!k 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

QwuPZj%X#lflK0AY@MWQqF`G13*|9UUl#uf1c) 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 Callable: @wraps(test_function) def wrap(*args, **kwargs) -> None: - args[0].reuse_keys = False + reuse_keys = args[0].reuse_keys + was_enabled = reuse_keys.enabled + reuse_keys.disable() test_function(*args, **kwargs) + if was_enabled: + reuse_keys.enable() return wrap class NodeTestBase(unittest.TestCase): - def setUp(self): - self.test_dir = pathlib.Path(get_testdir()) / self._relative_id() - self.reuse_keys = True - self.key_reuse_dir = self.test_dir.parent / 'key_reuse' + reuse_keys: Optional[NodeKeyReuseConfig] = None - def tearDown(self): - key_reuse = conftest.NodeKeyReuse.get() - if key_reuse.enabled and not key_reuse.keys_ready: - try: - self._copy_keystores() - except FileNotFoundError: - print('Copying keystores failed...') - return + @classmethod + def setUpClass(cls): + NodeKeyReuseConfig.set_dir(get_testdir()) + cls.reuse_keys = NodeKeyReuseConfig.get() - key_reuse.mark_keys_ready() + @classmethod + def tearDownClass(cls): + NodeKeyReuseConfig.reset() + + def setUp(self): + self.test_dir = get_testdir() / self._relative_id() def _relative_id(self): return self.id().replace(__name__ + '.', '') - def _can_recycle_keys(self) -> bool: - return conftest.NodeKeyReuse.get().keys_ready and self.reuse_keys - @staticmethod def _get_nodes_ids(test_path: str) -> 'List[NodeId]': config = get_config(test_path) @@ -68,7 +65,7 @@ def _run_test(self, test_path: str, *args, **kwargs): self.datadirs[node_id] = datadir os.makedirs(datadir) - cwd = pathlib.Path(__file__).resolve().parent.parent + cwd = Path(__file__).resolve().parent.parent test_args = [ str(cwd / 'run_test.py'), test_path, @@ -89,48 +86,8 @@ def _run_test(self, test_path: str, *args, **kwargs): if conftest.DumpOutput.enabled_on_crash(): test_args.append('--dump-output-on-crash') - if self._can_recycle_keys(): - self._recycle_keys() - + assert self.reuse_keys is not None, "reuse_keys is not configured" + self.reuse_keys.begin_test(self.datadirs) exit_code = subprocess.call(args=test_args) + self.reuse_keys.end_test() self.assertEqual(exit_code, 0) - - @staticmethod - def _replace_keystore(src: pathlib.Path, dst: pathlib.Path) -> None: - src_file = src / 'keystore.json' - dst_file = dst / KEYSTORE_DIR / 'keystore.json' - os.makedirs(str(dst / KEYSTORE_DIR)) - shutil.copyfile(str(src_file), str(dst_file)) - - def _recycle_keys(self): - # this is run before running second and later tests - for i, node_id in enumerate(self.nodes): - reuse_dir = self.key_reuse_dir / str(i) - if not reuse_dir.exists(): - continue - NodeTestBase._replace_keystore( - reuse_dir, self.datadirs[node_id]) - - def _copy_keystores(self): - # this is run after tests - self._prepare_keystore_reuse_folders() - for i, node_id in enumerate(self.nodes): - NodeTestBase._copy_keystore( - self.datadirs[node_id], self.key_reuse_dir / str(i)) - - def _prepare_keystore_reuse_folders(self) -> None: - # this is run after tests - try: - for i in range(len(self.nodes)): - reuse_dir = self.key_reuse_dir / str(i) - shutil.rmtree(reuse_dir, ignore_errors=True) - os.makedirs(reuse_dir) - except OSError: - print('Unexpected problem with creating folders for keystore') - raise - - @staticmethod - def _copy_keystore(datadir: pathlib.Path, reuse_dir: pathlib.Path) -> None: - src = str(datadir / KEYSTORE_DIR / 'keystore.json') - dst = str(reuse_dir / 'keystore.json') - shutil.copyfile(src, dst) diff --git a/scripts/node_integration_tests/tests/test_fails.py b/scripts/node_integration_tests/tests/test_fails.py new file mode 100644 index 0000000000..000c183ba5 --- /dev/null +++ b/scripts/node_integration_tests/tests/test_fails.py @@ -0,0 +1,3 @@ +class TestFails: + def test_fails(self): + self.fail() diff --git a/scripts/node_integration_tests/tests/test_golem.py b/scripts/node_integration_tests/tests/test_golem.py index 61cc5a56f0..315d14d500 100644 --- a/scripts/node_integration_tests/tests/test_golem.py +++ b/scripts/node_integration_tests/tests/test_golem.py @@ -10,8 +10,8 @@ class GolemNodeTest(NodeTestBase): def test_regular_task_run(self): self._run_test('golem.regular_run') - def test_no_concent(self): - self._run_test('golem.no_concent') + def test_concent(self): + self._run_test('golem.concent') def test_rpc(self): self._run_test('golem.rpc_test') @@ -48,3 +48,24 @@ def test_zero_price(self): def test_task_output_directory(self): self._run_test('golem.task_output') + + def test_large_result(self): + self._run_test( + 'golem.separate_hyperg', + **{'task-package': 'cubes', 'task-settings': '3k-low-samples'}, + ) + + def test_restart_failed_subtasks(self): + self._run_test('golem.restart_failed_subtasks') + + def test_main_scene_file(self): + self._run_test('golem.nested_column') + + def test_multinode_regular_run(self): + self._run_test('golem.multinode_regular_run') + + def test_disabled_verification(self): + self._run_test('golem.disabled_verification') + + def test_lenient_verification(self): + self._run_test('golem.lenient_verification') diff --git a/scripts/pyinstaller/hooks/hook-eventlet.py b/scripts/pyinstaller/hooks/hook-eventlet.py new file mode 100644 index 0000000000..eb4fd6e6dc --- /dev/null +++ b/scripts/pyinstaller/hooks/hook-eventlet.py @@ -0,0 +1,3 @@ +from PyInstaller.utils.hooks import collect_submodules + +hiddenimports = collect_submodules('eventlet') diff --git a/scripts/pyinstaller/hooks/hook-golem.py b/scripts/pyinstaller/hooks/hook-golem.py index ef15e2db0d..cb7f08de6f 100644 --- a/scripts/pyinstaller/hooks/hook-golem.py +++ b/scripts/pyinstaller/hooks/hook-golem.py @@ -9,11 +9,8 @@ datas = [ ('loggingconfig.py', '.'), ('apps/*.ini', 'apps/'), - ('apps/entrypoint.sh', 'apps/'), ('apps/core/resources/images/*', 'apps/core/resources/images/'), - ('apps/rendering/benchmark/minilight/cornellbox.ml.txt', - 'apps/rendering/benchmark/minilight/'), ('apps/blender/resources/images/*.Dockerfile', 'apps/blender/resources/images/'), ('apps/blender/resources/images/entrypoints/scripts/render_tools/templates/' @@ -30,6 +27,8 @@ ('golem/RELEASE-VERSION', 'golem/'), ('golem/TERMS.html', 'golem/'), ('golem/database/schemas/*.py', 'golem/database/schemas/'), + ('golem/envs/docker/benchmark/minilight/cornellbox.ml.txt', + 'golem/envs/docker/benchmark/minilight/'), ('golem/network/concent/resources/ssl/certs/*.crt', 'golem/network/concent/resources/ssl/certs/'), ('scripts/docker/create-share.ps1', 'scripts/docker/'), diff --git a/scripts/pyinstaller/hooks/hook-numpy.core.py b/scripts/pyinstaller/hooks/hook-numpy.core.py new file mode 100644 index 0000000000..3c41d70e03 --- /dev/null +++ b/scripts/pyinstaller/hooks/hook-numpy.core.py @@ -0,0 +1,35 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2013-2018, PyInstaller Development Team. +# +# Distributed under the terms of the GNU General Public License with exception +# for distributing bootloader. +# +# The full license is in the file COPYING.txt, distributed with this software. +# ----------------------------------------------------------------------------- +# If numpy is built with MKL support it depends on a set of libraries loaded +# at runtime. Since PyInstaller's static analysis can't find them they must be +# included manually. +# +# See +# https://github.com/pyinstaller/pyinstaller/issues/1881 +# https://github.com/pyinstaller/pyinstaller/issues/1969 +# for more information +import os.path +import re +from PyInstaller.utils.hooks import get_package_paths +from PyInstaller import log as logging +from typing import List + +binaries: List = [] + +logger = logging.getLogger(__name__) + +# look for libraries in numpy package path +pkg_base, pkg_dir = get_package_paths('numpy') +dll_dir = os.path.join(pkg_dir, 'DLLs') +logger.info("pkg_base=%r, pkg_dir=%r, dll_dir=%r", pkg_base, pkg_dir, dll_dir) +if os.path.exists(dll_dir): + re_anylib = re.compile(r'\w+\.(?:dll|so|dylib)', re.IGNORECASE) + dlls_pkg = [f for f in os.listdir(dll_dir) if re_anylib.match(f)] + logger.info("dlls_pkg=%r", dlls_pkg) + binaries += [(os.path.join(dll_dir, f), '.') for f in dlls_pkg] diff --git a/scripts/test-daemon-start.sh b/scripts/test-daemon-start.sh index bb660516de..4b758043fc 100755 --- a/scripts/test-daemon-start.sh +++ b/scripts/test-daemon-start.sh @@ -27,6 +27,9 @@ kill_running_hyperg () { kill_running_hyperg +# cleanup zombie workers +pkill -f hyperg || echo "nothing to kill, good to go" + echo "Starting hyperg" hyperg > /dev/null 2>&1 & H_PID=$! diff --git a/scripts/test-daemon-stop.sh b/scripts/test-daemon-stop.sh index f5a8ac640f..b8385bad88 100755 --- a/scripts/test-daemon-stop.sh +++ b/scripts/test-daemon-stop.sh @@ -5,4 +5,5 @@ rm $H_FILE || echo "Error, not able to delete '$H_FILE'" kill $H_PID || echo "Error, not able to stop hyperg" + exit 0 diff --git a/scripts/virtualization/check-hyperv-installation.ps1 b/scripts/virtualization/check-hyperv-installation.ps1 new file mode 100644 index 0000000000..d65003df18 --- /dev/null +++ b/scripts/virtualization/check-hyperv-installation.ps1 @@ -0,0 +1,17 @@ +# This scripts checks whether Hyper-V feature is: +# a) available +# b) installed + +$ErrorActionPreference = "Stop" + +$HyperVFeature = Get-WmiObject -query "select * from Win32_OptionalFeature where name = 'Microsoft-Hyper-V'" + +if ($HyperVFeature) { + "Hyper-V available" + AI_SetMsiProperty HYPER_V_AVAILABLE "True" + + if ($HyperVFeature.InstallState -eq 1) { + AI_SetMsiProperty HYPER_V_INSTALLED "True" + "Hyper-V installed" + } +} diff --git a/setup.py b/setup.py index 6eb4a2e2b0..0b3c5a4728 100755 --- a/setup.py +++ b/setup.py @@ -80,8 +80,9 @@ path.normpath('apps/registered_test.ini'), path.normpath('apps/images.ini') ]), - (path.normpath('../../golem/apps/rendering/benchmark/minilight'), [ - path.normpath('apps/rendering/benchmark/minilight/cornellbox.ml.txt'), + (path.normpath('../../golem/golem/envs/docker/benchmark/minilight'), [ + path.normpath( + 'golem/envs/docker/benchmark/minilight/cornellbox.ml.txt'), ]), (path.normpath( '../../golem/apps/blender/resources/images/entrypoints/scripts/' diff --git a/tests/apps/blender/benchmark/test_blenderbenchmark.py b/tests/apps/blender/benchmark/test_blenderbenchmark.py index 4568bd6d8e..4be17d2976 100644 --- a/tests/apps/blender/benchmark/test_blenderbenchmark.py +++ b/tests/apps/blender/benchmark/test_blenderbenchmark.py @@ -65,6 +65,7 @@ def test_run(self): task_definition, dir_manager ).build() + task.initialize(dir_manager) success = mock.MagicMock() error = mock.MagicMock() diff --git a/tests/apps/blender/task/test_blenderrendertask.py b/tests/apps/blender/task/test_blenderrendertask.py index e6b1232251..379445ba3b 100644 --- a/tests/apps/blender/task/test_blenderrendertask.py +++ b/tests/apps/blender/task/test_blenderrendertask.py @@ -639,7 +639,8 @@ def _task_dictionary(self): "resolution": [ 320, 240 - ] + ], + "samples": 150 } } @@ -653,8 +654,18 @@ def test_build(self): dir_manager=DirManager( self.tempdir)) blender_task = builder.build() + blender_task.initialize(builder.dir_manager) self.assertIsInstance(blender_task, BlenderRenderTask) + def test_build_dictionary_samples(self): + task_type = BlenderTaskTypeInfo() + task_dict = self._task_dictionary + samples = task_dict.get('options').get('samples') + dictionary = BlenderRenderTaskBuilder.build_full_definition( + task_type, task_dict) + result = BlenderRenderTaskBuilder.build_dictionary(dictionary) + self.assertEqual(result['options']['samples'], samples) + def test_build_correct_format(self): task_type = BlenderTaskTypeInfo() task_dict = self._task_dictionary diff --git a/tests/apps/blender/verification/test_extension_matcher.py b/tests/apps/blender/verification/test_extension_matcher.py new file mode 100644 index 0000000000..9f1dd02c33 --- /dev/null +++ b/tests/apps/blender/verification/test_extension_matcher.py @@ -0,0 +1,31 @@ +import typing +import unittest + +from apps.blender.resources.images.entrypoints.\ + scripts.verifier_tools.file_extension import matcher, types + + +class TestExtensionMatcher(unittest.TestCase): + def test_bmp_expected_extension(self): + self._assert_expected_extension(types.Bmp().extensions, '.bmp') + + def test_jpeg_expected_extension(self): + self._assert_expected_extension(types.Jpeg().extensions, '.jpg') + + def test_tga_expected_extension(self): + self._assert_expected_extension(types.Tga().extensions, '.tga') + + def test_unknown_extension(self): + extension = '.unkwn' + + alias = matcher.get_expected_extension(extension) + + self.assertEqual(extension, alias) + + def _assert_expected_extension( + self, + aliases: typing.Iterable[str], + expected: str + ): + for alias in aliases: + self.assertEqual(matcher.get_expected_extension(alias), expected) diff --git a/tests/apps/ffmpeg/task/test_ffmpegtask.py b/tests/apps/ffmpeg/task/test_ffmpegtask.py index 370473c66b..b6ce2a3ed8 100644 --- a/tests/apps/ffmpeg/task/test_ffmpegtask.py +++ b/tests/apps/ffmpeg/task/test_ffmpegtask.py @@ -48,9 +48,12 @@ def setUp(self): def _build_ffmpeg_task(self, subtasks_count=1, stream=RESOURCE_STREAM): td = self.tt.task_builder_type.build_definition( self.tt, self._task_dictionary(subtasks_count, stream)) - return self.tt.task_builder_type(dt_p2p_factory.Node(), td, - DirManager( - self.tempdir)).build() + + dir_manager = DirManager(self.tempdir) + task = self.tt.task_builder_type(dt_p2p_factory.Node(), td, + dir_manager).build() + task.initialize(dir_manager) + return task def _task_dictionary(self, subtasks_count=1, stream=RESOURCE_STREAM): return { @@ -224,6 +227,7 @@ def test_less_subtasks_than_requested(self): from apps.transcoding.task import logger with self.assertLogs(logger, level="WARNING") as log: task = builder.build() + task.initialize(DirManager(self.tempdir)) assert any("subtasks was requested but video splitting process" in log for log in log.output) diff --git a/tests/factories/granary.py b/tests/factories/granary.py new file mode 100644 index 0000000000..de4d7be566 --- /dev/null +++ b/tests/factories/granary.py @@ -0,0 +1,78 @@ +import subprocess +from typing import Optional + +import shlex + +from eth_utils import encode_hex, decode_hex +from ethereum.utils import sha3 + +from golem_messages.cryptography import ECCx + +_logging = False + + +def _log(*args): + # Private log function since pytest.tearDown() does not print logger + if _logging: + print(*args) + + +class Account: + def __init__(self, raw_key: bytes, ts: str): + self.key = ECCx(raw_key) + assert self.key.raw_privkey == raw_key + self.raw_key = self.key.raw_privkey + _log("Account created: " + str(self.raw_key)) + self.transaction_store = ts + + +class Granary: + def __init__(self, hostname: str): + self.hostname = hostname + + def request_account(self) -> Optional[Account]: + _log("Granary called, account requested") + cmd = ['ssh', self.hostname, 'golem-granary', 'get_used_account'] + + completed = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True) + _log('returncode:', completed.returncode, 'stderr:', completed.stderr) + + if not completed.stdout: + print('stdout: EMPTY') + return None + + _log('stdout:', completed.stdout) + + out_lines = completed.stdout.split('\n') + raw_key = out_lines[0].strip() + + key = decode_hex(raw_key) + _log('raw_key:', raw_key, 'key:', key) + return Account(key, out_lines[1] or '{}') + + def return_account(self, account: Account): + _log("Granary called, account returned. account=", account) + + key_pub_addr = encode_hex(sha3(account.key.raw_pubkey)[12:]) + + ts = account.transaction_store or '{}' + ts = shlex.quote(ts) + + cmd = ['ssh', self.hostname, 'golem-granary', 'return_used_account', + '-p', key_pub_addr, '-P', encode_hex(account.raw_key), '-t', ts] + + _log(cmd) + completed = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True) + _log( + 'returncode:', completed.returncode, + 'stdout:', completed.stdout, + 'stderr:', completed.stderr) + return diff --git a/tests/factories/model.py b/tests/factories/model.py index de9219808f..ba7bd9733f 100644 --- a/tests/factories/model.py +++ b/tests/factories/model.py @@ -1,32 +1,44 @@ -from factory import Factory, Faker, SubFactory +import factory + +from golem_messages.factories.datastructures import p2p as p2p_factory +from golem_messages.factories.helpers import ( + random_eth_address, + random_eth_pub_key, +) -from golem_messages.factories.datastructures import p2p from golem import model -class Income(Factory): +class CachedNode(factory.Factory): class Meta: - model = model.Income + model = model.CachedNode - sender_node = Faker('binary', length=64) - payer_address = '0x' + 40 * '3' - subtask = Faker('uuid4') - value = Faker('pyint') + node = factory.LazyAttribute(lambda o: o.node_field.key) + node_field = factory.SubFactory(p2p_factory.Node) -class PaymentDetails(Factory): +class WalletOperation(factory.Factory): class Meta: - model = model.PaymentDetails + model = model.WalletOperation - node_info = SubFactory(p2p.Node) - fee = Faker('pyint') + direction = factory.fuzzy.FuzzyChoice(model.WalletOperation.DIRECTION) + operation_type = factory.fuzzy.FuzzyChoice(model.WalletOperation.TYPE) + sender_address = factory.LazyFunction(random_eth_address) + recipient_address = factory.LazyFunction(random_eth_address) + amount = factory.fuzzy.FuzzyInteger(1, 10 << 20) + currency = factory.fuzzy.FuzzyChoice(model.WalletOperation.CURRENCY) + gas_cost = 0 -class Payment(Factory): +class TaskPayment(factory.Factory): class Meta: - model = model.Payment - - subtask = Faker('uuid4') - payee = Faker('binary', length=20) - value = Faker('pyint') - details = SubFactory(PaymentDetails) + model = model.TaskPayment + + wallet_operation = factory.SubFactory( + WalletOperation, + status=model.WalletOperation.STATUS.awaiting, + ) + node = factory.LazyFunction(random_eth_pub_key) + task = factory.Faker('uuid4') + subtask = factory.Faker('uuid4') + expected_amount = factory.fuzzy.FuzzyInteger(1, 10 << 20) diff --git a/tests/factories/task/taskstate.py b/tests/factories/task/taskstate.py index 527041eadd..7ce3c7b7e1 100644 --- a/tests/factories/task/taskstate.py +++ b/tests/factories/task/taskstate.py @@ -9,7 +9,7 @@ class SubtaskState(factory.Factory): class Meta: model = taskstate.SubtaskState - node_id = '00adbeef' + 'deadbeef' * 15 + node_id = '0xadbeef' + 'deadbeef' * 15 subtask_id = factory.Faker('uuid4') deadline = factory.LazyFunction( lambda: int(time.time()) + 10, diff --git a/tests/factories/taskserver.py b/tests/factories/taskserver.py index 84174892b1..58a0a1f82e 100644 --- a/tests/factories/taskserver.py +++ b/tests/factories/taskserver.py @@ -6,6 +6,17 @@ from golem.task import taskserver +class ClientConfigDescriptor(factory.Factory): + class Meta: + model = clientconfigdescriptor.ClientConfigDescriptor + + @factory.post_generation + # pylint: disable=attribute-defined-outside-init + def set_min_hardware_requirements(self, *_, **__): + self.max_memory_size = 1024 * 1024 # 1 GiB + self.num_cores = 1 + + class TaskServer(factory.Factory): class Meta: model = taskserver.TaskServer @@ -13,7 +24,7 @@ class Meta: node = factory.SubFactory( 'golem_messages.factories.datastructures.p2p.Node', ) - config_desc = clientconfigdescriptor.ClientConfigDescriptor() + config_desc = factory.SubFactory(ClientConfigDescriptor) use_docker_manager = False diff --git a/tests/golem/core/keygen_benchmark.py b/tests/golem/core/keygen_benchmark.py deleted file mode 100644 index 3351c85318..0000000000 --- a/tests/golem/core/keygen_benchmark.py +++ /dev/null @@ -1,21 +0,0 @@ -import os -import pytest - -from golem.core.keysauth import KeysAuth - - -def skip_benchmarks(): - if os.environ.get('benchmarks', False): - return False - return True - - -def key_gen(d: int): - return KeysAuth._generate_keys(difficulty=d) - - -@pytest.mark.skipif(skip_benchmarks(), reason="skip benchmarks by default") -@pytest.mark.parametrize("d", [10, 11, 12, 13, 14, 15, 16]) -@pytest.mark.benchmark(min_rounds=20, warmup=False) -def test_key_gen_speed(benchmark, d: int): - benchmark(key_gen, d) diff --git a/tests/golem/core/test_deferred.py b/tests/golem/core/test_deferred.py index e569b39b91..5d565e4213 100644 --- a/tests/golem/core/test_deferred.py +++ b/tests/golem/core/test_deferred.py @@ -1,51 +1,88 @@ import unittest +from unittest import mock -from twisted.internet.defer import Deferred +from twisted.internet.defer import Deferred, succeed, fail from twisted.python.failure import Failure -from golem.core.deferred import chain_function +from golem.core.deferred import chain_function, DeferredSeq + + +@mock.patch('golem.core.deferred.deferToThread', lambda x: succeed(x())) +@mock.patch('twisted.internet.reactor', mock.Mock(), create=True) +class TestDeferredSeq(unittest.TestCase): + + def test_init_empty(self): + assert not DeferredSeq()._seq + + def test_init_with_functions(self): + def fn_1(): + pass + + def fn_2(): + pass + + assert DeferredSeq().push(fn_1).push(fn_2)._seq == [ + (fn_1, (), {}), + (fn_2, (), {}), + ] + + @mock.patch('golem.core.deferred.DeferredSeq._execute') + def test_execute_empty(self, execute): + deferred_seq = DeferredSeq() + with mock.patch('golem.core.deferred.DeferredSeq._execute', + wraps=deferred_seq._execute): + deferred_seq.execute() + assert execute.called + + def test_execute_functions(self): + fn_1, fn_2 = mock.Mock(), mock.Mock() + + DeferredSeq().push(fn_1).push(fn_2).execute() + assert fn_1.called + assert fn_2.called + + def test_execute_interrupted(self): + fn_1, fn_2, fn_4 = mock.Mock(), mock.Mock(), mock.Mock() + + def fn_3(*_): + raise Exception + + def def2t(f, *args, **kwargs) -> Deferred: + try: + return succeed(f(*args, **kwargs)) + except Exception as exc: # pylint: disable=broad-except + return fail(exc) + + with mock.patch('golem.core.deferred.deferToThread', def2t): + DeferredSeq().push(fn_1).push(fn_2).push(fn_3).push(fn_4).execute() + + assert fn_1.called + assert fn_2.called + assert not fn_4.called class TestChainFunction(unittest.TestCase): def test_callback(self): - deferred = Deferred() - deferred.callback(True) - - def fn(): - d = Deferred() - d.callback(True) - return d + deferred = succeed(True) + result = chain_function(deferred, lambda: succeed(True)) - result = chain_function(deferred, fn) assert result.called assert result.result assert not isinstance(result, Failure) def test_main_errback(self): - deferred = Deferred() - deferred.errback(Exception()) + deferred = fail(Exception()) + result = chain_function(deferred, lambda: succeed(True)) - def fn(): - d = Deferred() - d.callback(True) - return d - - result = chain_function(deferred, fn) assert result.called assert result.result assert isinstance(result.result, Failure) def test_fn_errback(self): - deferred = Deferred() - deferred.callback(True) - - def fn(): - d = Deferred() - d.errback(Exception()) - return d + deferred = succeed(True) + result = chain_function(deferred, lambda: fail(Exception())) - result = chain_function(deferred, fn) assert result.called assert result.result assert isinstance(result.result, Failure) diff --git a/tests/golem/core/test_keysauth.py b/tests/golem/core/test_keysauth.py index 449685deb9..1cb1f06b50 100644 --- a/tests/golem/core/test_keysauth.py +++ b/tests/golem/core/test_keysauth.py @@ -1,18 +1,19 @@ +# pylint: disable=protected-access import os import shutil import time from random import random, randint from unittest.mock import patch +from eth_utils import decode_hex, encode_hex from golem_messages import message from golem_messages.cryptography import ECCx, privtopub -from golem_messages.factories.datastructures.tasks import TaskHeaderFactory +from golem_messages.factories import tasks as tasks_factory from golem import testutils from golem.core.keysauth import ( KeysAuth, get_random, get_random_float, sha2, WrongPassword) from golem.tools.testwithreactor import TestWithReactor -from eth_utils import decode_hex, encode_hex class TestKeysAuth(testutils.PEP8MixIn, testutils.TempDirFixture): @@ -20,7 +21,6 @@ class TestKeysAuth(testutils.PEP8MixIn, testutils.TempDirFixture): def _create_keysauth( self, - difficulty=0, key_name=None, password='') -> KeysAuth: if key_name is None: @@ -29,7 +29,6 @@ def _create_keysauth( datadir=self.path, private_key_name=key_name, password=password, - difficulty=difficulty, ) def test_sha(self): @@ -59,44 +58,6 @@ def test_pubkey_suits_privkey(self): ka = self._create_keysauth() self.assertEqual(ka.public_key, privtopub(ka._private_key)) - def test_difficulty(self): - difficulty = 5 - ek = self._create_keysauth(difficulty) - assert difficulty <= ek.difficulty - assert ek.difficulty == KeysAuth.get_difficulty(ek.key_id) - - def test_get_difficulty(self): - difficulty = 8 - ek = self._create_keysauth(difficulty) - # first 8 bits of digest must be 0 - assert sha2(ek.public_key).to_bytes(256, 'big')[0] == 0 - assert KeysAuth.get_difficulty(ek.key_id) >= difficulty - assert KeysAuth.is_pubkey_difficult(ek.public_key, difficulty) - assert KeysAuth.is_pubkey_difficult(ek.key_id, difficulty) - - def test_exception_difficulty(self): - # given - lower_difficulty = 0 - req_difficulty = 7 - priv_key = str(random())[2:] - assert lower_difficulty < req_difficulty # just in case - - keys_dir = KeysAuth._get_or_create_keys_dir(self.path) - # create key that has difficulty lower than req_difficulty - while True: - ka = self._create_keysauth(lower_difficulty, priv_key) - if not ka.is_difficult(req_difficulty): - break - shutil.rmtree(keys_dir) # to enable keys regeneration - - assert KeysAuth.get_difficulty(ka.key_id) >= lower_difficulty - assert KeysAuth.get_difficulty(ka.key_id) < req_difficulty - - # then - with self.assertRaisesRegex(Exception, - "Loaded key is not difficult enough"): - self._create_keysauth(difficulty=req_difficulty, key_name=priv_key) - def test_save_keys(self): # given keys_dir = KeysAuth._get_or_create_keys_dir(self.path) @@ -117,9 +78,8 @@ def test_key_successful_load(self, logger): private_key = ek._private_key public_key = ek.public_key del ek - assert logger.info.call_count == 2 + assert logger.info.call_count == 1 assert logger.info.call_args_list[0][0][0] == 'Generating new key pair' - assert logger.info.call_args_list[1][0][0] == 'Keys generated in %.2fs' logger.reset_mock() # just in case # when @@ -180,14 +140,7 @@ def test_fixed_sign_verify(self): # pylint: disable=too-many-locals ek.key_id = encode_hex(ek.public_key)[2:] ek.ecc = ECCx(ek._private_key) - msg = message.tasks.WantToComputeTask( - node_name='node_name', - perf_index=2200, - price=5 * 10 ** 18, - max_resource_size=250000000, - max_memory_size=300000000, - task_header=TaskHeaderFactory(), - ) + msg = tasks_factory.WantToComputeTaskFactory() dumped_l = msg.serialize( sign_as=ek._private_key, encrypt_func=lambda x: x) @@ -208,27 +161,3 @@ def test_keystore(self): with self.assertRaises(WrongPassword): self._create_keysauth(key_name=key_name, password='wrong_pw') - - -class TestKeysAuthWithReactor(TestWithReactor): - - @patch('golem.core.keysauth.logger') - def test_generate_keys_stop_when_reactor_stopped(self, logger): - # given - from twisted.internet import threads - reactor = self._get_reactor() - - # when - threads.deferToThread(KeysAuth._generate_keys, difficulty=200) - - time.sleep(0.01) - reactor.stop() - time.sleep(0.01) - - # then - assert not reactor.running - assert logger.info.call_count == 1 - assert logger.info.call_args_list[0][0][0] == 'Generating new key pair' - assert logger.warning.call_count == 1 - assert logger.warning.call_args_list[0][0][0] == \ - 'reactor stopped, aborting key generation ..' diff --git a/tests/golem/core/test_simpleserializer.py b/tests/golem/core/test_simpleserializer.py index 6a3b1d9d1e..abc9c6480e 100644 --- a/tests/golem/core/test_simpleserializer.py +++ b/tests/golem/core/test_simpleserializer.py @@ -1,3 +1,4 @@ +from enum import Enum import random import unittest @@ -46,6 +47,11 @@ def __eq__(self, other): self.property_4 == other.property_4 +class MockEnum(Enum): + Name1 = "value1" + Name2 = 2 + + def assert_properties(first, second): assert first.__class__ == second.__class__ @@ -99,6 +105,15 @@ def test_serialization_as_class(self) -> None: MockSerializationSubject )) + def test_enum_serialization(self): + dict_repr = DictSerializer.dump(MockEnum.Name1) + + assert len(dict_repr) == 1 + assert "py/enum" in dict_repr + assert dict_repr["py/enum"].endswith(".MockEnum.Name1") + + assert DictSerializer.load(dict_repr) == MockEnum.Name1 + def test_serialization_result(self): obj = MockSerializationSubject() self.assertEqual( diff --git a/tests/golem/database/test_migration.py b/tests/golem/database/test_migration.py index 9962fb0244..52a5c7a755 100644 --- a/tests/golem/database/test_migration.py +++ b/tests/golem/database/test_migration.py @@ -3,6 +3,7 @@ from contextlib import contextmanager from unittest import TestCase from unittest.mock import patch +import uuid import os @@ -147,7 +148,7 @@ def test_upgrade_twice(self): create_db_data() # -- Create a schema snapshot (0) - create_migration(data_dir, out_dir) + create_migration(data_dir, out_dir, migration_name='test_0_init') assert len(os.listdir(out_dir)) == 1 # -- Store initial state of the database (0) @@ -167,7 +168,7 @@ def test_upgrade_twice(self): ExtraTestModel.create_table() # -- Create a new migration file (1) - create_migration(data_dir, out_dir) + create_migration(data_dir, out_dir, migration_name='test_1_ETM') assert len(os.listdir(out_dir)) == 2 # -- Add a second model, alter other and bump version (2) @@ -178,7 +179,7 @@ def test_upgrade_twice(self): SecondExtraTestModel.create_table() # -- Create a new migration file (2) - create_migration(data_dir, out_dir) + create_migration(data_dir, out_dir, migration_name='test_2_SETM') assert len(os.listdir(out_dir)) == 3 # -- Drop the model table, downgrade version (0) @@ -312,22 +313,32 @@ def test_same_version(self): def test_18(self, _create_tables_mock): with self.database_context() as database: database._migrate_schema(6, 17) + sender_node = 'adbeef' + 'deadbeef' * 15 + subtask_id = str(uuid.uuid4()) database.db.execute_sql( "INSERT INTO income (" "sender_node, subtask, value, created_date, modified_date," " overdue" ")" - " VALUES ('0xdead', '0xdead', 10, datetime('now')," - " datetime('now'), 0)" + f" VALUES (?, ?, 10, datetime('now')," + " datetime('now'), 0)", + ( + sender_node, + subtask_id, + ), ) database._migrate_schema(17, 18) cursor = database.db.execute_sql( "SELECT payer_address FROM income" - " WHERE sender_node = '0xdead' AND subtask = '0xdead'" - " LIMIT 1" + f" WHERE sender_node = ? AND subtask = ?" + " LIMIT 1", + ( + sender_node, + subtask_id, + ), ) value = cursor.fetchone()[0] - self.assertEqual(value, '0eeA941c1244ADC31F53525D0eC1397ff6951C9C') + self.assertEqual(value, 'c106A6f2534E74b9D5890d13C5991A3fB146Ae52') @patch('golem.database.Database._create_tables') def test_20_income_value_received(self, _create_tables_mock): @@ -351,6 +362,154 @@ def test_20_income_value_received(self, _create_tables_mock): value = cursor.fetchone()[0] self.assertEqual(value, '10') + @patch('golem.database.Database._create_tables') + def test_30_wallet_operation_alter(self, _create_tables_mock): + tx_hash = ( + '0x8f30cb104c188f612f3492f53c069f65a4c4e2a8d4432a4878b1fd33f36787d3' + ) + with self.database_context() as database: + database._migrate_schema(6, 29) + cursor = database.db.execute_sql( + "INSERT INTO walletoperation" + " (tx_hash, direction, operation_type, status," + " sender_address, recipient_address, gas_cost," + " amount, currency, created_date, modified_date)" + " VALUES" + " (?, 'outgoing', 'task_payment', 'awaiting'," + " '', '', 0," + " 1, 'GNT', datetime('now'), datetime('now'))", + ( + tx_hash, + ) + ) + wallet_operation_id = cursor.lastrowid + # Migration used to fail because of foreign key and + # sqlite inability to DROP NOT NULL + cursor.execute( + "INSERT INTO taskpayment" + " (wallet_operation_id, node, task, subtask," + " accepted_ts, settled_ts," + " expected_amount, created_date, modified_date)" + " VALUES" + " (?, '', '', ''," + " datetime('now'), datetime('now')," + " 1, datetime('now'), datetime('now'))", + ( + wallet_operation_id, + ) + ) + database._migrate_schema(29, 30) + cursor = database.db.execute_sql( + "SELECT tx_hash FROM walletoperation" + " LIMIT 1" + ) + value = cursor.fetchone()[0] + self.assertEqual(value, tx_hash) + + @patch('golem.database.Database._create_tables') + def test_31_payments_migration(self, *_args): + with self.database_context() as database: + database._migrate_schema(6, 30) + + details = '{"node_info": {"node_name": "Laughing Octopus", "key": "392e54805752937326aa87da97a69c9271f7b4423382fb2563a349d54c44d9a904f38b4f2e3a022572c8257220426d8e5e34198be2cc8971bc149f1a368161e3", "prv_port": 40201, "pub_port": 40201, "p2p_prv_port": 40200, "p2p_pub_port": 40200, "prv_addr": "10.30.8.12", "pub_addr": "194.181.80.91", "prv_addresses": ["10.30.8.12", "172.17.0.1"], "hyperdrive_prv_port": 3282, "hyperdrive_pub_port": 3282, "port_statuses": {"3282": "timeout", "40200": "timeout", "40201": "timeout"}, "nat_type": "Symmetric NAT"}, "fee": 116264444444444, "block_hash": "184575de00b91fdac0ccd1c763d5b56b967898e3a541f400480b01a6dbf1fef9", "block_number": 1937551, "check": null, "tx": "4b9f628f16c82d0fe3f3ab144feef7940a0093107d521b45a8a0bfb5739400be"}' # noqa pylint: disable=line-too-long + database.db.execute_sql( + "INSERT INTO payment (" + " subtask, created_date, modified_date, status," + " payee, value, details)" + " VALUES ('0xdead', datetime('now'), datetime('now'), 1," + " '0x0eeA941c1244ADC31F53525D0eC1397ff6951C9C', 10," + f" '{details}')" + ) + database._migrate_schema(30, 31) + + # UNIONS don't work here. Do it manually + cursor = database.db.execute_sql("SELECT count(*) FROM payment") + payment_count = cursor.fetchone()[0] + cursor = database.db.execute_sql( + "SELECT count(*) FROM walletoperation", + ) + wo_count = cursor.fetchone()[0] + cursor = database.db.execute_sql("SELECT count(*) FROM taskpayment") + tp_count = cursor.fetchone()[0] + # Migrated payments shouldn't be removed + self.assertEqual(payment_count, 1) + self.assertEqual(wo_count, 1) + self.assertEqual(tp_count, 1) + + @patch('golem.database.Database._create_tables') + def test_32_incomes_migration(self, *_args): + with self.database_context() as database: + database._migrate_schema(6, 31) + + database.db.execute_sql( + "INSERT INTO income (" + " subtask, sender_node, created_date, modified_date," + " overdue," + " payer_address, value_received, value)" + " VALUES ('0xdead', '0xdead', datetime('now'), datetime('now')," + " 1," + " '0x0eeA941c1244ADC31F53525D0eC1397ff6951C9C'," + " '1', '2')" + ) + database._migrate_schema(31, 32) + + # UNIONS don't work here. Do it manually + cursor = database.db.execute_sql("SELECT count(*) FROM income") + income_count = cursor.fetchone()[0] + cursor = database.db.execute_sql( + "SELECT count(*) FROM walletoperation", + ) + wo_count = cursor.fetchone()[0] + cursor = database.db.execute_sql("SELECT count(*) FROM taskpayment") + tp_count = cursor.fetchone()[0] + # Migrated incomes shouldn't be removed + self.assertEqual(income_count, 1) + self.assertEqual(wo_count, 1) + self.assertEqual(tp_count, 1) + + @patch('golem.database.Database._create_tables') + def test_33_deposit_payments_migration(self, *_args): + with self.database_context() as database: + database._migrate_schema(6, 32) + + tx_hash = ( + '0xc9d936c0c1a10f19ab2952ccb4901a1118ea9a' + '4f78379ee2ebaa7f9e7beb1eb5' + ) + value = 'af7a173aa545c72' + status = 2 # sent + fee = 'af7a173aa545c71' + + database.db.execute_sql( + "INSERT INTO depositpayment (" + " tx, value, status, fee," + " created_date, modified_date)" + " VALUES (?, ?, ?, ?," + " datetime('now'), datetime('now'))", + ( + tx_hash, value, status, fee, + ) + ) + database._migrate_schema(32, 33) + + cursor = database.db.execute_sql( + "SELECT count(*) FROM walletoperation", + ) + wo_count = cursor.fetchone()[0] + self.assertEqual(wo_count, 1) + cursor.execute( + 'SELECT tx_hash, status, amount, gas_cost FROM walletoperation', + ) + self.assertCountEqual( + cursor.fetchone(), + [ + tx_hash, + 'sent', + value, + fee, + ], + ) + def generate(start, stop): return ['{:03}_script'.format(i) for i in range(start, stop + 1)] diff --git a/tests/golem/docker/docker-blender-cycles-task.json b/tests/golem/docker/docker-blender-cycles-task.json index f45aef494d..145e7a69d8 100644 --- a/tests/golem/docker/docker-blender-cycles-task.json +++ b/tests/golem/docker/docker-blender-cycles-task.json @@ -11,8 +11,8 @@ { "py/object": "golem.docker.image.DockerImage", "repository": "golemfactory/blender", - "tag": "1.9", - "name": "golemfactory/blender:1.9", + "tag": "1.10", + "name": "golemfactory/blender:1.10", "id": null } ], @@ -55,10 +55,11 @@ { "py/object": "golem.docker.image.DockerImage", "repository": "golemfactory/blender", - "tag": "1.9", - "name": "golemfactory/blender:1.9", + "tag": "1.10", + "name": "golemfactory/blender:1.10", "id": null } ], - "concent_enabled": false + "concent_enabled": false, + "run_verification":{"py/enum":"apps.core.task.coretaskstate.RunVerification.enabled"} } diff --git a/tests/golem/docker/docker-blender-render-task.json b/tests/golem/docker/docker-blender-render-task.json index f548cd959a..c7c4b1113d 100644 --- a/tests/golem/docker/docker-blender-render-task.json +++ b/tests/golem/docker/docker-blender-render-task.json @@ -11,10 +11,10 @@ "docker_images":[ { "py/object":"golem.docker.image.DockerImage", - "tag":"1.9", + "tag":"1.10", "id":null, "repository":"golemfactory/blender", - "name":"golemfactory/blender:1.9" + "name":"golemfactory/blender:1.10" } ], "caps":[], @@ -44,10 +44,10 @@ "docker_images":[ { "py/object":"golem.docker.image.DockerImage", - "tag":"1.9", + "tag":"1.10", "id":null, "repository":"golemfactory/blender", - "name":"golemfactory/blender:1.9" + "name":"golemfactory/blender:1.10" } ], "resolution":[ @@ -64,5 +64,6 @@ "task_type":"Blender", "subtask_timeout":1200, "subtasks_count":6, - "concent_enabled": false + "concent_enabled": false, + "run_verification":{"py/enum":"apps.core.task.coretaskstate.RunVerification.enabled"} } diff --git a/tests/golem/docker/docker-dummy-test-task.json b/tests/golem/docker/docker-dummy-test-task.json index efabae1f1c..152857eb35 100644 --- a/tests/golem/docker/docker-dummy-test-task.json +++ b/tests/golem/docker/docker-dummy-test-task.json @@ -55,5 +55,6 @@ } ], "concent_enabled": false, - "output_file": "output.png" + "output_file": "output.png", + "run_verification":{"py/enum":"apps.core.task.coretaskstate.RunVerification.enabled"} } diff --git a/tests/golem/docker/test_blender_job.py b/tests/golem/docker/test_blender_job.py index f3210502ff..8c9705f490 100644 --- a/tests/golem/docker/test_blender_job.py +++ b/tests/golem/docker/test_blender_job.py @@ -14,7 +14,7 @@ def _get_test_repository(self): return "golemfactory/blender" def _get_test_tag(self): - return "1.9" + return "1.10" def test_blender_job(self): # copy the scene file to the resources dir diff --git a/tests/golem/docker/test_docker_blender_task.py b/tests/golem/docker/test_docker_blender_task.py index f2d93f5795..42100d3888 100644 --- a/tests/golem/docker/test_docker_blender_task.py +++ b/tests/golem/docker/test_docker_blender_task.py @@ -91,6 +91,7 @@ def test_build(self): dir_manager, ) task = builder.build() + task.initialize(builder.dir_manager) assert isinstance(task, BlenderRenderTask) assert not task.compositing assert not task.use_frames @@ -109,7 +110,7 @@ def test_build(self): assert task.header.environment == 'BLENDER' assert task.header.estimated_memory == 0 assert task.docker_images[0].repository == 'golemfactory/blender' - assert task.docker_images[0].tag == '1.9' + assert task.docker_images[0].tag == '1.10' assert task.header.max_price == 12 assert not task.header.signature assert task.listeners == [] diff --git a/tests/golem/docker/test_docker_dummy_task.py b/tests/golem/docker/test_docker_dummy_task.py index 713a50683d..0167eb9cc9 100644 --- a/tests/golem/docker/test_docker_dummy_task.py +++ b/tests/golem/docker/test_docker_dummy_task.py @@ -3,7 +3,7 @@ import os from os import path -from unittest import mock +from unittest import mock, skip from unittest.mock import Mock from twisted.internet.defer import Deferred @@ -29,7 +29,7 @@ WAIT_TIMEOUT = 60 -@ci_skip +@skip("Disabled because it leaves zombie processes #4165") class TestDockerDummyTask( DockerTaskTestCase[DummyTask, DummyTaskBuilder], TestWithReactor ): diff --git a/tests/golem/docker/test_docker_environment.py b/tests/golem/docker/test_docker_environment.py index fcbdc9de78..292de69429 100644 --- a/tests/golem/docker/test_docker_environment.py +++ b/tests/golem/docker/test_docker_environment.py @@ -25,7 +25,7 @@ def test_docker_environment(self): DockerEnvironmentMock(additional_images=["aaa"]) de = DockerEnvironmentMock(additional_images=[ - DockerImage("golemfactory/blender", tag="1.9")]) + DockerImage("golemfactory/blender", tag="1.10")]) self.assertTrue(de.check_support()) self.assertTrue(de.check_docker_images()) diff --git a/tests/golem/docker/test_docker_job.py b/tests/golem/docker/test_docker_job.py index 4107453fa7..3c729ed33d 100644 --- a/tests/golem/docker/test_docker_job.py +++ b/tests/golem/docker/test_docker_job.py @@ -100,7 +100,6 @@ def _create_test_job(self, script=TEST_SCRIPT, params=None): resources_dir=self.resources_dir, work_dir=self.work_dir, output_dir=self.output_dir, - environment=DockerJob.get_environment(), host_config={ 'binds': { self.work_dir: { diff --git a/tests/golem/docker/test_docker_manager.py b/tests/golem/docker/test_docker_manager.py index 7eaf48b6cc..196a980767 100644 --- a/tests/golem/docker/test_docker_manager.py +++ b/tests/golem/docker/test_docker_manager.py @@ -52,7 +52,7 @@ def done_cb(_): hypervisor = mock.Mock() hypervisor.constraints.return_value = DEFAULTS - hypervisor.restart_ctx.return_value = mock.Mock( + hypervisor.reconfig_ctx.return_value = mock.Mock( __enter__=mock.Mock(), __exit__=mock.Mock(), ) diff --git a/tests/golem/docker/test_docker_task.py b/tests/golem/docker/test_docker_task.py index edb8a08273..fa193e0e1b 100644 --- a/tests/golem/docker/test_docker_task.py +++ b/tests/golem/docker/test_docker_task.py @@ -101,15 +101,18 @@ def _get_test_task(self) -> Task: dir_manager=DirManager(self.tempdir) ) task = task_builder.build() + task.initialize(task_builder.dir_manager) task.__class__._update_task_preview = lambda self_: () task.max_pending_client_results = 5 return task + @patch('golem.envs.docker.cpu.deferToThread', + lambda f, *args, **kwargs: f(*args, **kwargs)) def _run_task(self, task: Task, timeout: int = 60 * 5, *_) \ -> Optional[DockerTaskThread]: task_id = task.header.task_id node_id = '0xdeadbeef' - extra_data = task.query_extra_data(1.0, 0, node_id) + extra_data = task.query_extra_data(1.0, node_id, 'node_name') ctd = extra_data.ctd ctd['deadline'] = timeout_to_deadline(timeout) @@ -131,6 +134,8 @@ def _run_task(self, task: Task, timeout: int = 60 * 5, *_) \ self.node._run() ccd = ClientConfigDescriptor() + ccd.max_memory_size = 1024 * 1024 # 1 GiB + ccd.num_cores = 1 with patch("golem.network.concent.handlers_library.HandlersLibrary" ".register_handler"): @@ -142,7 +147,7 @@ def _run_task(self, task: Task, timeout: int = 60 * 5, *_) \ use_docker_manager=False ) - patch.object(task_server, 'create_and_set_result_package').start() + patch.object(task_server, '_create_and_set_result_package').start() task_server.task_keeper.task_headers[task_id] = task.header task_computer = task_server.task_computer @@ -166,8 +171,7 @@ def _run_task(self, task: Task, timeout: int = 60 * 5, *_) \ # Start task computation task_computer.task_given(ctd) - result = task_computer.resource_collected(ctd['task_id']) - self.assertTrue(result) + task_computer.start_computation() task_thread = None started = time.time() @@ -179,7 +183,7 @@ def _run_task(self, task: Task, timeout: int = 60 * 5, *_) \ if task_thread: task_thread.join(timeout) - task_computer.run() + task_computer.check_timeout() return task_thread diff --git a/tests/golem/docker/test_docker_task_thread.py b/tests/golem/docker/test_docker_task_thread.py index 8a999a6308..f50bcc6565 100644 --- a/tests/golem/docker/test_docker_task_thread.py +++ b/tests/golem/docker/test_docker_task_thread.py @@ -6,6 +6,7 @@ from golem.clientconfigdescriptor import ClientConfigDescriptor from golem.docker.image import DockerImage from golem.docker.task_thread import DockerTaskThread, EXIT_CODE_MESSAGE +from golem.envs.docker.cpu import DockerCPUEnvironment from golem.task.taskcomputer import TaskComputer from golem.tools.ci import ci_skip from golem.tools.testwithdatabase import TestWithDatabase @@ -25,14 +26,19 @@ def tearDown(self): def test_termination(self): task_server = Mock() task_server.config_desc = ClientConfigDescriptor() + task_server.config_desc.max_memory_size = 1024 * 1024 # 1 GiB + task_server.config_desc.num_cores = 1 task_server.client.datadir = self.test_dir task_server.benchmark_manager = Mock() task_server.benchmark_manager.benchmarks_needed.return_value = False task_server.client.get_node_name.return_value = "test_node" task_server.get_task_computer_root.return_value = \ task_server.client.datadir - task_computer = TaskComputer(task_server, - use_docker_manager=False) + docker_cpu_env = Mock(spec=DockerCPUEnvironment) + task_computer = TaskComputer( + task_server, + docker_cpu_env, + use_docker_manager=False) image = DockerImage("golemfactory/base", tag="1.4") with self.assertRaises(AttributeError): @@ -59,7 +65,7 @@ def test(): ct = task_computer.counting_thread while ct and ct.is_alive(): - task_computer.run() + task_computer.check_timeout() if time.time() - started > 15: self.fail("Job timed out") diff --git a/tests/golem/docker/test_dummy_job.py b/tests/golem/docker/test_dummy_job.py index c407463db8..bbaac05006 100644 --- a/tests/golem/docker/test_dummy_job.py +++ b/tests/golem/docker/test_dummy_job.py @@ -15,7 +15,7 @@ def _get_test_repository(self): return "golemfactory/dummy" def _get_test_tag(self): - return "1.1" + return "1.2" def test_dummytask_job(self): os.mkdir(os.path.join(self.resources_dir, "data")) diff --git a/tests/golem/docker/test_hyperv.py b/tests/golem/docker/test_hyperv.py index 0f532231c1..1bab6a81af 100644 --- a/tests/golem/docker/test_hyperv.py +++ b/tests/golem/docker/test_hyperv.py @@ -11,7 +11,8 @@ from os_win.exceptions import OSWinException from golem.docker.config import DOCKER_VM_NAME, MIN_CONSTRAINTS, CONSTRAINT_KEYS -from golem.docker.hypervisor.hyperv import HyperVHypervisor +from golem.docker.hypervisor.hyperv import HyperVHypervisor, Events, MESSAGES, \ + EVENTS from golem.docker.task_thread import DockerBind @@ -339,3 +340,24 @@ def _fail_to_start(_vm_name, state): )) start_vm.assert_called_once_with('test') logger.exception.assert_called_once() + + def test_log_and_publish_error(self): + params = {'SMB_PORT': 443} + expected = dict(EVENTS[Events.SMB]) + expected['data'] = MESSAGES[Events.SMB].format(**params) + + with patch(self.PATCH_BASE + '.publish_event') as publish_event: + self.hyperv._log_and_publish_event(Events.SMB, **params) + publish_event.assert_called_with(expected) + + def test_log_and_publish_warning(self): + params = {'mem_mb': 2048} + expected = dict(EVENTS[Events.MEM]) + expected['data'] = { + 'status': Events.MEM.value, + 'value': 2048, + } + + with patch(self.PATCH_BASE + '.publish_event') as publish_event: + self.hyperv._log_and_publish_event(Events.MEM, **params) + publish_event.assert_called_with(expected) diff --git a/tests/golem/docker/test_hypervisor.py b/tests/golem/docker/test_hypervisor.py index 238c28d16c..7200de476c 100644 --- a/tests/golem/docker/test_hypervisor.py +++ b/tests/golem/docker/test_hypervisor.py @@ -52,13 +52,17 @@ def command(self, key, machine_name=None, args=None, shell=False): class MockHypervisor(DockerMachineHypervisor): # pylint: disable=method-hidden - def __init__(self, manager=None, **_kwargs): super().__init__(manager or mock.Mock()) self.recover_ctx = self.ctx self.restart_ctx = self.ctx + self.reconfig_ctx = self.ctx self.constrain = mock.Mock() + @classmethod + def is_available(cls) -> bool: + return True + @contextmanager def ctx(self, name=None, *_): yield name @@ -72,12 +76,6 @@ def create(self, vm_name: Optional[str] = None, **params) -> bool: def constrain(self, name: Optional[str] = None, **params) -> None: pass - def restart_ctx(self, name: Optional[str] = None): - self.ctx(name) - - def recover_ctx(self, name: Optional[str] = None): - self.ctx(name) - class MockDockerManager(DockerManager): # pylint: disable=too-many-instance-attributes @@ -171,23 +169,6 @@ def test_remove(self): with self.assertLogs(level='WARN'): assert not hypervisor.remove('test') - def test_not_implemented(self): - hypervisor = Hypervisor(MockDockerManager()) - - with self.assertRaises(NotImplementedError): - hypervisor.constrain(VM_NAME, param_1=1) - - with self.assertRaises(NotImplementedError): - hypervisor.constraints(VM_NAME) - - with self.assertRaises(NotImplementedError): - with hypervisor.restart_ctx(VM_NAME): - pass - - with self.assertRaises(NotImplementedError): - with hypervisor.recover_ctx(VM_NAME): - pass - class TestVirtualBoxHypervisor(LogTestCase): @@ -214,7 +195,7 @@ def test_save_vm_state(self): session = self.hypervisor._save_state(mock.Mock()) assert session.machine.save_state.called - def test_restart_ctx(self): + def test_reconfig_ctx(self): machine = mock.Mock() session = mock.Mock() @@ -229,7 +210,7 @@ def test_restart_ctx(self): self.hypervisor.vm_running = mock.Mock(return_value=True) vms = [None] - with self.hypervisor.restart_ctx(VM_NAME) as vm: + with self.hypervisor.reconfig_ctx(VM_NAME) as vm: assert self.hypervisor.stop_vm.called assert session.console.power_down.called assert machine.create_session.called @@ -242,7 +223,7 @@ def test_restart_ctx(self): session.machine.state = None vms = [None] - with self.hypervisor.restart_ctx(VM_NAME) as vm: + with self.hypervisor.reconfig_ctx(VM_NAME) as vm: vms[0] = vm raise Exception assert vms[0].save_settings.called diff --git a/tests/golem/envs/__init__.py b/tests/golem/envs/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/golem/envs/docker/__init__.py b/tests/golem/envs/docker/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/golem/envs/docker/cpu/__init__.py b/tests/golem/envs/docker/cpu/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/golem/envs/docker/cpu/test_config.py b/tests/golem/envs/docker/cpu/test_config.py new file mode 100644 index 0000000000..d3d38e2a0d --- /dev/null +++ b/tests/golem/envs/docker/cpu/test_config.py @@ -0,0 +1,57 @@ +from pathlib import Path +from unittest import TestCase + +from golem.envs.docker.cpu import DockerCPUConfig + + +class TestFromDict(TestCase): + + def test_missing_values(self): + with self.assertRaises((TypeError, KeyError)): + DockerCPUConfig.from_dict({}) + + def test_extra_values(self): + with self.assertRaises(TypeError): + DockerCPUConfig.from_dict({ + 'work_dir': '/tmp/golem', + 'memory_mb': 2000, + 'cpu_count': 2, + 'extra': 'value' + }) + + def test_default_values(self): + config = DockerCPUConfig.from_dict({ + 'work_dir': '/tmp/golem' + }) + + self.assertEqual(config.work_dir, Path('/tmp/golem')) + self.assertIsNotNone(config.memory_mb) + self.assertIsNotNone(config.cpu_count) + + def test_custom_values(self): + config = DockerCPUConfig.from_dict({ + 'work_dir': '/tmp/golem', + 'memory_mb': 2137, + 'cpu_count': 12 + }) + + self.assertEqual(config.work_dir, Path('/tmp/golem')) + self.assertEqual(config.memory_mb, 2137) + self.assertEqual(config.cpu_count, 12) + + +class TestToDict(TestCase): + + def test_to_dict(self): + config_dict = DockerCPUConfig( + work_dir=Path('/tmp/golem'), + memory_mb=2137, + cpu_count=12 + ).to_dict() + + # We cannot assert exact path string because it depends on OS + self.assertEqual(Path(config_dict.pop('work_dir')), Path('/tmp/golem')) + self.assertEqual(config_dict, { + 'memory_mb': 2137, + 'cpu_count': 12 + }) diff --git a/tests/golem/envs/docker/cpu/test_env.py b/tests/golem/envs/docker/cpu/test_env.py new file mode 100644 index 0000000000..e36a976e0b --- /dev/null +++ b/tests/golem/envs/docker/cpu/test_env.py @@ -0,0 +1,591 @@ +from logging import Logger +from pathlib import Path +from unittest.mock import patch as _patch, Mock, MagicMock, ANY + +from twisted.trial.unittest import TestCase + +from golem.docker.config import CONSTRAINT_KEYS +from golem.docker.hypervisor import Hypervisor +from golem.docker.hypervisor.docker_for_mac import DockerForMac +from golem.docker.hypervisor.dummy import DummyHypervisor +from golem.docker.hypervisor.hyperv import HyperVHypervisor +from golem.docker.hypervisor.virtualbox import VirtualBoxHypervisor +from golem.docker.hypervisor.xhyve import XhyveHypervisor +from golem.docker.task_thread import DockerBind +from golem.envs import EnvStatus +from golem.envs.docker import DockerPrerequisites, DockerPayload +from golem.envs.docker.cpu import DockerCPUEnvironment, DockerCPUConfig + +cpu = CONSTRAINT_KEYS['cpu'] +mem = CONSTRAINT_KEYS['mem'] + + +def patch(name: str, *args, **kwargs): + return _patch(f'golem.envs.docker.cpu.{name}', *args, **kwargs) + + +def patch_handler(name: str, *args, **kwargs): + return patch(f'DockerCommandHandler.{name}', *args, **kwargs) + + +def patch_env(name: str, *args, **kwargs): + return patch(f'DockerCPUEnvironment.{name}', *args, **kwargs) + + +# pylint: disable=too-many-arguments +def patch_hypervisors(linux=False, windows=False, mac_os=False, hyperv=False, + vbox=False, docker_for_mac=False, xhyve=False): + def _wrapper(func): + return_values = { + 'is_linux': linux, + 'is_windows': windows, + 'is_osx': mac_os, + 'HyperVHypervisor.is_available': hyperv, + 'VirtualBoxHypervisor.is_available': vbox, + 'DockerForMac.is_available': docker_for_mac, + 'XhyveHypervisor.is_available': xhyve + } + for k, v in return_values.items(): + func = patch(k, return_value=v)(func) + return func + return _wrapper + + +class TestSupported(TestCase): + + @patch_env('_get_hypervisor_class', return_value=None) + def test_no_hypervisor(self, *_): + self.assertFalse(DockerCPUEnvironment.supported().supported) + + @patch_env('_get_hypervisor_class') + def test_ok(self, *_): + self.assertTrue(DockerCPUEnvironment.supported().supported) + + +class TestGetHypervisorClass(TestCase): + + @patch_hypervisors() + def test_unknown_os(self, *_): + self.assertIsNone(DockerCPUEnvironment._get_hypervisor_class()) + + @patch_hypervisors(linux=True) + def test_linux(self, *_): + self.assertEqual( + DockerCPUEnvironment._get_hypervisor_class(), DummyHypervisor) + + @patch_hypervisors(windows=True) + def test_windows_no_available_hypervisor(self, *_): + self.assertIsNone(DockerCPUEnvironment._get_hypervisor_class()) + + @patch_hypervisors(windows=True, hyperv=True) + def test_windows_only_hyperv(self, *_): + self.assertEqual( + DockerCPUEnvironment._get_hypervisor_class(), HyperVHypervisor) + + @patch_hypervisors(windows=True, vbox=True) + def test_windows_only_virtualbox(self, *_): + self.assertEqual( + DockerCPUEnvironment._get_hypervisor_class(), VirtualBoxHypervisor) + + @patch_hypervisors(windows=True, hyperv=True, vbox=True) + def test_windows_hyperv_and_virtualbox(self, *_): + # If this was possible (but isn't) Hyper-V should be preferred + self.assertEqual( + DockerCPUEnvironment._get_hypervisor_class(), HyperVHypervisor) + + @patch_hypervisors(mac_os=True) + def test_macos_no_available_hypervisor(self, *_): + self.assertIsNone(DockerCPUEnvironment._get_hypervisor_class()) + + @patch_hypervisors(mac_os=True, docker_for_mac=True) + def test_macos_only_docker_for_mac(self, *_): + self.assertEqual( + DockerCPUEnvironment._get_hypervisor_class(), DockerForMac) + + @patch_hypervisors(mac_os=True, xhyve=True) + def test_macos_only_xhyve(self, *_): + self.assertEqual( + DockerCPUEnvironment._get_hypervisor_class(), XhyveHypervisor) + + @patch_hypervisors(mac_os=True, docker_for_mac=True, xhyve=True) + def test_macos_docker_for_mac_and_xhyve(self, *_): + self.assertEqual( + DockerCPUEnvironment._get_hypervisor_class(), DockerForMac) + + +class TestInit(TestCase): + + @patch_env('_validate_config', side_effect=ValueError) + def test_invalid_config(self, *_): + with self.assertRaises(ValueError): + DockerCPUEnvironment(Mock(spec=DockerCPUConfig)) + + @patch_env('_validate_config') + @patch_env('_get_hypervisor_class', return_value=None) + def test_no_hypervisor(self, *_): + with self.assertRaises(EnvironmentError): + DockerCPUEnvironment(Mock(spec=DockerCPUConfig)) + + @patch_env('_validate_config') + @patch_env('_get_hypervisor_class') + def test_ok(self, get_hypervisor, *_): + config = DockerCPUConfig( + work_dir=Mock(spec=Path), + memory_mb=2137, + cpu_count=12 + ) + + def _instance(get_config_fn): + self.assertDictEqual(get_config_fn(), { + mem: config.memory_mb, + cpu: config.cpu_count + }) + get_hypervisor.return_value.instance = _instance + + DockerCPUEnvironment(config) + + +class TestDockerCPUEnv(TestCase): + + @patch_env('_validate_config') + @patch_env('_get_hypervisor_class') + def setUp(self, get_hypervisor, _): # pylint: disable=arguments-differ + self.hypervisor = Mock(spec=Hypervisor) + self.config = DockerCPUConfig(work_dir=Mock()) + get_hypervisor.return_value.instance.return_value = self.hypervisor + self.logger = Mock(spec=Logger) + with patch('logger', self.logger): + self.env = DockerCPUEnvironment(self.config) + + def _patch_async(self, name, *args, **kwargs): + patcher = patch(name, *args, **kwargs) + self.addCleanup(patcher.stop) + return patcher.start() + + def _patch_env_async(self, name, *args, **kwargs): + patcher = patch_env(name, *args, **kwargs) + self.addCleanup(patcher.stop) + return patcher.start() + + +class TestPrepare(TestDockerCPUEnv): + + def test_enabled_status(self): + self.env._status = EnvStatus.ENABLED + with self.assertRaises(ValueError): + self.env.prepare() + + def test_preparing_status(self): + self.env._status = EnvStatus.PREPARING + with self.assertRaises(ValueError): + self.env.prepare() + + def test_cleaning_up_status(self): + self.env._status = EnvStatus.CLEANING_UP + with self.assertRaises(ValueError): + self.env.prepare() + + def test_hypervisor_setup_error(self): + error = OSError("test") + self.hypervisor.setup.side_effect = error + error_occurred = self._patch_env_async('_error_occurred') + + deferred = self.env.prepare() + self.assertEqual(self.env.status(), EnvStatus.PREPARING) + deferred = self.assertFailure(deferred, OSError) + + def _check(_): + error_occurred.assert_called_once_with(error, ANY) + deferred.addCallback(_check) + return deferred + + def test_ok(self): + env_enabled = self._patch_env_async('_env_enabled') + + deferred = self.env.prepare() + self.assertEqual(self.env.status(), EnvStatus.PREPARING) + + def _check(_): + env_enabled.assert_called_once_with() + deferred.addCallback(_check) + return deferred + + +class TestCleanup(TestDockerCPUEnv): + + def test_disabled_status(self): + with self.assertRaises(ValueError): + self.env.clean_up() + + def test_preparing_status(self): + self.env._status = EnvStatus.PREPARING + with self.assertRaises(ValueError): + self.env.clean_up() + + def test_cleaning_up_status(self): + self.env._status = EnvStatus.CLEANING_UP + with self.assertRaises(ValueError): + self.env.clean_up() + + def test_hypervisor_quit_error(self): + self.env._status = EnvStatus.ENABLED + error = OSError("test") + self.hypervisor.quit.side_effect = error + error_occurred = self._patch_env_async('_error_occurred') + + deferred = self.env.clean_up() + self.assertEqual(self.env.status(), EnvStatus.CLEANING_UP) + deferred = self.assertFailure(deferred, OSError) + + def _check(_): + error_occurred.assert_called_once_with(error, ANY) + deferred.addCallback(_check) + return deferred + + def test_ok(self): + self.env._status = EnvStatus.ENABLED + env_disabled = self._patch_env_async('_env_disabled') + + deferred = self.env.clean_up() + self.assertEqual(self.env.status(), EnvStatus.CLEANING_UP) + + def _check(_): + env_disabled.assert_called_once_with() + deferred.addCallback(_check) + return deferred + + +class TestMetadata(TestCase): + + def test_metadata(self): + metadata = DockerCPUEnvironment.metadata() + self.assertEqual(metadata.id, DockerCPUEnvironment.ENV_ID) + self.assertEqual( + metadata.description, DockerCPUEnvironment.ENV_DESCRIPTION) + + +class TestInstallPrerequisites(TestDockerCPUEnv): + + def test_wrong_type(self): + with self.assertRaises(AssertionError): + self.env.install_prerequisites(object()) + + def test_env_disabled(self): + prereqs = Mock(spec=DockerPrerequisites) + with self.assertRaises(ValueError): + self.env.install_prerequisites(prereqs) + + def test_pull_image_error(self): + error = OSError("test") + local_client = MagicMock() + local_client.return_value.pull.side_effect = error + self._patch_async("local_client", local_client) + self._patch_async('Whitelist.is_whitelisted', return_value=True) + error_occurred = self._patch_env_async('_error_occurred') + self.env._status = EnvStatus.ENABLED + + prereqs = Mock(spec=DockerPrerequisites) + deferred = self.env.install_prerequisites(prereqs) + deferred = self.assertFailure(deferred, OSError) + + def _check(_): + error_occurred.assert_called_once_with(error, ANY, set_status=False) + deferred.addCallback(_check) + return deferred + + def test_not_whitelisted(self): + self.env._status = EnvStatus.ENABLED + self._patch_async('Whitelist.is_whitelisted', return_value=False) + prereqs_installed = self._patch_env_async('_prerequisites_installed') + + prereqs = Mock(spec=DockerPrerequisites) + deferred = self.env.install_prerequisites(prereqs) + + def _check(return_value): + self.assertFalse(return_value) + prereqs_installed.assert_not_called() + deferred.addCallback(_check) + return deferred + + def test_ok(self): + self.env._status = EnvStatus.ENABLED + self._patch_async('Whitelist.is_whitelisted', return_value=True) + prereqs_installed = self._patch_env_async('_prerequisites_installed') + local_client = self._patch_async('local_client') + + prereqs = Mock(spec=DockerPrerequisites) + deferred = self.env.install_prerequisites(prereqs) + + def _check(return_value): + self.assertTrue(return_value) + prereqs_installed.assert_called_once_with(prereqs) + local_client().pull.assert_called_once_with( + prereqs.image, + tag=prereqs.tag + ) + deferred.addCallback(_check) + return deferred + + +class TestUpdateConfig(TestDockerCPUEnv): + + def test_wrong_type(self): + with self.assertRaises(AssertionError): + self.env.update_config(object()) + + def test_enabled_status(self): + self.env._status = EnvStatus.ENABLED + with self.assertRaises(ValueError): + self.env.update_config(Mock(spec=DockerCPUConfig)) + + @patch_env('_validate_config', side_effect=ValueError) + def test_invalid_config(self, validate): + config = Mock(spec=DockerCPUConfig) + with self.assertRaises(ValueError): + self.env.update_config(config) + + validate.assert_called_once_with(config) + + @patch_env('_update_work_dir') + @patch_env('_config_updated') + @patch_env('_validate_config') + @patch_env('_constrain_hypervisor') + def test_work_dir_unchanged( + self, constrain, validate, config_updated, update_work_dir): + config = DockerCPUConfig(work_dir=self.config.work_dir) + self.env.update_config(config) + + validate.assert_called_once_with(config) + constrain.assert_called_once_with(config) + update_work_dir.assert_not_called() + config_updated.assert_called_once_with(config) + + @patch_env('_update_work_dir') + @patch_env('_config_updated') + @patch_env('_validate_config') + @patch_env('_constrain_hypervisor') + def test_config_changed( + self, constrain, validate, config_updated, update_work_dir): + config = DockerCPUConfig( + work_dir=Mock(), + memory_mb=2137, + cpu_count=12 + ) + self.env.update_config(config) + + validate.assert_called_once_with(config) + constrain.assert_called_once_with(config) + update_work_dir.assert_called_once_with(config.work_dir) + config_updated.assert_called_once_with(config) + self.assertEqual(self.env.config(), config) + + +class TestValidateConfig(TestCase): + + @staticmethod + def _get_config(work_dir_exists=True, **kwargs): + work_dir = Mock(spec=Path) + work_dir.is_dir.return_value = work_dir_exists + return DockerCPUConfig(work_dir=work_dir, **kwargs) + + def test_invalid_work_dir(self): + config = self._get_config(work_dir_exists=False) + with self.assertRaises(ValueError): + DockerCPUEnvironment._validate_config(config) + + def test_too_low_memory(self): + config = self._get_config(memory_mb=0) + with self.assertRaises(ValueError): + DockerCPUEnvironment._validate_config(config) + + def test_too_few_memory(self): + config = self._get_config(cpu_count=0) + with self.assertRaises(ValueError): + DockerCPUEnvironment._validate_config(config) + + def test_valid_config(self): + config = self._get_config() + DockerCPUEnvironment._validate_config(config) + + +class TestUpdateWorkDir(TestDockerCPUEnv): + + @patch_env('_error_occurred') + def test_hypervisor_error(self, error_occurred): + work_dir = Mock(spec=Path) + error = OSError("test") + self.hypervisor.update_work_dir.side_effect = error + + with self.assertRaises(OSError): + self.env._update_work_dir(work_dir) + error_occurred.assert_called_once_with(error, ANY) + + def test_ok(self): + work_dir = Mock(spec=Path) + self.env._update_work_dir(work_dir) + self.hypervisor.update_work_dir.assert_called_once_with(work_dir) + + +class TestConstrainHypervisor(TestDockerCPUEnv): + + def test_config_unchanged(self): + config = DockerCPUConfig(work_dir=Mock()) + self.hypervisor.constraints.return_value = { + mem: config.memory_mb, + cpu: config.cpu_count + } + + self.env._constrain_hypervisor(config) + self.hypervisor.reconfig_ctx.assert_not_called() + self.hypervisor.constrain.assert_not_called() + + @patch_env('_error_occurred') + def test_constrain_error(self, error_occurred): + config = DockerCPUConfig( + work_dir=Mock(), + memory_mb=1000, + cpu_count=1 + ) + self.hypervisor.constraints.return_value = { + mem: 2000, + cpu: 2 + } + self.hypervisor.reconfig_ctx = MagicMock() + error = OSError("test") + self.hypervisor.constrain.side_effect = error + + with self.assertRaises(OSError): + self.env._constrain_hypervisor(config) + error_occurred.assert_called_once_with(error, ANY) + + def test_config_changed(self): + config = DockerCPUConfig( + work_dir=Mock(), + memory_mb=1000, + cpu_count=1 + ) + self.hypervisor.constraints.return_value = { + mem: 2000, + cpu: 2 + } + self.hypervisor.reconfig_ctx = MagicMock() + + self.env._constrain_hypervisor(config) + self.hypervisor.reconfig_ctx.assert_called_once() + self.hypervisor.constrain.assert_called_once_with(**{ + mem: 1000, + cpu: 1 + }) + + +class TestRuntime(TestDockerCPUEnv): + + def test_invalid_payload_class(self): + with self.assertRaises(AssertionError): + self.env.runtime(object()) + + @patch('Whitelist.is_whitelisted', return_value=True) + def test_invalid_config_class(self, _): + with self.assertRaises(AssertionError): + self.env.runtime(Mock(spec=DockerPayload), config=object()) + + @patch('Whitelist.is_whitelisted', return_value=False) + def test_image_not_whitelisted(self, is_whitelisted): + payload = Mock(spec=DockerPayload) + with self.assertRaises(RuntimeError): + self.env.runtime(payload) + is_whitelisted.assert_called_once_with(payload.image) + + @patch('Whitelist.is_whitelisted', return_value=True) + @patch('DockerCPURuntime') + @patch_env('_create_host_config') + def test_default_config(self, create_host_config, runtime_mock, _): + payload = Mock(spec=DockerPayload) + runtime = self.env.runtime(payload) + + create_host_config.assert_called_once_with(self.config, None) + runtime_mock.assert_called_once_with( + payload, create_host_config(), None) + self.assertEqual(runtime, runtime_mock()) + + @patch('Whitelist.is_whitelisted', return_value=True) + @patch('DockerCPURuntime') + @patch_env('_create_host_config') + def test_custom_config(self, create_host_config, runtime_mock, _): + payload = Mock(spec=DockerPayload) + config = Mock(spec=DockerCPUConfig) + runtime = self.env.runtime(payload, config=config) + + create_host_config.assert_called_once_with(config, None) + runtime_mock.assert_called_once_with( + payload, create_host_config(), None) + self.assertEqual(runtime, runtime_mock()) + + @patch('Whitelist.is_whitelisted', return_value=True) + @patch('DockerCPURuntime') + @patch_env('_create_host_config') + def test_shared_dir(self, create_host_config, runtime_mock, _): + payload = Mock(spec=DockerPayload) + shared_dir = Mock(spec=Path) + runtime = self.env.runtime(payload, shared_dir=shared_dir) + + create_host_config.assert_called_once_with(self.config, shared_dir) + runtime_mock.assert_called_once_with( + payload, + create_host_config(), + [DockerCPUEnvironment.SHARED_DIR_PATH]) + self.assertEqual(runtime, runtime_mock()) + + +class TestCreateHostConfig(TestDockerCPUEnv): + + @patch('hardware.cpus', return_value=[1, 2, 3, 4, 5, 6]) + @patch('local_client') + def test_no_shared_dir(self, local_client, _): + config = DockerCPUConfig( + work_dir=Mock(spec=Path), + cpu_count=4, + memory_mb=2137 + ) + host_config = self.env._create_host_config(config, None) + + self.hypervisor.create_volumes.assert_not_called() + local_client().create_host_config.assert_called_once_with( + cpuset_cpus='1,2,3,4', + mem_limit='2137m', + binds=None, + privileged=False, + network_mode=DockerCPUEnvironment.NETWORK_MODE, + dns=DockerCPUEnvironment.DNS_SERVERS, + dns_search=DockerCPUEnvironment.DNS_SEARCH_DOMAINS, + cap_drop=DockerCPUEnvironment.DROPPED_KERNEL_CAPABILITIES + ) + self.assertEqual(host_config, local_client().create_host_config()) + + @patch('hardware.cpus', return_value=[1, 2, 3, 4, 5, 6]) + @patch('local_client') + def test_shared_dir(self, local_client, _): + config = DockerCPUConfig( + work_dir=Mock(spec=Path), + cpu_count=4, + memory_mb=2137 + ) + shared_dir = Mock(spec=Path) + host_config = self.env._create_host_config(config, shared_dir) + + self.hypervisor.create_volumes.assert_called_once_with([DockerBind( + source=shared_dir, + target=DockerCPUEnvironment.SHARED_DIR_PATH, + mode='rw' + )]) + local_client().create_host_config.assert_called_once_with( + cpuset_cpus='1,2,3,4', + mem_limit='2137m', + binds=self.hypervisor.create_volumes(), + privileged=False, + network_mode=DockerCPUEnvironment.NETWORK_MODE, + dns=DockerCPUEnvironment.DNS_SERVERS, + dns_search=DockerCPUEnvironment.DNS_SEARCH_DOMAINS, + cap_drop=DockerCPUEnvironment.DROPPED_KERNEL_CAPABILITIES + ) + self.assertEqual(host_config, local_client().create_host_config()) diff --git a/tests/golem/envs/docker/cpu/test_input.py b/tests/golem/envs/docker/cpu/test_input.py new file mode 100644 index 0000000000..663fe1188a --- /dev/null +++ b/tests/golem/envs/docker/cpu/test_input.py @@ -0,0 +1,28 @@ +from unittest import TestCase +from unittest.mock import Mock + +from golem.envs.docker.cpu import DockerInput, InputSocket + + +class TestWrite(TestCase): + + def test_raw(self): + sock = Mock(spec=InputSocket) + input_ = DockerInput(sock) + input_.write(b"test") + sock.write.assert_called_once_with(b"test") + + def test_encoded(self): + sock = Mock(spec=InputSocket) + input_ = DockerInput(sock, encoding="utf-8") + input_.write("żółw") + sock.write.assert_called_once_with("żółw".encode("utf-8")) + + +class TestClose(TestCase): + + def test_close(self): + sock = Mock(spec=InputSocket) + input_ = DockerInput(sock) + input_.close() + sock.close.assert_called_once() diff --git a/tests/golem/envs/docker/cpu/test_input_socket.py b/tests/golem/envs/docker/cpu/test_input_socket.py new file mode 100644 index 0000000000..0a3e73b213 --- /dev/null +++ b/tests/golem/envs/docker/cpu/test_input_socket.py @@ -0,0 +1,87 @@ +from socket import SocketIO, socket, SHUT_WR +from threading import RLock +from unittest import TestCase +from unittest.mock import Mock + +from urllib3.contrib.pyopenssl import WrappedSocket + +from golem.envs.docker.cpu import InputSocket + + +class TestInit(TestCase): + + def test_wrapped_socket(self): + wrapped_sock = Mock(spec=WrappedSocket) + input_sock = InputSocket(wrapped_sock) + self.assertEqual(input_sock._sock, wrapped_sock) + + def test_socket_io(self): + sock = Mock(spec=socket) + socket_io = Mock(spec=SocketIO, _sock=sock) + input_sock = InputSocket(socket_io) + self.assertEqual(input_sock._sock, sock) + + def test_invalid_socket_class(self): + with self.assertRaises(TypeError): + InputSocket(Mock()) + + +class TestInputSocket(TestCase): + + def _get_socket(self, socket_io=False): + lock = RLock() + + def _assert_lock(*_, **__): + if not lock._is_owned(): + self.fail("Socket operation performed without lock") + + sock = Mock(spec=(socket if socket_io else WrappedSocket)) + sock.sendall.side_effect = _assert_lock + sock.shutdown.side_effect = _assert_lock + sock.close.side_effect = _assert_lock + + wrapped_sock = Mock(spec=SocketIO, _sock=sock) if socket_io else sock + input_sock = InputSocket(wrapped_sock) + input_sock._lock = lock + + return sock, input_sock + + +class TestWrite(TestInputSocket): + + def test_ok(self): + sock, input_sock = self._get_socket() + input_sock.write(b"test") + sock.sendall.assert_called_once_with(b"test") + + def test_closed(self): + sock, input_sock = self._get_socket() + input_sock.close() + with self.assertRaises(RuntimeError): + input_sock.write(b"test") + sock.sendall.assert_not_called() + + +class TestClose(TestInputSocket): + + def test_socket_io(self): + sock, input_sock = self._get_socket(socket_io=True) + input_sock.close() + self.assertTrue(input_sock.closed()) + sock.shutdown.assert_called_once_with(SHUT_WR) + sock.close.assert_called_once_with() + + def test_wrapped_socket(self): + sock, input_sock = self._get_socket(socket_io=False) + input_sock.close() + self.assertTrue(input_sock.closed()) + sock.shutdown.assert_called_once_with() + sock.close.assert_called_once_with() + + def test_multiple_close(self): + sock, input_sock = self._get_socket() + input_sock.close() + input_sock.close() + self.assertTrue(input_sock.closed()) + sock.shutdown.assert_called_once() + sock.close.assert_called_once() diff --git a/tests/golem/envs/docker/cpu/test_integration.py b/tests/golem/envs/docker/cpu/test_integration.py new file mode 100644 index 0000000000..91467065cf --- /dev/null +++ b/tests/golem/envs/docker/cpu/test_integration.py @@ -0,0 +1,94 @@ +import tempfile +import time +from pathlib import Path + +import pytest +from twisted.internet.defer import inlineCallbacks +from twisted.trial.unittest import TestCase + +from golem.envs import EnvStatus, RuntimeStatus +from golem.envs.docker import DockerPrerequisites, DockerPayload +from golem.envs.docker.cpu import DockerCPUConfig, DockerCPUEnvironment +from golem.envs.docker.whitelist import Whitelist +from golem.testutils import DatabaseFixture +from golem.tools.ci import ci_skip + + +@ci_skip +class TestIntegration(TestCase, DatabaseFixture): + + @pytest.mark.timeout(60) # 60 sec should be well enough for this test + @inlineCallbacks + def test_io(self): + # Set up environment + config = DockerCPUConfig(work_dir=Path(tempfile.gettempdir())) + env = DockerCPUEnvironment(config) + yield env.prepare() + self.assertEqual(env.status(), EnvStatus.ENABLED) + + # Add environment cleanup to clean it if test goes wrong + def _clean_up_env(): + if env.status() != EnvStatus.DISABLED: + env.clean_up() + self.addCleanup(_clean_up_env) + + # Download image + Whitelist.add("busybox") + installed = yield env.install_prerequisites(DockerPrerequisites( + image="busybox", + tag="latest" + )) + self.assertTrue(installed) + + # Create runtime + runtime = env.runtime(DockerPayload( + image="busybox", + tag="latest", + env={}, + command="sh -c 'cat -'" + )) + + # Prepare container + yield runtime.prepare() + self.assertEqual(runtime.status(), RuntimeStatus.PREPARED) + + # Add runtime cleanup to clean it if test goes wrong + def _clean_up_runtime(): + if runtime.status() != RuntimeStatus.TORN_DOWN: + runtime.clean_up() + self.addCleanup(_clean_up_runtime) + + # Start container + yield runtime.start() + self.assertEqual(runtime.status(), RuntimeStatus.RUNNING) + + # Test stdin/stdout + test_input = ["żółw\n", "źrebię\n", "liść\n"] + stdout = runtime.stdout(encoding="utf-8") + with runtime.stdin(encoding="utf-8") as stdin: + for line in test_input: + stdin.write(line) + test_output = list(stdout) + self.assertEqual(test_input, test_output) + + # Wait for exit and delete container + yield runtime.wait_until_stopped() + self.assertEqual(runtime.status(), RuntimeStatus.STOPPED) + yield runtime.clean_up() + self.assertEqual(runtime.status(), RuntimeStatus.TORN_DOWN) + + # Clean up the environment + yield env.clean_up() + self.assertEqual(env.status(), EnvStatus.DISABLED) + + @inlineCallbacks + def test_benchmark(self): + config = DockerCPUConfig(work_dir=Path(tempfile.gettempdir())) + env = DockerCPUEnvironment(config) + yield env.prepare() + + Whitelist.add(env.BENCHMARK_IMAGE.split('/')[0]) + score = yield env.run_benchmark() + self.assertGreater(score, 0) + + yield env.clean_up() diff --git a/tests/golem/envs/docker/cpu/test_output.py b/tests/golem/envs/docker/cpu/test_output.py new file mode 100644 index 0000000000..b05d50ec9a --- /dev/null +++ b/tests/golem/envs/docker/cpu/test_output.py @@ -0,0 +1,45 @@ +from unittest import TestCase + +from golem.envs.docker.cpu import DockerOutput + + +class TestDockerOutput(TestCase): + + def _generic_test(self, raw_output, exp_output, encoding=None): + output = DockerOutput(raw_output, encoding=encoding) + self.assertEqual(list(output), exp_output) + + def test_empty(self): + self._generic_test(raw_output=[], exp_output=[]) + + def test_multiple_empty_chunks(self): + self._generic_test(raw_output=[b"", b"", b""], exp_output=[]) + + def test_single_line(self): + self._generic_test(raw_output=[b"test\n"], exp_output=[b"test\n"]) + + def test_multiple_lines(self): + self._generic_test( + raw_output=[b"test\n", b"test2\n"], + exp_output=[b"test\n", b"test2\n"]) + + def test_multiline_chunk(self): + self._generic_test( + raw_output=[b"test\ntest", b"2\n"], + exp_output=[b"test\n", b"test2\n"]) + + def test_chunks_without_newline(self): + self._generic_test( + raw_output=[b"t", b"e", b"s", b"t"], + exp_output=[b"test"]) + + def test_empty_lines(self): + self._generic_test( + raw_output=[b"\n\n\n"], + exp_output=[b"\n", b"\n", b"\n"]) + + def test_decoding(self): + self._generic_test( + raw_output=["ಠ_ಠ\nʕ•ᴥ•ʔ\n".encode("utf-8")], + exp_output=["ಠ_ಠ\n", "ʕ•ᴥ•ʔ\n"], + encoding="utf-8") diff --git a/tests/golem/envs/docker/cpu/test_runtime.py b/tests/golem/envs/docker/cpu/test_runtime.py new file mode 100644 index 0000000000..bf601ced14 --- /dev/null +++ b/tests/golem/envs/docker/cpu/test_runtime.py @@ -0,0 +1,700 @@ +from threading import Thread +from unittest.mock import Mock, patch as _patch, call, ANY + +import pytest +from docker.errors import APIError +from twisted.trial.unittest import TestCase + +from golem.envs import RuntimeStatus +from golem.envs.docker import DockerPayload +from golem.envs.docker.cpu import DockerCPURuntime, DockerOutput, DockerInput, \ + InputSocket + + +def patch(name: str, *args, **kwargs): + return _patch(f'golem.envs.docker.cpu.{name}', *args, **kwargs) + + +def patch_runtime(name: str, *args, **kwargs): + return patch(f'DockerCPURuntime.{name}', *args, **kwargs) + + +class TestInit(TestCase): + + @patch('local_client') + def test_init(self, local_client): + payload = DockerPayload( + image='repo/img', + tag='1.0', + command='cmd', + env={'key': 'value'}, + user='user', + work_dir='/test' + ) + host_config = {'memory': '1234m'} + volumes = ['/test'] + runtime = DockerCPURuntime(payload, host_config, volumes) + + local_client().create_container_config.assert_called_once_with( + image='repo/img:1.0', + command='cmd', + volumes=volumes, + environment={'key': 'value'}, + user='user', + working_dir='/test', + host_config=host_config, + stdin_open=True, + ) + self.assertEqual(runtime.status(), RuntimeStatus.CREATED) + self.assertIsNone(runtime._container_id) + self.assertIsNone(runtime._stdin_socket) + self.assertEqual( + runtime._container_config, + local_client().create_container_config()) + + +class TestDockerCPURuntime(TestCase): + + def setUp(self) -> None: + super().setUp() + + self.logger = self._patch_async('logger') + self.client = self._patch_async('local_client').return_value + self.container_config = self.client.create_container_config() + + payload = DockerPayload( + image='repo/img', + tag='1.0', + env={} + ) + + self.runtime = DockerCPURuntime(payload, {}, None) + self.container_config = self.client.create_container_config() + + # We want to make sure that status is being set and read using lock. + + def _getattribute(obj, item): + if item == "_status" and not self.runtime._status_lock._is_owned(): + self.fail("Status read without lock") + return object.__getattribute__(obj, item) + + def _setattr(obj, name, value): + if name == "_status" and not self.runtime._status_lock._is_owned(): + self.fail("Status write without lock") + return object.__setattr__(obj, name, value) + + self._patch_runtime_async('__getattribute__', _getattribute) + self._patch_runtime_async('__setattr__', _setattr) + + def _generic_test_invalid_status(self, method, valid_statuses): + def _test(status): + self.runtime._set_status(status) + with self.assertRaises(ValueError): + method() + + for status in set(RuntimeStatus) - valid_statuses: + _test(status) + + def _patch_async(self, name, *args, **kwargs): + patcher = patch(name, *args, **kwargs) + self.addCleanup(patcher.stop) + return patcher.start() + + def _patch_runtime_async(self, name, *args, **kwargs): + patcher = patch_runtime(name, *args, **kwargs) + self.addCleanup(patcher.stop) + return patcher.start() + + +class TestInspectContainer(TestDockerCPURuntime): + + def test_no_container_id(self): + with self.assertRaises(AssertionError): + self.runtime._inspect_container() + + def test_ok(self): + self.runtime._container_id = "container_id" + self.client.inspect_container.return_value = { + "State": { + "Status": "running", + "ExitCode": 0 + } + } + + status, exit_code = self.runtime._inspect_container() + + self.client.inspect_container.assert_called_once_with("container_id") + self.assertEqual(status, "running") + self.assertEqual(exit_code, 0) + + +class TestUpdateStatus(TestDockerCPURuntime): + + def test_status_not_running(self): + self.assertFalse(self.runtime._update_status()) + self.assertEqual(self.runtime.status(), RuntimeStatus.CREATED) + + def _update_status(self, inspect_error=None, inspect_result=None): + self.runtime._set_status(RuntimeStatus.RUNNING) + with patch_runtime('_inspect_container', + side_effect=inspect_error, + return_value=inspect_result): + self.runtime._update_status() + + @patch_runtime('_error_occurred') + @patch_runtime('_stopped') + def test_docker_api_error(self, stopped, error_occurred): + error = APIError("error") + self._update_status(inspect_error=error) + stopped.assert_not_called() + error_occurred.assert_called_once_with( + error, "Error inspecting container.") + + @patch_runtime('_error_occurred') + @patch_runtime('_stopped') + def test_container_running(self, stopped, error_occurred): + self._update_status(inspect_result=("running", 0)) + stopped.assert_not_called() + error_occurred.assert_not_called() + + @patch_runtime('_error_occurred') + @patch_runtime('_stopped') + def test_container_exited_ok(self, stopped, error_occurred): + self._update_status(inspect_result=("exited", 0)) + stopped.assert_called_once() + error_occurred.assert_not_called() + + @patch_runtime('_error_occurred') + @patch_runtime('_stopped') + def test_container_exited_error(self, stopped, error_occurred): + self._update_status(inspect_result=("exited", -1234)) + stopped.assert_not_called() + error_occurred.assert_called_once_with( + None, "Container stopped with exit code -1234.") + + @patch_runtime('_error_occurred') + @patch_runtime('_stopped') + def test_container_dead(self, stopped, error_occurred): + self._update_status(inspect_result=("dead", -1234)) + stopped.assert_not_called() + error_occurred.assert_called_once_with( + None, "Container stopped with exit code -1234.") + + @patch_runtime('_error_occurred') + @patch_runtime('_stopped') + def test_container_unexpected_status(self, stopped, error_occurred): + self._update_status(inspect_result=("(╯°□°)╯︵ ┻━┻", 0)) + stopped.assert_not_called() + error_occurred.assert_called_once_with( + None, "Unexpected container status: '(╯°□°)╯︵ ┻━┻'.") + + +class TestUpdateStatusLoop(TestDockerCPURuntime): + + # Timeout not to enter an infinite loop if there's a bug in the method + @pytest.mark.timeout(0.1) + @patch('sleep') + @patch_runtime('_update_status') + def test_not_running(self, update_status, sleep): + self.runtime._update_status_loop() + update_status.assert_not_called() + sleep.assert_not_called() + + # Timeout not to enter an infinite loop if there's a bug in the method + @pytest.mark.timeout(0.1) + @patch('sleep') + @patch_runtime('_update_status') + def test_updated(self, update_status, sleep): + self.runtime._set_status(RuntimeStatus.RUNNING) + update_status.side_effect = \ + lambda: self.runtime._set_status(RuntimeStatus.STOPPED) + + self.runtime._update_status_loop() + update_status.assert_called_once() + sleep.assert_called_once_with(DockerCPURuntime.STATUS_UPDATE_INTERVAL) + + +class TestPrepare(TestDockerCPURuntime): + + def test_invalid_status(self): + self._generic_test_invalid_status( + method=self.runtime.prepare, + valid_statuses={RuntimeStatus.CREATED}) + + def test_create_error(self): + error = APIError("test") + self.client.create_container_from_config.side_effect = error + prepared = self._patch_runtime_async('_prepared') + error_occurred = self._patch_runtime_async('_error_occurred') + + deferred = self.runtime.prepare() + self.assertEqual(self.runtime.status(), RuntimeStatus.PREPARING) + deferred = self.assertFailure(deferred, APIError) + + def _check(_): + self.assertIsNone(self.runtime._stdin_socket) + self.client.create_container_from_config.assert_called_once_with( + self.container_config) + self.client.attach_socket.assert_not_called() + prepared.assert_not_called() + error_occurred.assert_called_once_with( + error, "Creating container failed.") + + deferred.addCallback(_check) + return deferred + + def test_invalid_container_id(self): + self.client.create_container_from_config.return_value = {"Id": None} + prepared = self._patch_runtime_async('_prepared') + error_occurred = self._patch_runtime_async('_error_occurred') + + deferred = self.runtime.prepare() + self.assertEqual(self.runtime.status(), RuntimeStatus.PREPARING) + deferred = self.assertFailure(deferred, AssertionError) + + def _check(_): + self.assertIsNone(self.runtime._stdin_socket) + self.client.create_container_from_config.assert_called_once_with( + self.container_config) + self.client.attach_socket.assert_not_called() + prepared.assert_not_called() + error_occurred.assert_called_once_with( + ANY, "Creating container failed.") + + deferred.addCallback(_check) + return deferred + + def test_attach_socket_error(self): + self.client.create_container_from_config.return_value = { + "Id": "Id", + "Warnings": None + } + error = APIError("test") + self.client.attach_socket.side_effect = error + prepared = self._patch_runtime_async('_prepared') + error_occurred = self._patch_runtime_async('_error_occurred') + + deferred = self.runtime.prepare() + self.assertEqual(self.runtime.status(), RuntimeStatus.PREPARING) + deferred = self.assertFailure(deferred, APIError) + + def _check(_): + self.assertIsNone(self.runtime._stdin_socket) + self.assertEqual(self.runtime._container_id, "Id") + self.client.create_container_from_config.assert_called_once_with( + self.container_config) + self.client.attach_socket.assert_called_once_with( + "Id", params={"stdin": True, "stream": True}) + prepared.assert_not_called() + error_occurred.assert_called_once_with( + error, "Creating container failed.") + + deferred.addCallback(_check) + return deferred + + def test_warnings(self): + self.client.create_container_from_config.return_value = { + "Id": "Id", + "Warnings": ["foo", "bar"] + } + input_socket = self._patch_async('InputSocket') + prepared = self._patch_runtime_async('_prepared') + error_occurred = self._patch_runtime_async('_error_occurred') + + deferred = self.runtime.prepare() + self.assertEqual(self.runtime.status(), RuntimeStatus.PREPARING) + + def _check(_): + self.assertEqual( + self.runtime._stdin_socket, + input_socket.return_value) + self.assertEqual(self.runtime._container_id, "Id") + input_socket.assert_called_once_with( + self.client.attach_socket.return_value) + self.client.create_container_from_config.assert_called_once_with( + self.container_config) + self.client.attach_socket.assert_called_once_with( + "Id", params={"stdin": True, "stream": True}) + + warning_calls = self.logger.warning.call_args_list + self.assertEqual(len(warning_calls), 2) + self.assertIn("foo", warning_calls[0][0]) + self.assertIn("bar", warning_calls[1][0]) + + prepared.assert_called_once() + error_occurred.assert_not_called() + + deferred.addCallback(_check) + return deferred + + def test_ok(self): + self.client.create_container_from_config.return_value = { + "Id": "Id", + "Warnings": None + } + input_socket = self._patch_async('InputSocket') + prepared = self._patch_runtime_async('_prepared') + error_occurred = self._patch_runtime_async('_error_occurred') + + deferred = self.runtime.prepare() + self.assertEqual(self.runtime.status(), RuntimeStatus.PREPARING) + + def _check(_): + self.assertEqual( + self.runtime._stdin_socket, + input_socket.return_value) + self.assertEqual(self.runtime._container_id, "Id") + input_socket.assert_called_once_with( + self.client.attach_socket.return_value) + self.client.create_container_from_config.assert_called_once_with( + self.container_config) + self.client.attach_socket.assert_called_once_with( + "Id", params={"stdin": True, "stream": True}) + self.logger.warning.assert_not_called() + prepared.assert_called_once() + error_occurred.assert_not_called() + + deferred.addCallback(_check) + return deferred + + +class TestCleanup(TestDockerCPURuntime): + + def test_invalid_status(self): + self._generic_test_invalid_status( + method=self.runtime.clean_up, + valid_statuses={RuntimeStatus.STOPPED, RuntimeStatus.FAILURE}) + + def test_client_error(self): + self.runtime._set_status(RuntimeStatus.STOPPED) + self.runtime._container_id = "Id" + self.runtime._stdin_socket = Mock(spec=InputSocket) + error = APIError("test") + self.client.remove_container.side_effect = error + torn_down = self._patch_runtime_async('_torn_down') + error_occurred = self._patch_runtime_async('_error_occurred') + + deferred = self.runtime.clean_up() + self.assertEqual(self.runtime.status(), RuntimeStatus.CLEANING_UP) + deferred = self.assertFailure(deferred, APIError) + + def _check(_): + self.client.remove_container.assert_called_once_with("Id") + self.runtime._stdin_socket.close.assert_called_once() + torn_down.assert_not_called() + error_occurred.assert_called_once_with( + error, "Failed to remove container 'Id'.") + + deferred.addCallback(_check) + return deferred + + def test_ok(self): + self.runtime._set_status(RuntimeStatus.STOPPED) + self.runtime._container_id = "Id" + self.runtime._stdin_socket = Mock(spec=InputSocket) + torn_down = self._patch_runtime_async('_torn_down') + error_occurred = self._patch_runtime_async('_error_occurred') + + deferred = self.runtime.clean_up() + self.assertEqual(self.runtime.status(), RuntimeStatus.CLEANING_UP) + + def _check(_): + self.client.remove_container.assert_called_once_with("Id") + self.runtime._stdin_socket.close.assert_called_once() + torn_down.assert_called_once() + error_occurred.assert_not_called() + + deferred.addCallback(_check) + return deferred + + +class TestStart(TestDockerCPURuntime): + + def setUp(self): + super().setUp() + self.update_status_loop = \ + self._patch_runtime_async('_update_status_loop') + + def test_invalid_status(self): + self._generic_test_invalid_status( + method=self.runtime.start, + valid_statuses={RuntimeStatus.PREPARED}) + + def test_client_error(self): + self.runtime._set_status(RuntimeStatus.PREPARED) + self.runtime._container_id = "Id" + error = APIError("test") + self.client.start.side_effect = error + started = self._patch_runtime_async('_started') + error_occurred = self._patch_runtime_async('_error_occurred') + + deferred = self.runtime.start() + self.assertEqual(self.runtime.status(), RuntimeStatus.STARTING) + deferred = self.assertFailure(deferred, APIError) + + def _check(_): + self.assertIsNone(self.runtime._status_update_thread) + self.client.start.assert_called_once_with("Id") + self.update_status_loop.assert_not_called() + started.assert_not_called() + error_occurred.assert_called_once_with( + error, "Starting container 'Id' failed.") + + deferred.addCallback(_check) + return deferred + + def test_ok(self): + self.runtime._set_status(RuntimeStatus.PREPARED) + self.runtime._container_id = "Id" + started = self._patch_runtime_async('_started') + error_occurred = self._patch_runtime_async('_error_occurred') + + deferred = self.runtime.start() + self.assertEqual(self.runtime.status(), RuntimeStatus.STARTING) + + def _check(_): + self.client.start.assert_called_once_with("Id") + started.assert_called_once() + error_occurred.assert_not_called() + + self.assertIsInstance(self.runtime._status_update_thread, Thread) + self.runtime._status_update_thread.join(0.1) + self.update_status_loop.assert_called_once() + + deferred.addCallback(_check) + + return deferred + + +class TestStop(TestDockerCPURuntime): + + def test_invalid_status(self): + self._generic_test_invalid_status( + method=self.runtime.stop, + valid_statuses={RuntimeStatus.RUNNING}) + + def test_client_error(self): + self.runtime._set_status(RuntimeStatus.RUNNING) + self.runtime._container_id = "Id" + self.runtime._stdin_socket = Mock(spec=InputSocket) + self.runtime._status_update_thread = Mock(spec=Thread) + self.runtime._status_update_thread.is_alive.return_value = False + error = APIError("test") + self.client.stop.side_effect = error + stopped = self._patch_runtime_async('_stopped') + error_occurred = self._patch_runtime_async('_error_occurred') + + deferred = self.assertFailure(self.runtime.stop(), APIError) + + def _check(_): + self.client.stop.assert_called_once_with("Id") + self.runtime._status_update_thread.join.assert_called_once() + self.runtime._stdin_socket.close.assert_called_once() + stopped.assert_not_called() + error_occurred.assert_called_once_with( + error, "Stopping container 'Id' failed.") + + deferred.addCallback(_check) + return deferred + + def test_failed_to_join_status_update_thread(self): + self.runtime._set_status(RuntimeStatus.RUNNING) + self.runtime._container_id = "Id" + self.runtime._stdin_socket = Mock(spec=InputSocket) + self.runtime._status_update_thread = Mock(spec=Thread) + self.runtime._status_update_thread.is_alive.return_value = True + stopped = self._patch_runtime_async('_stopped') + error_occurred = self._patch_runtime_async('_error_occurred') + + deferred = self.runtime.stop() + + def _check(_): + self.client.stop.assert_called_once_with("Id") + self.runtime._status_update_thread.join.assert_called_once() + self.runtime._stdin_socket.close.assert_called_once() + self.logger.warning.assert_called_once() + stopped.assert_called_once() + error_occurred.assert_not_called() + + deferred.addCallback(_check) + return deferred + + def test_ok(self): + self.runtime._set_status(RuntimeStatus.RUNNING) + self.runtime._container_id = "Id" + self.runtime._stdin_socket = Mock(spec=InputSocket) + self.runtime._status_update_thread = Mock(spec=Thread) + self.runtime._status_update_thread.is_alive.return_value = False + stopped = self._patch_runtime_async('_stopped') + error_occurred = self._patch_runtime_async('_error_occurred') + + deferred = self.runtime.stop() + + def _check(_): + self.client.stop.assert_called_once_with("Id") + self.runtime._status_update_thread.join.assert_called_once() + self.runtime._stdin_socket.close.assert_called_once() + self.logger.warning.assert_not_called() + stopped.assert_called_once() + error_occurred.assert_not_called() + + deferred.addCallback(_check) + return deferred + + +class TestStdin(TestDockerCPURuntime): + + def test_invalid_status(self): + self._generic_test_invalid_status( + method=self.runtime.stdin, + valid_statuses={ + RuntimeStatus.PREPARED, + RuntimeStatus.STARTING, + RuntimeStatus.RUNNING} + ) + + def test_ok(self): + self.runtime._set_status(RuntimeStatus.RUNNING) + sock = Mock(spec=InputSocket) + self.runtime._stdin_socket = sock + + result = self.runtime.stdin(encoding="utf-8") + + self.assertIsInstance(result, DockerInput) + self.assertEqual(result._sock, sock) + self.assertEqual(result._encoding, "utf-8") + + +class TestStdout(TestDockerCPURuntime): + + @patch_runtime('_get_output', side_effect=ValueError) + def test_error(self, _): + with self.assertRaises(ValueError): + self.runtime.stdout() + + @patch_runtime('_get_output') + def test_ok(self, get_output): + result = self.runtime.stdout(encoding="utf-8") + get_output.assert_called_once_with(stdout=True, encoding="utf-8") + self.assertEqual(result, get_output.return_value) + + +class TestStderr(TestDockerCPURuntime): + + @patch_runtime('_get_output', side_effect=ValueError) + def test_error(self, _): + with self.assertRaises(ValueError): + self.runtime.stderr() + + @patch_runtime('_get_output') + def test_ok(self, get_output): + result = self.runtime.stderr(encoding="utf-8") + get_output.assert_called_once_with(stderr=True, encoding="utf-8") + self.assertEqual(result, get_output.return_value) + + +class TestGetOutput(TestDockerCPURuntime): + + def test_invalid_status(self): + self._generic_test_invalid_status( + method=self.runtime._get_output, + valid_statuses={ + RuntimeStatus.PREPARED, + RuntimeStatus.STARTING, + RuntimeStatus.RUNNING, + RuntimeStatus.STOPPED, + RuntimeStatus.FAILURE + } + ) + + @patch_runtime('_update_status') + @patch_runtime('_get_raw_output') + def test_running(self, get_raw_output, update_status): + self.runtime._set_status(RuntimeStatus.RUNNING) + + result = self.runtime._get_output(stdout=True, encoding="utf-8") + + get_raw_output.assert_called_once_with(stdout=True, stream=True) + update_status.assert_called_once() + self.assertIsInstance(result, DockerOutput) + self.assertEqual(result._raw_output, get_raw_output.return_value) + self.assertEqual(result._encoding, "utf-8") + + @patch_runtime('_update_status') + @patch_runtime('_get_raw_output') + def test_stopped(self, get_raw_output, update_status): + self.runtime._set_status(RuntimeStatus.STOPPED) + + result = self.runtime._get_output(stdout=True, encoding="utf-8") + + get_raw_output.assert_called_once_with(stdout=True, stream=False) + update_status.assert_called_once() + self.assertIsInstance(result, DockerOutput) + self.assertEqual(result._raw_output, get_raw_output.return_value) + self.assertEqual(result._encoding, "utf-8") + + @patch_runtime('_update_status') + @patch_runtime('_get_raw_output') + def test_stopped_in_the_meantime(self, get_raw_output, update_status): + self.runtime._set_status(RuntimeStatus.RUNNING) + update_status.side_effect = \ + lambda: self.runtime._set_status(RuntimeStatus.STOPPED) + raw_output = Mock() + get_raw_output.side_effect = [None, raw_output] + + result = self.runtime._get_output(stdout=True, encoding="utf-8") + + get_raw_output.assert_has_calls([ + call(stdout=True, stream=True), + call(stdout=True, stream=False)]) + update_status.assert_called_once() + self.assertIsInstance(result, DockerOutput) + self.assertEqual(result._raw_output, raw_output) + self.assertEqual(result._encoding, "utf-8") + + +class TestGetRawOutput(TestDockerCPURuntime): + + def setUp(self) -> None: + super().setUp() + self.runtime._container_id = "container_id" + + def test_no_arguments(self): + with self.assertRaises(AssertionError): + self.runtime._get_raw_output() + + def test_container_id_missing(self): + self.runtime._container_id = None + with self.assertRaises(AssertionError): + self.runtime._get_raw_output(stdout=True) + + @patch_runtime('_error_occurred') + def test_client_error(self, error_occurred): + error = APIError("test") + self.client.attach.side_effect = error + result = self.runtime._get_raw_output(stdout=True) + self.assertEqual(result, []) + error_occurred.assert_called_once_with( + error, "Error attaching to container's output.", set_status=False) + + def test_stdout_stream(self): + result = self.runtime._get_raw_output(stdout=True, stream=True) + self.assertEqual(result, self.client.attach.return_value) + self.client.attach.assert_called_once_with( + container=self.runtime._container_id, + stdout=True, + stderr=False, + logs=True, + stream=True + ) + + def test_stderr_non_stream(self): + result = self.runtime._get_raw_output(stderr=True, stream=False) + self.assertEqual(result, [self.client.attach.return_value]) + self.client.attach.assert_called_once_with( + container=self.runtime._container_id, + stdout=False, + stderr=True, + logs=True, + stream=False + ) diff --git a/tests/golem/envs/docker/test_payload.py b/tests/golem/envs/docker/test_payload.py new file mode 100644 index 0000000000..5046119f70 --- /dev/null +++ b/tests/golem/envs/docker/test_payload.py @@ -0,0 +1,67 @@ +from unittest import TestCase +from unittest.mock import patch + +from golem.envs.docker import DockerPayload + + +class TestFromDict(TestCase): + + def test_missing_values(self): + with self.assertRaises(TypeError): + DockerPayload.from_dict({}) + + def test_extra_values(self): + with self.assertRaises(TypeError): + DockerPayload.from_dict({ + 'image': 'repo/img', + 'tag': '1.0', + 'extra': 'value' + }) + + def test_default_values(self): + payload = DockerPayload.from_dict({ + 'image': 'repo/img', + 'tag': '1.0', + }) + self.assertEqual(payload.env, {}) + + def test_custom_values(self): + payload = DockerPayload.from_dict({ + 'image': 'repo/img', + 'tag': '1.0', + 'env': {'var': 'value'}, + 'command': 'cmd', + 'user': 'user', + 'work_dir': '/tmp/', + }) + + self.assertEqual(payload, DockerPayload( + image='repo/img', + tag='1.0', + env={'var': 'value'}, + command='cmd', + user='user', + work_dir='/tmp/', + )) + + +class TestToDict(TestCase): + + def test_to_dict(self): + payload_dict = DockerPayload( + image='repo/img', + tag='1.0', + env={'var': 'value'}, + command='cmd', + user='user', + work_dir='/tmp/', + ).to_dict() + + self.assertEqual(payload_dict, { + 'image': 'repo/img', + 'tag': '1.0', + 'env': {'var': 'value'}, + 'command': 'cmd', + 'user': 'user', + 'work_dir': '/tmp/', + }) diff --git a/tests/golem/envs/docker/test_prerequisites.py b/tests/golem/envs/docker/test_prerequisites.py new file mode 100644 index 0000000000..a140e13087 --- /dev/null +++ b/tests/golem/envs/docker/test_prerequisites.py @@ -0,0 +1,41 @@ +from unittest import TestCase + +from golem.envs.docker import DockerPrerequisites + + +class TestFromDict(TestCase): + + def test_missing_values(self): + with self.assertRaises(TypeError): + DockerPrerequisites.from_dict({}) + + def test_extra_values(self): + with self.assertRaises(TypeError): + DockerPrerequisites.from_dict({ + 'image': 'repo/img', + 'tag': '1.0', + 'extra': 'value' + }) + + def test_ok(self): + prereqs = DockerPrerequisites.from_dict({ + 'image': 'repo/img', + 'tag': '1.0' + }) + self.assertEqual(prereqs, DockerPrerequisites( + image='repo/img', + tag='1.0' + )) + + +class TestToDict(TestCase): + + def test_to_dict(self): + prereqs_dict = DockerPrerequisites( + image='repo/img', + tag='1.0' + ).to_dict() + self.assertEqual(prereqs_dict, { + 'image': 'repo/img', + 'tag': '1.0' + }) diff --git a/tests/golem/envs/docker/test_whitelist.py b/tests/golem/envs/docker/test_whitelist.py new file mode 100644 index 0000000000..da80927805 --- /dev/null +++ b/tests/golem/envs/docker/test_whitelist.py @@ -0,0 +1,24 @@ +from golem.envs.docker.whitelist import Whitelist + +from golem.testutils import DatabaseFixture + + +class TestWhitelist(DatabaseFixture): + def test_simple_flow(self): + repo = 'test_repo' + image = f'{repo}/image' + assert not Whitelist.is_whitelisted(image) + + Whitelist.add(repo) + assert Whitelist.is_whitelisted(image) + + Whitelist.remove(repo) + assert not Whitelist.is_whitelisted(image) + + def test_double_add_remove(self): + repo = 'test_repo' + assert Whitelist.add(repo) + assert not Whitelist.add(repo) + + assert Whitelist.remove(repo) + assert not Whitelist.remove(repo) diff --git a/tests/golem/envs/test_env.py b/tests/golem/envs/test_env.py new file mode 100644 index 0000000000..4ad85a6187 --- /dev/null +++ b/tests/golem/envs/test_env.py @@ -0,0 +1,120 @@ +from logging import Logger +from unittest import TestCase +from unittest.mock import Mock, patch + +from golem.envs import Environment, EnvEvent, EnvEventType, EnvConfig, \ + Prerequisites, EnvStatus + + +class TestEnvironment(TestCase): + + @patch.object(Environment, "__abstractmethods__", set()) + def setUp(self) -> None: + self.logger = Mock(spec=Logger) + # pylint: disable=abstract-class-instantiated + self.env = Environment(logger=self.logger) # type: ignore + + +class TestEmitEvents(TestEnvironment): + + @patch('golem.envs.deferToThread') + def test_emit_event(self, defer): + defer.side_effect = lambda f, *args, **kwargs: f(*args, **kwargs) + metadata = Mock(id="env_id") + with patch.object(self.env, 'metadata', return_value=metadata): + enabled_listener1 = Mock() + enabled_listener2 = Mock() + disabled_listener = Mock() + + self.env.listen(EnvEventType.ENABLED, enabled_listener1) + self.env.listen(EnvEventType.ENABLED, enabled_listener2) + self.env.listen(EnvEventType.DISABLED, disabled_listener) + + event = EnvEvent( + env_id="env_id", + type=EnvEventType.ENABLED, + details={"key": "value"} + ) + + self.env._emit_event(event.type, event.details) + + enabled_listener1.assert_called_once_with(event) + enabled_listener2.assert_called_once_with(event) + disabled_listener.assert_not_called() + + @patch('golem.envs.Environment._emit_event') + def test_env_enabled(self, emit): + self.env._env_enabled() + self.assertEqual(self.env.status(), EnvStatus.ENABLED) + self.logger.info.assert_called_once_with('Environment enabled.') + emit.assert_called_once_with(EnvEventType.ENABLED) + + @patch('golem.envs.Environment._emit_event') + def test_env_disabled(self, emit): + self.env._status = EnvStatus.ENABLED + self.env._env_disabled() + self.assertEqual(self.env.status(), EnvStatus.DISABLED) + self.logger.info.assert_called_once_with('Environment disabled.') + emit.assert_called_once_with(EnvEventType.DISABLED) + + @patch('golem.envs.Environment._emit_event') + def test_config_updated(self, emit): + config = Mock(spec=EnvConfig) + self.env._config_updated(config) + self.logger.info.assert_called_once_with('Configuration updated.') + emit.assert_called_once_with( + EnvEventType.CONFIG_UPDATED, {'config': config}) + + @patch('golem.envs.Environment._emit_event') + def test_prerequisites_installed(self, emit): + prereqs = Mock(spec=Prerequisites) + self.env._prerequisites_installed(prereqs) + self.logger.info.assert_called_once_with('Prerequisites installed.') + emit.assert_called_once_with( + EnvEventType.PREREQUISITES_INSTALLED, + {'prerequisites': prereqs}) + + @patch('golem.envs.Environment._emit_event') + def test_error_occurred(self, emit): + error = RuntimeError("test") + message = "error message" + self.env._error_occurred(error, message) + self.assertEqual(self.env.status(), EnvStatus.ERROR) + self.logger.error.assert_called_once_with(message, exc_info=error) + emit.assert_called_once_with( + EnvEventType.ERROR_OCCURRED, { + 'error': error, + 'message': message + }) + + +class TestListen(TestEnvironment): + + def test_single_listener(self): + listener = Mock() + self.env.listen(EnvEventType.ENABLED, listener) + self.assertEqual(self.env._event_listeners, { + EnvEventType.ENABLED: {listener} + }) + + def test_multiple_listeners(self): + enabled_listener1 = Mock() + enabled_listener2 = Mock() + disabled_listener = Mock() + + self.env.listen(EnvEventType.ENABLED, enabled_listener1) + self.env.listen(EnvEventType.ENABLED, enabled_listener2) + self.env.listen(EnvEventType.DISABLED, disabled_listener) + + self.assertEqual(self.env._event_listeners, { + EnvEventType.ENABLED: {enabled_listener1, enabled_listener2}, + EnvEventType.DISABLED: {disabled_listener} + }) + + def test_re_register(self): + listener = Mock() + self.env.listen(EnvEventType.ERROR_OCCURRED, listener) + self.env.listen(EnvEventType.ERROR_OCCURRED, listener) + self.assertEqual(self.env._event_listeners, { + EnvEventType.ERROR_OCCURRED: {listener} + }) diff --git a/tests/golem/envs/test_manager.py b/tests/golem/envs/test_manager.py new file mode 100644 index 0000000000..564a609dad --- /dev/null +++ b/tests/golem/envs/test_manager.py @@ -0,0 +1,81 @@ +from unittest import TestCase +from unittest.mock import MagicMock + +from golem.envs import Environment +from golem.envs.manager import EnvironmentManager + + +class TestEnvironmentManager(TestCase): + + def setUp(self): + self.manager = EnvironmentManager() + + @staticmethod + def new_env(env_id): + env = MagicMock(spec=Environment) + env.metadata().id = env_id + return env + + def test_register_env(self): + # Given + self.assertEqual(self.manager.environments(), []) + self.assertEqual(self.manager.state(), {}) + + # When + env = self.new_env("env1") + self.manager.register_env(env) + + # Then + self.assertEqual(self.manager.environments(), [env]) + self.assertEqual(self.manager.state(), {"env1": False}) + + def test_re_register_env(self): + # Given + env = self.new_env("env1") + self.manager.register_env(env) + self.manager.set_enabled("env1", True) + self.assertEqual(self.manager.environments(), [env]) + self.assertTrue(self.manager.enabled("env1")) + + # When + self.manager.register_env(env) + + # Then + self.assertEqual(self.manager.environments(), [env]) + self.assertTrue(self.manager.enabled("env1")) + + def test_set_enabled(self): + # Given + env = self.new_env("env1") + self.manager.register_env(env) + self.assertFalse(self.manager.enabled("env1")) + + # When + self.manager.set_enabled("env1", True) + # Then / Given + self.assertTrue(self.manager.enabled("env1")) + + # When + self.manager.set_enabled("env1", False) + # Then + self.assertFalse(self.manager.enabled("env1")) + + def test_set_state(self): + # Given + env1 = self.new_env("env1") + env2 = self.new_env("env2") + self.manager.register_env(env1) + self.manager.register_env(env2) + self.assertFalse(self.manager.enabled("env1")) + self.assertFalse(self.manager.enabled("env2")) + + # When + self.manager.set_state({ + "env2": True, + "bogus_env": True + }) + + # Then + self.assertFalse(self.manager.enabled("env1")) + self.assertTrue(self.manager.enabled("env2")) + self.assertRaises(KeyError, self.manager.enabled, "bogus_env") diff --git a/tests/golem/envs/test_runtime.py b/tests/golem/envs/test_runtime.py new file mode 100644 index 0000000000..fa657f72c2 --- /dev/null +++ b/tests/golem/envs/test_runtime.py @@ -0,0 +1,102 @@ +from logging import Logger +from unittest import TestCase +from unittest.mock import Mock, patch + +from golem.envs import RuntimeStatus, Runtime, RuntimeEventType, RuntimeEvent + + +class TestRuntime(TestCase): + + @patch.object(Runtime, "__abstractmethods__", set()) + def setUp(self) -> None: + self.logger = Mock(spec=Logger) + # pylint: disable=abstract-class-instantiated + self.runtime = Runtime(logger=self.logger) # type: ignore + + +class TestChangeStatus(TestRuntime): + + def test_wrong_single_status(self): + with self.assertRaises(ValueError): + self.runtime._change_status( + from_status=RuntimeStatus.STOPPED, + to_status=RuntimeStatus.RUNNING) + + def test_wrong_multiple_statuses(self): + with self.assertRaises(ValueError): + self.runtime._change_status( + from_status=[RuntimeStatus.STOPPED, RuntimeStatus.FAILURE], + to_status=RuntimeStatus.RUNNING) + + def test_ok(self): + self.runtime._change_status( + from_status=RuntimeStatus.CREATED, + to_status=RuntimeStatus.RUNNING) + self.assertEqual(self.runtime.status(), RuntimeStatus.RUNNING) + + +class TestEmitEvents(TestRuntime): + + @patch('golem.envs.deferToThread') + def test_emit_event(self, defer): + defer.side_effect = lambda f, *args, **kwargs: f(*args, **kwargs) + + started_listener1 = Mock() + started_listener2 = Mock() + stopped_listener = Mock() + + self.runtime.listen(RuntimeEventType.STARTED, started_listener1) + self.runtime.listen(RuntimeEventType.STARTED, started_listener2) + self.runtime.listen(RuntimeEventType.STOPPED, stopped_listener) + + event = RuntimeEvent( + type=RuntimeEventType.STARTED, + details={"key": "value"} + ) + + self.runtime._emit_event(event.type, event.details) + + started_listener1.assert_called_once_with(event) + started_listener2.assert_called_once_with(event) + stopped_listener.assert_not_called() + + @patch('golem.envs.Runtime._emit_event') + def test_prepared(self, emit): + self.runtime._prepared() + self.assertEqual(self.runtime.status(), RuntimeStatus.PREPARED) + self.logger.info.assert_called_once_with('Runtime prepared.') + emit.assert_called_once_with(RuntimeEventType.PREPARED) + + @patch('golem.envs.Runtime._emit_event') + def test_started(self, emit): + self.runtime._started() + self.assertEqual(self.runtime.status(), RuntimeStatus.RUNNING) + self.logger.info.assert_called_once_with('Runtime started.') + emit.assert_called_once_with(RuntimeEventType.STARTED) + + @patch('golem.envs.Runtime._emit_event') + def test_stopped(self, emit): + self.runtime._stopped() + self.assertEqual(self.runtime.status(), RuntimeStatus.STOPPED) + self.logger.info.assert_called_once_with('Runtime stopped.') + emit.assert_called_once_with(RuntimeEventType.STOPPED) + + @patch('golem.envs.Runtime._emit_event') + def test_torn_down(self, emit): + self.runtime._torn_down() + self.assertEqual(self.runtime.status(), RuntimeStatus.TORN_DOWN) + self.logger.info.assert_called_once_with('Runtime torn down.') + emit.assert_called_once_with(RuntimeEventType.TORN_DOWN) + + @patch('golem.envs.Runtime._emit_event') + def test_error_occurred(self, emit): + error = RuntimeError("test") + message = "error message" + self.runtime._error_occurred(error, message) + self.assertEqual(self.runtime.status(), RuntimeStatus.FAILURE) + self.logger.error.assert_called_once_with(message, exc_info=error) + emit.assert_called_once_with( + RuntimeEventType.ERROR_OCCURRED, { + 'error': error, + 'message': message + }) diff --git a/tests/golem/ethereum/test_incomeskeeper.py b/tests/golem/ethereum/test_incomeskeeper.py index cbcd39acab..3d1077596b 100644 --- a/tests/golem/ethereum/test_incomeskeeper.py +++ b/tests/golem/ethereum/test_incomeskeeper.py @@ -1,13 +1,17 @@ -from datetime import datetime, timedelta from random import Random import time import unittest.mock as mock +import uuid from freezegun import freeze_time +from golem_messages.factories.helpers import ( + random_eth_address, + random_eth_pub_key, +) +from golem import model from golem.core.variables import PAYMENT_DEADLINE from golem.ethereum.incomeskeeper import IncomesKeeper -from golem.model import db, Income from golem.tools.testwithdatabase import TestWithDatabase from tests.factories import model as model_factories @@ -34,6 +38,13 @@ def setUp(self): random.seed(__name__) self.incomes_keeper = IncomesKeeper() + def assertIncomeHash(self, sender_node, subtask_id, transaction_id): + income = self._get_income( + model.TaskPayment.node == sender_node, + model.TaskPayment.subtask == subtask_id, + ) + self.assertEqual(income.wallet_operation.tx_hash, transaction_id) + # pylint:disable=too-many-arguments def _test_expect_income( self, @@ -44,18 +55,23 @@ def _test_expect_income( accepted_ts): self.incomes_keeper.expect( sender_node=sender_node, + my_address=random_eth_address(), + task_id=str(uuid.uuid4()), subtask_id=subtask_id, payer_address=payer_addr, value=value, accepted_ts=accepted_ts, ) - with db.atomic(): - expected_income = Income.get( - sender_node=sender_node, - subtask=subtask_id, - ) - assert expected_income.value == value - assert expected_income.transaction is None + with model.db.atomic(): + expected_income = model.TaskPayment \ + .incomes() \ + .where( + model.TaskPayment.node == sender_node, + model.TaskPayment.subtask == subtask_id, + ) \ + .get() + self.assertEqual(expected_income.expected_amount, value) + self.assertIsNone(expected_income.wallet_operation.tx_hash) @mock.patch("golem.ethereum.incomeskeeper.IncomesKeeper" ".received_batch_transfer") @@ -81,7 +97,7 @@ def test_received_batch_transfer_closure_time(self): value2 = MAX_INT + 100 accepted_ts2 = 2137 - assert Income.select().count() == 0 + self.assertEqual(model.TaskPayment.select().count(), 0) self._test_expect_income( sender_node=sender_node, subtask_id=subtask_id1, @@ -96,7 +112,7 @@ def test_received_batch_transfer_closure_time(self): value=value2, accepted_ts=accepted_ts2, ) - assert Income.select().count() == 2 + self.assertEqual(model.TaskPayment.select().count(), 2) transaction_id = '0x' + 64 * '1' transaction_id1 = '0x' + 64 * 'b' @@ -109,10 +125,8 @@ def test_received_batch_transfer_closure_time(self): value1, accepted_ts1 - 1, ) - income1 = Income.get(sender_node=sender_node, subtask=subtask_id1) - assert income1.transaction is None - income2 = Income.get(sender_node=sender_node, subtask=subtask_id2) - assert income2.transaction is None + self.assertIncomeHash(sender_node, subtask_id1, None) + self.assertIncomeHash(sender_node, subtask_id2, None) self.incomes_keeper.received_batch_transfer( transaction_id1, @@ -120,10 +134,8 @@ def test_received_batch_transfer_closure_time(self): value1, accepted_ts1, ) - income1 = Income.get(sender_node=sender_node, subtask=subtask_id1) - assert transaction_id1[2:] == income1.transaction - income2 = Income.get(sender_node=sender_node, subtask=subtask_id2) - assert income2.transaction is None + self.assertIncomeHash(sender_node, subtask_id1, transaction_id1) + self.assertIncomeHash(sender_node, subtask_id2, None) self.incomes_keeper.received_batch_transfer( transaction_id2, @@ -131,10 +143,8 @@ def test_received_batch_transfer_closure_time(self): value2, accepted_ts2, ) - income1 = Income.get(sender_node=sender_node, subtask=subtask_id1) - assert transaction_id1[2:] == income1.transaction - income2 = Income.get(sender_node=sender_node, subtask=subtask_id2) - assert transaction_id2[2:] == income2.transaction + self.assertIncomeHash(sender_node, subtask_id1, transaction_id1) + self.assertIncomeHash(sender_node, subtask_id2, transaction_id2) def test_received_batch_transfer_two_senders(self): sender_node1 = 64 * 'a' @@ -148,7 +158,7 @@ def test_received_batch_transfer_two_senders(self): closure_time1 = 1337 closure_time2 = 2137 - assert Income.select().count() == 0 + self.assertEqual(model.TaskPayment.incomes().count(), 0) self._test_expect_income( sender_node=sender_node1, subtask_id=subtask_id1, @@ -163,7 +173,7 @@ def test_received_batch_transfer_two_senders(self): value=value2, accepted_ts=closure_time2, ) - assert Income.select().count() == 2 + self.assertEqual(model.TaskPayment.incomes().count(), 2) transaction_id1 = '0x' + 64 * 'b' transaction_id2 = '0x' + 64 * 'd' @@ -174,10 +184,8 @@ def test_received_batch_transfer_two_senders(self): value1, closure_time1, ) - income1 = Income.get(sender_node=sender_node1, subtask=subtask_id1) - assert transaction_id1[2:] == income1.transaction - income2 = Income.get(sender_node=sender_node2, subtask=subtask_id2) - assert income2.transaction is None + self.assertIncomeHash(sender_node1, subtask_id1, transaction_id1) + self.assertIncomeHash(sender_node2, subtask_id2, None) self.incomes_keeper.received_batch_transfer( transaction_id2, @@ -185,72 +193,99 @@ def test_received_batch_transfer_two_senders(self): value2, closure_time2, ) - income1 = Income.get(sender_node=sender_node1, subtask=subtask_id1) - assert transaction_id1[2:] == income1.transaction - income2 = Income.get(sender_node=sender_node2, subtask=subtask_id2) - assert transaction_id2[2:] == income2.transaction + self.assertIncomeHash(sender_node1, subtask_id1, transaction_id1) + self.assertIncomeHash(sender_node2, subtask_id2, transaction_id2) @staticmethod def _create_income(**kwargs): - income = model_factories.Income(**kwargs) + income = model_factories.TaskPayment( + wallet_operation__operation_type= # noqa + model.WalletOperation.TYPE.task_payment, + wallet_operation__direction= # noqa + model.WalletOperation.DIRECTION.incoming, + **kwargs, + ) + income.wallet_operation.save(force_insert=True) income.save(force_insert=True) return income + @staticmethod + def _get_income(*args): + return model.TaskPayment \ + .incomes() \ + .where(*args) \ + .get() + def test_expect_income_accepted_ts(self): - sender_node = 64 * 'a' - payer_address = '0x' + 40 * '1' - subtask_id = 'sample_subtask_id1' + sender_node = random_eth_pub_key() + payer_address = random_eth_address() + subtask_id = str(uuid.uuid4()) + task_id = str(uuid.uuid4()) value = 123 accepted_ts = 1337 - income = self._create_income( - sender_node=sender_node, - subtask=subtask_id, - payer_address=payer_address, - value=value, - ) - assert income.accepted_ts is None - self.incomes_keeper.expect( - sender_node, - subtask_id, - payer_address, - value, - accepted_ts, + expect_kwargs = { + 'my_address': random_eth_address(), + 'sender_node': sender_node, + 'task_id': task_id, + 'subtask_id': subtask_id, + 'payer_address': payer_address, + 'value': value, + 'accepted_ts': accepted_ts, + } + income = self.incomes_keeper.expect(**expect_kwargs) + self.assertEqual(income.accepted_ts, accepted_ts) + db_income = self._get_income( + model.TaskPayment.node == sender_node, + model.TaskPayment.subtask == subtask_id, ) - income = Income.get(sender_node=sender_node, subtask=subtask_id) - assert income.accepted_ts == accepted_ts - self.incomes_keeper.expect( - sender_node, - subtask_id, - payer_address, - value, - accepted_ts + 1, + self.assertEqual(db_income.accepted_ts, accepted_ts) + expect_kwargs['accepted_ts'] += 1 + self.incomes_keeper.expect(**expect_kwargs) + db_income = self._get_income( + model.TaskPayment.node == sender_node, + model.TaskPayment.subtask == subtask_id, ) - income = Income.get(sender_node=sender_node, subtask=subtask_id) - assert income.accepted_ts == accepted_ts + self.assertEqual(db_income.accepted_ts, accepted_ts) @freeze_time() def test_update_overdue_incomes_all_paid(self): + tx_hash = f'0x{"0"*64}' income1 = self._create_income( accepted_ts=int(time.time()), - transaction='transaction') + wallet_operation__tx_hash=tx_hash) income2 = self._create_income( accepted_ts=int(time.time()) - 2*PAYMENT_DEADLINE, - transaction='transaction') + wallet_operation__tx_hash=tx_hash) self.incomes_keeper.update_overdue_incomes() - self.assertFalse(income1.refresh().overdue) - self.assertFalse(income2.refresh().overdue) + self.assertNotEqual( + income1.wallet_operation.refresh().status, + model.WalletOperation.STATUS.overdue, + ) + self.assertNotEqual( + income2.wallet_operation.refresh().status, + model.WalletOperation.STATUS.overdue, + ) @freeze_time() def test_update_overdue_incomes_accepted_deadline_passed(self): overdue_income = self._create_income( - accepted_ts=int(time.time()) - 2*PAYMENT_DEADLINE) + accepted_ts=int(time.time()) - 2*PAYMENT_DEADLINE, + wallet_operation__status=model.WalletOperation.STATUS.awaiting, + ) self.incomes_keeper.update_overdue_incomes() - self.assertTrue(overdue_income.refresh().overdue) + self.assertEqual( + overdue_income.wallet_operation.refresh().status, + model.WalletOperation.STATUS.overdue, + ) @freeze_time() def test_update_overdue_incomes_already_marked_as_overdue(self): income = self._create_income( accepted_ts=int(time.time()) - 2*PAYMENT_DEADLINE, - overdue=True) + wallet_operation__status=model.WalletOperation.STATUS.overdue, + ) self.incomes_keeper.update_overdue_incomes() - self.assertTrue(income.refresh().overdue) + self.assertEqual( + income.wallet_operation.refresh().status, + model.WalletOperation.STATUS.overdue, + ) diff --git a/tests/golem/ethereum/test_paymentprocessor.py b/tests/golem/ethereum/test_paymentprocessor.py index 1772b30ecb..774c3c5612 100644 --- a/tests/golem/ethereum/test_paymentprocessor.py +++ b/tests/golem/ethereum/test_paymentprocessor.py @@ -2,7 +2,6 @@ import random import time import uuid -import unittest import unittest.mock as mock from os import urandom @@ -13,30 +12,23 @@ from freezegun import freeze_time from hexbytes import HexBytes +from golem import model +from golem.core import variables from golem.core.common import timestamp_to_datetime from golem.ethereum.paymentprocessor import ( PaymentProcessor, PAYMENT_MAX_DELAY, ) -from golem.model import Payment, PaymentStatus, PaymentDetails from golem.testutils import DatabaseFixture +from tests.factories import model as model_factory -class PaymentStatusTest(unittest.TestCase): - def test_status(self): - s = PaymentStatus(1) - self.assertEqual(s, PaymentStatus.awaiting) - - -class PaymentProcessorInternalTest(DatabaseFixture): - """ In this suite we test internal logic of PaymentProcessor. The final - Ethereum transactions are not inspected. - """ +class PaymentProcessorBase(DatabaseFixture): def setUp(self): DatabaseFixture.setUp(self) self.addr = encode_hex(privtoaddr(urandom(32))) - self.sci = mock.Mock() + self.sci = mock.Mock(spec=golem_sci.SmartContractsInterface) self.sci.GAS_PRICE = 20 self.sci.GAS_PER_PAYMENT = 300 self.sci.GAS_BATCH_PAYMENT_BASE = 30 @@ -44,56 +36,84 @@ def setUp(self): self.sci.get_gntb_balance.return_value = 0 self.sci.get_eth_address.return_value = self.addr self.sci.get_current_gas_price.return_value = self.sci.GAS_PRICE - latest_block = mock.Mock() + self.sci.get_gate_address.return_value = None + latest_block = mock.Mock(golem_sci.Block) latest_block.gas_limit = 10 ** 10 - self.sci.get_latest_block.return_value = latest_block + self.sci.get_latest_confirmed_block.return_value = latest_block + self.tx_hash = ( + '0xa1360025847dbf4b02c53f4d62424a1f8b77d76d0278938600fd69cde6ec61f5' + ) + self.sci.batch_transfer.return_value = self.tx_hash + self.pp = PaymentProcessor(self.sci) self.pp._gnt_converter = mock.Mock() self.pp._gnt_converter.is_converting.return_value = False self.pp._gnt_converter.get_gate_balance.return_value = 0 + +class PaymentProcessorInternalTest(PaymentProcessorBase): + """ In this suite we test internal logic of PaymentProcessor. The final + Ethereum transactions are not inspected. + """ def test_load_from_db_awaiting(self): self.assertEqual([], self.pp._awaiting) - value = 10 - payment = Payment.create( - subtask=str(uuid.uuid4()), - payee=urandom(20), - value=value, + payment = model_factory.TaskPayment( + wallet_operation__operation_type= # noqa + model.WalletOperation.TYPE.task_payment, + wallet_operation__direction= # noqa + model.WalletOperation.DIRECTION.outgoing, ) + payment.wallet_operation.save(force_insert=True) + payment.save(force_insert=True) self.pp.load_from_db() expected = [payment] self.assertEqual(expected, self.pp._awaiting) - self.assertEqual(value, self.pp.reserved_gntb) + self.assertEqual( + payment.wallet_operation.amount, + self.pp.reserved_gntb, + ) self.assertLess(0, self.pp.recipients_count) def test_load_from_db_sent(self): tx_hash1 = encode_hex(urandom(32)) tx_hash2 = encode_hex(urandom(32)) value = 10 - payee = urandom(20) - sent_payment11 = Payment.create( - subtask=str(uuid.uuid4()), - payee=payee, - value=value, - details=PaymentDetails(tx=tx_hash1[2:]), - status=PaymentStatus.sent + payee = '0x' + 40 * '3' + sent_payment11 = model_factory.TaskPayment( + wallet_operation__operation_type= # noqa + model.WalletOperation.TYPE.task_payment, + wallet_operation__direction= # noqa + model.WalletOperation.DIRECTION.outgoing, + wallet_operation__recipient_address=payee, + wallet_operation__amount=value, + wallet_operation__tx_hash=tx_hash1, + wallet_operation__status=model.WalletOperation.STATUS.sent, ) - sent_payment12 = Payment.create( - subtask=str(uuid.uuid4()), - payee=payee, - value=value, - details=PaymentDetails(tx=tx_hash1[2:]), - status=PaymentStatus.sent + sent_payment12 = model_factory.TaskPayment( + wallet_operation__operation_type= # noqa + model.WalletOperation.TYPE.task_payment, + wallet_operation__direction= # noqa + model.WalletOperation.DIRECTION.outgoing, + wallet_operation__recipient_address=payee, + wallet_operation__amount=value, + wallet_operation__tx_hash=tx_hash1, + wallet_operation__status=model.WalletOperation.STATUS.sent, ) - sent_payment21 = Payment.create( - subtask=str(uuid.uuid4()), - payee=payee, - value=value, - details=PaymentDetails(tx=tx_hash2[2:]), - status=PaymentStatus.sent + sent_payment21 = model_factory.TaskPayment( + wallet_operation__operation_type= # noqa + model.WalletOperation.TYPE.task_payment, + wallet_operation__direction= # noqa + model.WalletOperation.DIRECTION.outgoing, + wallet_operation__recipient_address=payee, + wallet_operation__amount=value, + wallet_operation__tx_hash=tx_hash2, + wallet_operation__status=model.WalletOperation.STATUS.sent, ) + for sent_payment in (sent_payment11, sent_payment12, sent_payment21): + sent_payment.wallet_operation.save(force_insert=True) + sent_payment.save(force_insert=True) self.pp.load_from_db() self.assertEqual(3 * value, self.pp.reserved_gntb) self.assertEqual(0, self.pp.recipients_count) @@ -135,11 +155,19 @@ def test_monitor_progress(self): assert self.pp.recipients_count == 0 gnt_value = 10**17 - self.pp.add("test_subtask_id", encode_hex(urandom(20)), gnt_value) + self.pp.add( + subtask_id="test_subtask_id", + eth_addr=urandom(20), + value=gnt_value, + node_id='0xadbeef' + 'deadbeef' * 15, + task_id=str(uuid.uuid4()), + ) assert self.pp.reserved_gntb == gnt_value assert self.pp.recipients_count == 1 - tx_hash = '0xdead' + tx_hash = ( + '0xa1360025847dbf4b02c53f4d62424a1f8b77d76d0278938600fd69cde6ec61f5' + ) self.sci.batch_transfer.return_value = tx_hash assert self.pp.sendout(0) assert self.sci.batch_transfer.call_count == 1 @@ -150,7 +178,8 @@ def test_monitor_progress(self): tx_block_number = 1337 tx_timestamp = 1541766000.5 - self.sci.get_block_number.return_value = tx_block_number + self.sci.get_latest_confirmed_block_number.return_value = \ + tx_block_number self.sci.get_block_by_number.return_value = mock.Mock( timestamp=tx_timestamp) receipt = TransactionReceipt({ @@ -165,11 +194,15 @@ def test_monitor_progress(self): threads.deferToThread.call_args[0][0]( *threads.deferToThread.call_args[0][1:]) - p = Payment.get() - self.assertEqual(p.status, PaymentStatus.confirmed) - self.assertEqual(p.details.block_number, tx_block_number) - self.assertEqual(p.details.block_hash, 64 * 'f') - self.assertEqual(p.details.fee, 55001 * gas_price) + p = model.TaskPayment.get() + self.assertEqual( + p.wallet_operation.status, + model.WalletOperation.STATUS.confirmed, + ) + self.assertEqual( + p.wallet_operation.gas_cost, + 55001 * gas_price, + ) self.assertEqual(self.pp.reserved_gntb, 0) def test_failed_transaction(self): @@ -179,12 +212,19 @@ def test_failed_transaction(self): self.sci.get_gntb_balance.return_value = balance_gntb gnt_value = 10**17 - self.pp.add("test_subtask_id", encode_hex(urandom(20)), gnt_value) + self.pp.add( + subtask_id="test_subtask_id", + eth_addr=encode_hex(urandom(20)), + value=gnt_value, + node_id='0xadbeef' + 'deadbeef' * 15, + task_id=str(uuid.uuid4()), + ) self.pp.CLOSURE_TIME_DELAY = 0 - tx_hash = '0xdead' + tx_hash = \ + '0x5e9880b3e9349b609917014690c7a0afcdec6dbbfbef3812b27b60d246ca10ae' self.sci.batch_transfer.return_value = tx_hash - assert self.pp.sendout(0) + self.assertTrue(self.pp.sendout(0)) tx_block_number = 1337 receipt = TransactionReceipt({ @@ -198,20 +238,8 @@ def test_failed_transaction(self): self.sci.on_transaction_confirmed.call_args[0][1](receipt) threads.deferToThread.call_args[0][0]( *threads.deferToThread.call_args[0][1:]) - assert self.pp.reserved_gntb == gnt_value - assert len(self.pp._awaiting) == 1 - - def test_payment_timestamp(self): - self.sci.get_eth_balance.return_value = denoms.ether - - ts = 7000000 - with freeze_time(timestamp_to_datetime(ts)): - processed_ts = self.pp.add( - "test_subtask_id", - encode_hex(urandom(20)), - 1, - ) - self.assertEqual(ts, processed_ts) + self.assertEqual(self.pp.reserved_gntb, gnt_value) + self.assertEqual(len(self.pp._awaiting), 1) def _add_payment(pp, value=None, ts=None): @@ -219,33 +247,20 @@ def _add_payment(pp, value=None, ts=None): value = value if value else random.randint(1, 10) if not ts: ts = int(time.time()) - with freeze_time(timestamp_to_datetime(ts)): - pp.add(uuid.uuid4(), payee, value) + freezed = timestamp_to_datetime(ts) + with freeze_time(freezed): + payment = pp.add( + subtask_id=uuid.uuid4(), + eth_addr=payee, + value=value, + node_id='0xadbeef' + 'deadbeef' * 15, + task_id=str(uuid.uuid4()), + ) + assert payment.created_date == freezed return golem_sci.Payment(payee, value) -class InteractionWithSmartContractInterfaceTest(DatabaseFixture): - - def setUp(self): - DatabaseFixture.setUp(self) - self.sci = mock.Mock() - self.sci.GAS_BATCH_PAYMENT_BASE = 10 - self.sci.GAS_PER_PAYMENT = 1 - self.sci.GAS_PRICE = 20 - self.sci.get_gate_address.return_value = None - self.sci.get_current_gas_price.return_value = self.sci.GAS_PRICE - latest_block = mock.Mock() - latest_block.gas_limit = 10 ** 10 - self.sci.get_latest_block.return_value = latest_block - - self.tx_hash = '0xdead' - self.sci.batch_transfer.return_value = self.tx_hash - - self.pp = PaymentProcessor(self.sci) - self.pp._gnt_converter = mock.Mock() - self.pp._gnt_converter.is_converting.return_value = False - self.pp._gnt_converter.get_gate_balance.return_value = 0 - +class InteractionWithSmartContractInterfaceTest(PaymentProcessorBase): def _assert_batch_transfer_called_with( self, payments, @@ -445,7 +460,7 @@ def test_block_gas_limit(self): self.sci.get_eth_balance.return_value = denoms.ether self.sci.get_gnt_balance.return_value = 0 self.sci.get_gntb_balance.return_value = 1000 * denoms.ether - self.sci.get_latest_block.return_value.gas_limit = \ + self.sci.get_latest_confirmed_block.return_value.gas_limit = \ (self.sci.GAS_BATCH_PAYMENT_BASE + self.sci.GAS_PER_PAYMENT) /\ self.pp.BLOCK_GAS_LIMIT_RATIO self.pp.CLOSURE_TIME_DELAY = 0 @@ -459,3 +474,66 @@ def test_block_gas_limit(self): [scip1], 1) self.sci.batch_transfer.reset_mock() + + +class UpdateOverdueTest(PaymentProcessorBase): + def add_payment(self, processed_ts: int): + payment = model_factory.TaskPayment( + wallet_operation__operation_type= # noqa + model.WalletOperation.TYPE.task_payment, + wallet_operation__direction= # noqa + model.WalletOperation.DIRECTION.outgoing, + created_date=timestamp_to_datetime(processed_ts), + ) + payment.wallet_operation.save(force_insert=True) + payment.save(force_insert=True) + self.pp._awaiting.add(payment) + return payment + + def add_current_payment(self): + return self.add_payment(int(time.time())) + + def add_overdue_payment(self): + deadline = int(time.time()) - variables.PAYMENT_DEADLINE + return self.add_payment(deadline - random.randint(1, 100)) + + def test_no_overdues(self): + payment = self.add_current_payment() + self.pp.update_overdue() + self.assertIs( + payment.refresh().wallet_operation.status, + model.WalletOperation.STATUS.awaiting, + ) + + def test_one_overdue(self): + payment = self.add_current_payment() + payment_overdue = self.add_overdue_payment() + self.pp.update_overdue() + self.assertIs( + payment.refresh().wallet_operation.status, + model.WalletOperation.STATUS.awaiting, + ) + self.assertIs( + payment_overdue.refresh().wallet_operation.status, + model.WalletOperation.STATUS.overdue, + ) + + def test_all_overdues(self): + payments = [self.add_overdue_payment() for _ in range(10)] + self.pp.update_overdue() + for payment_overdue in payments: + self.assertIs( + payment_overdue.refresh().wallet_operation.status, + model.WalletOperation.STATUS.overdue, + ) + + def test_already_overdue(self): + payment_overdue = self.add_overdue_payment() + payment_overdue.wallet_operation.status = \ + model.WalletOperation.STATUS.overdue + payment_overdue.wallet_operation.save() + self.pp.update_overdue() + self.assertIs( + payment_overdue.refresh().wallet_operation.status, + model.WalletOperation.STATUS.overdue, + ) diff --git a/tests/golem/ethereum/test_paymentskeeper.py b/tests/golem/ethereum/test_paymentskeeper.py index 4ea2ccdb3d..ab5dff0a29 100644 --- a/tests/golem/ethereum/test_paymentskeeper.py +++ b/tests/golem/ethereum/test_paymentskeeper.py @@ -1,53 +1,20 @@ -from eth_utils import encode_hex -from os import urandom - -from golem.model import PaymentStatus -from golem.ethereum.paymentskeeper import PaymentsDatabase, PaymentsKeeper +from golem import model +from golem.ethereum.paymentskeeper import PaymentsDatabase from golem.tools.testwithdatabase import TestWithDatabase -from golem.tools.ci import ci_skip -from tests.factories.model import Payment as PaymentFactory - - -@ci_skip # Windows gives random failures #1738 -class TestPaymentsKeeper(TestWithDatabase): - def test_init(self): - pk = PaymentsKeeper() - self.assertIsInstance(pk, PaymentsKeeper) - - def test_database(self): - pk = PaymentsKeeper() - addr = urandom(20) - addr2 = urandom(20) - pk.finished_subtasks("xxyyzz", addr2, 2023) - pk.finished_subtasks("aabbcc", addr2, 2023) - pk.finished_subtasks("xxxyyy", addr2, 2023) - pk.finished_subtasks("zzzzzz", addr, 10) - pk.finished_subtasks("xxxxxx", addr, 10) - all_payments = pk.get_list_of_all_payments() - self.assertEqual(len(all_payments), 5) - self.assertEqual(all_payments[0]["subtask"], "xxxxxx") - self.assertEqual(all_payments[0]["payee"], encode_hex(addr)) - self.assertEqual(all_payments[0]["value"], str(10)) - self.assertEqual(all_payments[0]["status"], PaymentStatus.awaiting.name) - self.assertEqual(all_payments[1]["subtask"], "zzzzzz") - self.assertEqual(all_payments[1]["payee"], encode_hex(addr)) - self.assertEqual(all_payments[1]["value"], str(10)) - self.assertEqual(all_payments[1]["status"], PaymentStatus.awaiting.name) - self.assertEqual(all_payments[2]["subtask"], "xxxyyy") - self.assertEqual(all_payments[2]["payee"], encode_hex(addr2)) - self.assertEqual(all_payments[2]["value"], str(2023)) - self.assertEqual(all_payments[2]["status"], PaymentStatus.awaiting.name) - pk.finished_subtasks("whaooa!", addr, 10) - all_payments = pk.get_list_of_all_payments() - self.assertEqual(len(all_payments), 6) - assert pk.get_payment("xxyyzz") == 2023 - assert pk.get_payment("not existing") == 0 +from tests.factories.model import TaskPayment as TaskPaymentFactory class TestPaymentsDatabase(TestWithDatabase): @staticmethod def _create_payment(**kwargs): - payment = PaymentFactory(**kwargs) + payment = TaskPaymentFactory( + wallet_operation__operation_type= # noqa + model.WalletOperation.TYPE.task_payment, + wallet_operation__direction= # noqa + model.WalletOperation.DIRECTION.outgoing, + **kwargs, + ) + payment.wallet_operation.save(force_insert=True) payment.save(force_insert=True) return payment diff --git a/tests/golem/ethereum/test_transactionsystem.py b/tests/golem/ethereum/test_transactionsystem.py index f52204341c..f84c4a56d2 100644 --- a/tests/golem/ethereum/test_transactionsystem.py +++ b/tests/golem/ethereum/test_transactionsystem.py @@ -1,13 +1,17 @@ # pylint: disable=protected-access +import datetime from pathlib import Path import sys import time from typing import Optional from unittest.mock import patch, Mock, ANY, PropertyMock +import uuid from ethereum.utils import denoms import faker from freezegun import freeze_time +from golem_messages.factories import p2p as p2p_factory +import golem_sci import golem_sci.contracts import golem_sci.structs @@ -17,6 +21,8 @@ from golem.ethereum.transactionsystem import TransactionSystem from golem.ethereum.exceptions import NotEnoughFunds +from tests.factories import model as model_factory + fake = faker.Faker() PASSWORD = 'derp' @@ -24,17 +30,15 @@ class TransactionSystemBase(testutils.DatabaseFixture): def setUp(self): super().setUp() - self.sci = Mock() + self.sci = Mock(spec=golem_sci.SmartContractsInterface) self.sci.GAS_PRICE = 10 ** 9 self.sci.GAS_BATCH_PAYMENT_BASE = 30000 self.sci.get_gate_address.return_value = None - self.sci.get_block_number.return_value = 1223 self.sci.get_current_gas_price.return_value = self.sci.GAS_PRICE - 1 self.sci.get_eth_balance.return_value = 0 self.sci.get_gnt_balance.return_value = 0 self.sci.get_gntb_balance.return_value = 0 self.sci.GAS_PER_PAYMENT = 20000 - self.sci.REQUIRED_CONFS = 6 self.sci.get_deposit_locked_until.return_value = 0 self.ets = self._make_ets() @@ -78,6 +82,7 @@ def test_stop(self, mock_is_service_running): e = self._make_ets() mock_is_service_running.return_value = True + self.sci.get_latest_confirmed_block_number.return_value = 1223 e._payment_processor = Mock() # noqa pylint: disable=no-member e.stop() e._payment_processor.sendout.assert_called_once_with(0) # noqa pylint: disable=no-member @@ -112,7 +117,13 @@ def test_payment(self): subtask_id = 'derp' value = 10 payee = '0x' + 40 * '1' - self.ets.add_payment_info(subtask_id, value, payee) + self.ets.add_payment_info( + subtask_id=subtask_id, + value=value, + eth_address=payee, + node_id='0xadbeef' + 'deadbeef' * 15, + task_id=str(uuid.uuid4()), + ) payments = self.ets.get_payments_list() assert len(payments) == 1 assert payments[0]['subtask'] == subtask_id @@ -131,12 +142,11 @@ def test_get_withdraw_gas_cost(self): cost = self.ets.get_withdraw_gas_cost(200, dest, 'GNT') assert cost == self.sci.GAS_WITHDRAW - def test_get_gas_price(self): + def test_gas_price(self): test_gas_price = 1234 self.sci.get_current_gas_price.return_value = test_gas_price - ets = self._make_ets() - self.assertEqual(ets.gas_price, test_gas_price) + self.assertEqual(self.ets.gas_price, test_gas_price) def test_get_gas_price_limit(self): ets = self._make_ets() @@ -274,7 +284,7 @@ def test_subscriptions(self): ) block_number = 123 - self.sci.get_block_number.return_value = block_number + self.sci.get_latest_confirmed_block_number.return_value = block_number with patch('golem.ethereum.transactionsystem.LoopingCallService.stop'): self.ets.stop() @@ -283,7 +293,7 @@ def test_subscriptions(self): self.sci.subscribe_to_batch_transfers.assert_called_once_with( None, self.sci.get_eth_address(), - block_number - self.sci.REQUIRED_CONFS - 1, + block_number + 1, ANY, ) @@ -322,6 +332,32 @@ def test_backwards_compatibility_privkey(self): # Shouldn't throw self._make_ets(datadir=self.new_path / 'other', password=password) + def test_eth_for_batch_payment(self): + self.sci.get_eth_balance.return_value = 1 * denoms.ether + self.sci.get_gntb_balance.return_value = 100 * denoms.ether + self.ets._refresh_balances() + payments_count = 2 + + initial_gas_price = self.sci.GAS_PRICE + self.sci.GAS_PRICE = 10 * initial_gas_price + self.ets.lock_funds_for_payments(1, payments_count) + + self.sci.GAS_PRICE = initial_gas_price + eth_for_batch = self.ets.eth_for_batch_payment(payments_count) + + # Should be 0, since locked ETH > ETH required for batch payment + self.assertEqual(0, eth_for_batch) + + def test_expect_income(self): + self.ets.expect_income( + sender_node='0xadbeef' + 'deadbeef' * 15, + task_id=str(uuid.uuid4()), + subtask_id=str(uuid.uuid4()), + payer_address='0x' + 40 * '1', + value=10, + accepted_ts=1, + ) + class WithdrawTest(TransactionSystemBase): def setUp(self): @@ -581,7 +617,7 @@ def fail_it(tx_hash, cb): ) deposit_value = gntb_balance - (subtask_price * subtask_count) self.sci.deposit_payment.assert_called_once_with(deposit_value) - self.assertFalse(model.DepositPayment.select().exists()) + self.assertFalse(model.WalletOperation.deposit_transfers().exists()) def test_done(self): gntb_balance = 20 @@ -603,13 +639,16 @@ def test_done(self): self.assertEqual(tx_hash, db_tx_hash) deposit_value = gntb_balance - (subtask_price * subtask_count) self.sci.deposit_payment.assert_called_once_with(deposit_value) - dpayment = model.DepositPayment.get() + dpayment = model.WalletOperation.deposit_transfers().get() for field, value in ( - ('status', model.PaymentStatus.confirmed), - ('value', deposit_value), - ('fee', 42000), - ('tx', tx_hash),): - self.assertEqual(getattr(dpayment, field), value) + ('status', model.WalletOperation.STATUS.confirmed), + ('amount', deposit_value), + ('gas_cost', 42000), + ('tx_hash', tx_hash),): + self.assertEqual( + getattr(dpayment, field), + value, + ) def test_gas_price_skyrocketing(self): self.sci.get_deposit_value.return_value = 0 @@ -713,3 +752,97 @@ def test_full(self, call_later): int(time.time()) + delay self.sci.on_transaction_confirmed.call_args[0][1](Mock()) call_later.assert_called_once_with(delay, self.ets.concent_withdraw) + + +class DepositPaymentsListTest(TransactionSystemBase): + + def test_empty(self): + self.assertEqual(self.ets.get_deposit_payments_list(), []) + + def test_one(self): + tx_hash = \ + '0x5e9880b3e9349b609917014690c7a0afcdec6dbbfbef3812b27b60d246ca10ae' + value = 31337 + ts = 1514761200.0 + dt = datetime.datetime.fromtimestamp( + ts, + tz=datetime.timezone.utc, + ) + instance = model_factory.WalletOperation( + direction= # noqa + model.WalletOperation.DIRECTION.outgoing, + operation_type= # noqa + model.WalletOperation.TYPE.deposit_transfer, + status= # noqa + model.WalletOperation.STATUS.sent, + amount=value, + tx_hash=tx_hash, + created_date=dt, + modified_date=dt, + ) + instance.save(force_insert=True) + + self.assertEqual( + [instance], + self.ets.get_deposit_payments_list(), + ) + + +class IncomesListTest(TransactionSystemBase): + def test_empty(self): + self.assertEqual(self.ets.get_incomes_list(), []) + + def _get_income(self): + income = model_factory.TaskPayment( + wallet_operation__direction= # noqa + model.WalletOperation.DIRECTION.incoming, + wallet_operation__operation_type= # noqa + model.WalletOperation.TYPE.task_payment, + ) + income.wallet_operation.save(force_insert=True) + self.assertEqual( + income.save(force_insert=True), + 1, + ) + return income + + def test_one(self): + income = self._get_income() + node = p2p_factory.Node(key=income.node) + model.CachedNode( + node=node.key, + node_field=node, + ).save(force_insert=True) + self.assertEqual( + [ + { + 'created': ANY, + 'modified': ANY, + 'node': node.to_dict(), + 'payer': income.node, + 'status': 'awaiting', + 'subtask': income.subtask, + 'transaction': None, + 'value': str(income.expected_amount), + }, + ], + self.ets.get_incomes_list(), + ) + + def test_nodeskeeper_record_not_present(self): + income = self._get_income() + self.assertEqual( + [ + { + 'created': ANY, + 'modified': ANY, + 'node': None, + 'payer': income.node, + 'status': 'awaiting', + 'subtask': income.subtask, + 'transaction': None, + 'value': str(income.expected_amount), + }, + ], + self.ets.get_incomes_list(), + ) diff --git a/tests/golem/interface/test_client_commands.py b/tests/golem/interface/test_client_commands.py index b5bcc42a95..3f773cc681 100644 --- a/tests/golem/interface/test_client_commands.py +++ b/tests/golem/interface/test_client_commands.py @@ -49,7 +49,12 @@ def test_show(self): client.get_node.return_value = node client.get_computing_trust.return_value = .01 client.get_requesting_trust.return_value = .02 - client.get_payment_address.return_value = 'f0f0f0ababab' + + def call(uri, *_args, **_kwargs): + if uri == 'pay.ident': + return 'f0f0f0ababab' + return None + client._call.side_effect = call client.get_balance.return_value = { 'gnt': 3 * denoms.ether, 'av_gnt': 2 * denoms.ether, @@ -394,9 +399,15 @@ def setUpClass(cls): } for i in range(1, 6)] client = Mock() - client.get_incomes_list.return_value = incomes_list - client.get_payments_list.return_value = payments_list - client.get_deposit_payments_list.return_value = deposit_payments_list + def call(uri, *_args, **_kwargs): + if uri == 'pay.incomes': + return incomes_list + if uri == 'pay.payments': + return payments_list + if uri == 'pay.deposit_payments': + return deposit_payments_list + return None + client._call.side_effect = call cls.n_incomes = len(incomes_list) cls.n_payments = len(payments_list) @@ -864,7 +875,7 @@ def test_subtasks_ok(self): subtasks = tasks.subtasks('745c1d01', None) assert isinstance(subtasks, CommandResult) assert subtasks.data[1][0] == [ - 'node_1', 'subtask_1', '0:00:09', 'waiting', '1.00 %' + 'node_1', 'subtask_1', 'waiting', '1.00 %' ] def test_subtasks_error(self): diff --git a/tests/golem/network/concent/test_concent_client.py b/tests/golem/network/concent/test_concent_client.py index 80cf75f011..a19b450dbd 100644 --- a/tests/golem/network/concent/test_concent_client.py +++ b/tests/golem/network/concent/test_concent_client.py @@ -16,6 +16,9 @@ import golem_messages.exceptions from golem_messages import message from golem_messages import factories as msg_factories +from golem_messages.factories.helpers import ( + random_eth_address, +) from golem import testutils from golem.core import keysauth @@ -487,13 +490,19 @@ def tearDown(self): def test_submit(self, submit_mock): sra1 = msg_factories.tasks.SubtaskResultsAcceptedFactory( payment_ts=int(time.time()) - 3600*26, + report_computed_task__task_to_compute__concent_enabled=True, ) sra2 = msg_factories.tasks.SubtaskResultsAcceptedFactory( payment_ts=int(time.time()) - 3600*25, + report_computed_task__task_to_compute__concent_enabled=True, ) + sra3 = msg_factories.tasks.SubtaskResultsAcceptedFactory( + payment_ts=int(time.time()) - 3600*25, + ) + local_role = history.Actor.Provider remote_role = history.Actor.Requestor - for msg in (sra1, sra2): + for msg in (sra1, sra2, sra3): msg._fake_sign() history.add( msg=msg, @@ -504,8 +513,10 @@ def test_submit(self, submit_mock): ) self.incomes_keeper.expect( sender_node='requestor_id', + task_id=msg.task_id, subtask_id=msg.subtask_id, payer_address='0x1234', + my_address=random_eth_address(), value=msg.task_to_compute.price, # pylint: disable=no-member accepted_ts=msg.payment_ts, ) diff --git a/tests/golem/network/concent/test_received_handler.py b/tests/golem/network/concent/test_received_handler.py index 72bf31b5a3..36138ba4b3 100644 --- a/tests/golem/network/concent/test_received_handler.py +++ b/tests/golem/network/concent/test_received_handler.py @@ -160,6 +160,8 @@ def test_reject_from_requestor(self, add_mock): class TaskServerMessageHandlerTestBase( testutils.DatabaseFixture, testutils.TestWithClient): + @mock.patch('golem.envs.docker.cpu.deferToThread', + lambda f, *args, **kwargs: f(*args, **kwargs)) def setUp(self): # Avoid warnings caused by previous tests leaving handlers library._handlers = {} @@ -410,11 +412,12 @@ def test_force_subtask_results_response_accepted( library.interpret(msg) self.client.transaction_system.expect_income.assert_called_once_with( - msg.task_to_compute.requestor_id, - msg.subtask_id, - msg.task_to_compute.requestor_ethereum_address, - msg.task_to_compute.price, - msg.subtask_results_accepted.payment_ts, + sender_node=msg.task_to_compute.requestor_id, + task_id=msg.task_id, + subtask_id=msg.subtask_id, + payer_address=msg.task_to_compute.requestor_ethereum_address, + value=msg.task_to_compute.price, + accepted_ts=msg.subtask_results_accepted.payment_ts, ) add_mock.assert_called_once_with( @@ -692,6 +695,44 @@ def test_no_sra_nor_srr(self, last_resort_mock, get_mock): .report_computed_task, ) + @mock.patch('golem.network.history.get') + @mock.patch( + 'golem.network.concent.received_handler' + '.TaskServerMessageHandler.' + '_after_ack_report_computed_task') + def test_no_sra_nor_srr_but_has_fgtrf(self, last_resort_mock, get_mock): + fgtrf = msg_factories.concents.ForceGetTaskResultFailedFactory( + task_to_compute__subtask_id=self.msg.subtask_id, + ) + + def history_get(*, message_class_name, **_kwargs): + if message_class_name == 'ForceGetTaskResultFailed': + return fgtrf + return None + + get_mock.side_effect = history_get + + library.interpret(self.msg) + + last_resort_mock.assert_not_called() + self.task_server.client.concent_service.submit_task_message \ + .assert_called_once_with( + self.msg.subtask_id, + message.concents.ForceSubtaskResultsResponse( + subtask_results_accepted=None, + subtask_results_rejected=( + message.tasks.SubtaskResultsRejected( + report_computed_task=( + self.msg.ack_report_computed_task + .report_computed_task), + force_get_task_result_failed=fgtrf, + reason=(message.tasks.SubtaskResultsRejected.REASON + .ForcedResourcesFailure), + ) + ), + ) + ) + @mock.patch('golem.network.history.get') @mock.patch( 'golem.network.concent.received_handler' diff --git a/tests/golem/network/p2p/test_peersession.py b/tests/golem/network/p2p/test_peersession.py index f04cd33273..555d3f9184 100644 --- a/tests/golem/network/p2p/test_peersession.py +++ b/tests/golem/network/p2p/test_peersession.py @@ -37,7 +37,8 @@ class TestPeerSession(testutils.DatabaseFixture, LogTestCase, testutils.PEP8MixIn): PEP8_FILES = ['golem/network/p2p/peersession.py', ] - def setUp(self): + @patch('golem.task.taskserver.NonHypervisedDockerCPUEnvironment') + def setUp(self, _): super().setUp() random.seed() self.peer_session = PeerSession(MagicMock()) @@ -60,21 +61,18 @@ def setUp(self): def __setup_handshake_server_test(self, send_mock) -> message.base.Hello: self.peer_session.conn.server.node = node = dt_p2p_factory.Node() - self.peer_session.conn.server.node_name = node_name = node.node_name + self.peer_session.conn.server.node_name = node.node_name self.peer_session.conn.server.keys_auth.key_id = \ key_id = 'server_key_id' - self.peer_session.conn.server.key_difficulty = 2 self.peer_session.conn.server.cur_port = port = random.randint(1, 50000) self.peer_session.conn_type = self.peer_session.CONN_TYPE_SERVER self.peer_session.start() self.assertEqual(1, send_mock.call_count) expected = message.base.Hello( challenge=None, - client_key_id=key_id, client_ver=golem.__version__, difficulty=None, node_info=node, - node_name=node_name, port=port, proto_id=PROTOCOL_CONST.ID, rand_val=self.peer_session.rand_val, @@ -95,9 +93,7 @@ def find_peer(key): client_peer_info = dt_p2p_factory.Node() client_hello = message.base.Hello( port=1, - node_name='client', rand_val=random.random(), - client_key_id=client_peer_info.key, node_info=client_peer_info, proto_id=PROTOCOL_CONST.ID) fill_slots(client_hello) @@ -141,19 +137,6 @@ def test_handshake_server_protoid(self, send_mock): message.base.Disconnect( reason=message.base.Disconnect.REASON.ProtocolVersion).slots()) - @patch('golem.network.transport.session.BasicSession.send') - @patch('golem.core.keysauth.KeysAuth.is_pubkey_difficult', - return_value=False) - def test_react_to_hello_key_not_difficult(self, is_difficult_fn, send_mock): - client_hello = self.__setup_handshake_server_test(send_mock) - - self.peer_session._react_to_hello(client_hello) - assert self.peer_session.key_id is None - - # should not throw - self.peer_session._react_to_rand_val( - message.base.RandVal(rand_val=self.peer_session.rand_val)) - @patch('golem.network.transport.session.BasicSession.send') def test_handshake_server_randval(self, send_mock): client_hello = self.__setup_handshake_server_test(send_mock) @@ -169,20 +152,9 @@ def test_handshake_server_randval(self, send_mock): message.base.Disconnect( reason=message.base.Disconnect.REASON.Unverified).slots()) - @patch('golem.network.transport.session.BasicSession.send') - def test_handshake_server_key_not_difficult(self, send_mock): - client_hello = self.__setup_handshake_server_test(send_mock) - client_hello.node_info.key = 'deadbeef' * 16 - self.peer_session._react_to_hello(client_hello) - - self.assertEqual( - send_mock.call_args_list[1][0][1].slots(), - message.base.Disconnect( - reason=message.base.Disconnect.REASON.KeyNotDifficult).slots()) - def __setup_handshake_client_test(self, send_mock): self.peer_session.conn.server.node = node = dt_p2p_factory.Node() - self.peer_session.conn.server.node_name = node_name = node.node_name + self.peer_session.conn.server.node_name = node.node_name self.peer_session.conn.server.keys_auth.key_id = \ key_id = node.key self.peer_session.conn.server.cur_port = port = random.randint(1, 50000) @@ -201,9 +173,7 @@ def find_peer(key): self.peer_session.p2p_service.enough_peers = lambda: False server_hello = message.base.Hello( port=1, - node_name='server', rand_val=random.random(), - client_key_id=server_peer_info.key, node_info=server_peer_info, proto_id=PROTOCOL_CONST.ID, metadata={}, @@ -211,11 +181,9 @@ def find_peer(key): fill_slots(server_hello) expected = message.base.Hello( challenge=None, - client_key_id=key_id, client_ver=golem.__version__, difficulty=None, node_info=node, - node_name=node_name, port=port, proto_id=PROTOCOL_CONST.ID, rand_val=self.peer_session.rand_val, @@ -286,9 +254,6 @@ def test_react_to_hello_new_version(self): ) msg_kwargs = { 'port': random.randint(0, 65535), - 'node_name': 'How could youths better learn to live than by at' - 'once trying the experiment of living? --HDT', - 'client_key_id': peer_info.key, 'client_ver': None, 'node_info': peer_info, 'proto_id': random.randint(0, sys.maxsize), @@ -495,7 +460,8 @@ def test_send_remove_task(self, send_mock): send_mock.assert_called() assert isinstance(send_mock.call_args[0][0], message.p2p.RemoveTask) - def _gen_data_for_test_react_to_remove_task(self): + @patch('golem.task.taskserver.NonHypervisedDockerCPUEnvironment') + def _gen_data_for_test_react_to_remove_task(self, _): keys_auth = KeysAuth(self.path, 'priv_key', 'password') previous_ka = self.peer_session.p2p_service.keys_auth self.peer_session.p2p_service.keys_auth = keys_auth diff --git a/tests/golem/network/test_history.py b/tests/golem/network/test_history.py index 708a1ddba1..c345dad8e7 100644 --- a/tests/golem/network/test_history.py +++ b/tests/golem/network/test_history.py @@ -22,7 +22,7 @@ def message_count(): return NetworkMessage.select().count() -class TestMessageHistoryService(DatabaseFixture): +class MessageHistoryServiceTestBase(DatabaseFixture): def setUp(self): super().setUp() @@ -32,6 +32,8 @@ def tearDown(self): super().tearDown() history.MessageHistoryService.instance = None + +class TestMessageHistoryService(MessageHistoryServiceTestBase): @staticmethod def _build_dict(task=None, subtask=None): return dict( @@ -256,6 +258,44 @@ def test_loop_remove_sync(self): assert not self.service.remove_sync.called +class TestMessageHistoryGet(MessageHistoryServiceTestBase): + def setUp(self): + super().setUp() + self.msg = msg_factories.tasks.TaskToComputeFactory() + self.msg._fake_sign() + self.node_id = fake.binary(length=64) + self.local_role = fake.random_element(Actor) + self.remote_role = fake.random_element(Actor) + + history.add( + msg=self.msg, + node_id=self.node_id, + local_role=self.local_role, + remote_role=self.remote_role, + sync=True + ) + + def test_get(self): + msg_retrieved = history.get( + 'TaskToCompute', + subtask_id=self.msg.subtask_id, + node_id=self.node_id, + ) + + self.assertEqual(self.msg, msg_retrieved) + + def test_get_pickle_fail(self): + with mock.patch('golem.model.pickle.loads', + mock.Mock(side_effect=AttributeError)): + msg_retrieved = history.get( + 'TaskToCompute', + subtask_id=self.msg.subtask_id, + node_id=self.node_id, + ) + + self.assertIsNone(msg_retrieved) + + @mock.patch("golem.network.history.MessageHistoryService.add") class TestAdd(unittest.TestCase): def setUp(self): diff --git a/tests/golem/resource/test_resourcehandshake.py b/tests/golem/resource/test_resourcehandshake.py index e457039436..89e92ba828 100644 --- a/tests/golem/resource/test_resourcehandshake.py +++ b/tests/golem/resource/test_resourcehandshake.py @@ -10,6 +10,10 @@ from golem_messages.factories.datastructures import tasks as dt_tasks_factory from twisted.internet.defer import Deferred +from golem.appconfig import ( + DEFAULT_HYPERDRIVE_RPC_PORT, DEFAULT_HYPERDRIVE_RPC_ADDRESS +) +from golem.network.hyperdrive.daemon_manager import HyperdriveDaemonManager from golem.resource.dirmanager import DirManager from golem.resource.hyperdrive.resource import ResourceStorage from golem.resource.hyperdrive.resourcesmanager import HyperdriveResourceManager @@ -302,7 +306,6 @@ def test_handshake_error(self, *_): self.session._handshake_error(self.session.key_id, 'Test error') assert self.session._block_peer.called assert self.session._finalize_handshake.called - assert self.session.task_server.task_computer.session_closed.called assert not self.session.disconnect.called def test_get_set_remove_handshake(self, *_): diff --git a/tests/golem/rpc/api/test_ethereum.py b/tests/golem/rpc/api/test_ethereum.py new file mode 100644 index 0000000000..0022594035 --- /dev/null +++ b/tests/golem/rpc/api/test_ethereum.py @@ -0,0 +1,91 @@ +import datetime +from unittest import TestCase, mock + +from golem_messages.datastructures import p2p as dt_p2p + +from golem import model +from golem.ethereum.transactionsystem import TransactionSystem +from golem.rpc.api.ethereum_ import ETSProvider + +from tests.factories.model import TaskPayment as TaskPaymentFactory + + +class TestEthereum(TestCase): + def setUp(self): + self.maxDiff = None + self.ets = mock.Mock(spec_set=TransactionSystem) + self.ets_provider = ETSProvider(self.ets) + + def test_get_gas_price(self): + test_gas_price = 1234 + test_price_limit = 12345 + self.ets.gas_price = test_gas_price + self.ets.gas_price_limit = test_price_limit + + result = self.ets_provider.get_gas_price() + + self.assertEqual(result["current_gas_price"], str(test_gas_price)) + self.assertEqual(result["gas_price_limit"], str(test_price_limit)) + + def test_one(self): + tx_hash = \ + '0x5e9880b3e9349b609917014690c7a0afcdec6dbbfbef3812b27b60d246ca10ae' + value = 31337 + ts = 1514761200.0 + dt = datetime.datetime.fromtimestamp( + ts, + tz=datetime.timezone.utc, + ) + deposit_payment = mock.Mock(spec_set=model.WalletOperation) + deposit_payment.amount = value + deposit_payment.tx_hash = tx_hash + deposit_payment.created_date = dt + deposit_payment.modified_date = dt + deposit_payment.gas_cost = 0 + deposit_payment.status = model.WalletOperation.STATUS.awaiting + self.ets.get_deposit_payments_list.return_value = [deposit_payment] + + expected = [ + { + 'created': ts, + 'modified': ts, + 'fee': '0', + 'status': 'awaiting', + 'transaction': tx_hash, + 'value': str(value), + }, + ] + self.assertEqual( + expected, + self.ets_provider.get_deposit_payments_list(), + ) + + @mock.patch('golem.network.nodeskeeper.get', return_value=None) + def test_get_incomes_list(self, *_): + ts = 1514761200.0 + dt = datetime.datetime.fromtimestamp( + ts, + tz=datetime.timezone.utc, + ) + instance = TaskPaymentFactory( + created_date=dt, + modified_date=dt, + ) + self.ets.get_incomes_list.return_value = [instance] + + expected = [ + { + 'subtask': instance.subtask, + 'payer': instance.node, + 'value': str(instance.wallet_operation.amount), + 'status': 'awaiting', + 'transaction': instance.wallet_operation.tx_hash, + 'created': ts, + 'modified': ts, + 'node': dt_p2p.Node(key=instance.node).to_dict(), + }, + ] + self.assertEqual( + expected, + self.ets_provider.get_incomes_list(), + ) diff --git a/tests/golem/rpc/test_router.py b/tests/golem/rpc/test_router.py index 95708eac01..551b5ce057 100644 --- a/tests/golem/rpc/test_router.py +++ b/tests/golem/rpc/test_router.py @@ -9,12 +9,13 @@ import os import pprint import time -from threading import Thread +from multiprocessing import Process import typing -from unittest import mock +from unittest import mock, skip from autobahn.twisted import util from autobahn.wamp import ApplicationError +import psutil from twisted.internet.defer import inlineCallbacks from twisted.internet.defer import setDebugging @@ -29,6 +30,7 @@ Session, ) from golem.tools.testwithreactor import TestDirFixtureWithReactor +from golem.tools.testchildprocesses import KillLeftoverChildrenTestMixin setDebugging(True) @@ -82,7 +84,7 @@ class MockProxy(ClientProxy): # pylint: disable=too-few-public-methods ) -class _TestRouter(TestDirFixtureWithReactor): +class _TestRouter(KillLeftoverChildrenTestMixin, TestDirFixtureWithReactor): TIMEOUT = 20 CSRB_FRONTEND: typing.Optional[cert.CertificateManager.CrossbarUsers] = None CSRB_BACKEND: typing.Optional[cert.CertificateManager.CrossbarUsers] = None @@ -107,6 +109,7 @@ def __init__(self): self.subscribe = False self.method = None + self.process = None def add_errors(self, *errors): print('Errors: {}'.format(pprint.pformat(errors))) @@ -164,7 +167,7 @@ def _backend_session_started(self, *_): yield txdefer yield self._frontend_session_started() - def _wait_for_thread(self, expect_error=False): + def _wait_for_process(self, expect_error=False): deadline = time.time() + self.TIMEOUT while True: @@ -189,14 +192,19 @@ def _wait_for_thread(self, expect_error=False): raise Exception("Expected error") self.reactor_thread.reactor.stop() + if self.process.is_alive(): + self.process.terminate() + def _run_test(self, expect_error, *args, **kwargs): - thread = Thread(target=self.in_thread, args=args, kwargs=kwargs) - thread.daemon = True - thread.run() + self.process = Process( + target=self.in_subprocess, args=args, kwargs=kwargs + ) + self.process.daemon = True + self.process.run() - self._wait_for_thread(expect_error=expect_error) + self._wait_for_process(expect_error=expect_error) - def in_thread(self, *args, **kwargs): + def in_subprocess(self, *args, **kwargs): deferred = self._start_router(*args, **kwargs) deferred.addCallback(lambda *args: print('Router finished', args)) deferred.addErrback(self.state.add_errors) diff --git a/tests/golem/task/dummy/runner.py b/tests/golem/task/dummy/runner.py index f062459971..0250f9407e 100644 --- a/tests/golem/task/dummy/runner.py +++ b/tests/golem/task/dummy/runner.py @@ -30,6 +30,7 @@ from golem.task import rpc as task_rpc from golem.model import db, DB_FIELDS, DB_MODELS from golem.network.transport.tcpnetwork import SocketAddress +from tests.factories import model as model_factory from tests.golem.task.dummy.task import DummyTask, DummyTaskParameters REQUESTING_NODE_KIND = "requestor" @@ -68,9 +69,10 @@ def create_client(datadir, node_name): app_config = AppConfig.load_config(datadir) config_desc = ClientConfigDescriptor() config_desc.init_from_app_config(app_config) - config_desc.key_difficulty = 0 config_desc.use_upnp = False config_desc.node_name = node_name + config_desc.max_memory_size = 1024 * 1024 # 1 GiB + config_desc.num_cores = 1 from golem.core.keysauth import KeysAuth with mock.patch.dict('ethereum.keys.PBKDF2_CONSTANTS', {'c': 1}): @@ -78,7 +80,6 @@ def create_client(datadir, node_name): datadir=datadir, private_key_name=faker.Faker().pystr(), password='password', - difficulty=config_desc.key_difficulty, ) database = Database( @@ -116,7 +117,7 @@ def _make_mock_ets(): ets.eth_base_for_batch_payment.return_value = 0.001 * denoms.ether ets.get_payment_address.return_value = '0x' + 40 * '6' ets.get_nodes_with_overdue_payments.return_value = [] - ets.add_payment_info.return_value = int(time.time()) + ets.add_payment_info.return_value = model_factory.TaskPayment() return ets @@ -157,23 +158,24 @@ def shutdown(): client = create_client(datadir, '[Requestor] DUMMY') client.are_terms_accepted = lambda: True - client.start() - report("Started in {:.1f} s".format(time.time() - start_time)) - dummy_env = DummyEnvironment() - client.environments_manager.add_environment(dummy_env) + def _start(): + client.start() + report("Started in {:.1f} s".format(time.time() - start_time)) - params = DummyTaskParameters(1024, 2048, 256, 0x0001ffff) - task = DummyTask(client.get_node_name(), params, num_subtasks, - client.keys_auth.public_key) - task.initialize(DirManager(datadir)) - task_rpc.enqueue_new_task(client, task) + dummy_env = DummyEnvironment() + client.environments_manager.add_environment(dummy_env) - port = client.p2pservice.cur_port - requestor_addr = "{}:{}".format(client.node.prv_addr, port) - report("Listening on {}".format(requestor_addr)) + params = DummyTaskParameters(1024, 2048, 256, 0x0001ffff) + task = DummyTask(client.get_node_name(), params, num_subtasks, + client.keys_auth.public_key) + task.initialize(DirManager(datadir)) + task_rpc.enqueue_new_task(client, task) + + port = client.p2pservice.cur_port + requestor_addr = "{}:{}".format(client.node.prv_addr, port) + report("Listening on {}".format(requestor_addr)) - def report_status(): while True: time.sleep(1) if not task.finished_computation(): @@ -185,7 +187,7 @@ def report_status(): shutdown() return - reactor.callInThread(report_status) + reactor.callInThread(_start) reactor.run() return client # Used in tests, with mocked reactor @@ -217,19 +219,20 @@ def shutdown(): client = create_client(datadir, f'[Provider{ provider_id }] DUMMY') client.are_terms_accepted = lambda: True - client.start() - client.task_server.task_computer.support_direct_computation = True - report("Started in {:.1f} s".format(time.time() - start_time)) - dummy_env = DummyEnvironment() - dummy_env.accept_tasks = True - client.environments_manager.add_environment(dummy_env) + def _start(): + client.start() + client.task_server.task_computer.support_direct_computation = True + report("Started in {:.1f} s".format(time.time() - start_time)) + + dummy_env = DummyEnvironment() + dummy_env.accept_tasks = True + client.environments_manager.add_environment(dummy_env) - report("Connecting to requesting node at {}:{} ..." - .format(peer_address.address, peer_address.port)) - client.connect(peer_address) + report("Connecting to requesting node at {}:{} ..." + .format(peer_address.address, peer_address.port)) + client.connect(peer_address) - def report_status(fail_after=None): t0 = time.time() while True: if fail_after and time.time() - t0 > fail_after: @@ -239,7 +242,7 @@ def report_status(fail_after=None): return time.sleep(1) - reactor.callInThread(report_status, fail_after) + reactor.callInThread(_start) reactor.run() return client # Used in tests, with mocked reactor diff --git a/tests/golem/task/dummy/test_runner_script.py b/tests/golem/task/dummy/test_runner_script.py index 5a5bdb7331..f90543aea9 100644 --- a/tests/golem/task/dummy/test_runner_script.py +++ b/tests/golem/task/dummy/test_runner_script.py @@ -5,6 +5,7 @@ from tests.golem.task.dummy import runner, task +@mock.patch('golem.task.taskserver.NonHypervisedDockerCPUEnvironment') class TestDummyTaskRunnerScript(DatabaseFixture): """Tests for the runner script""" @@ -13,7 +14,7 @@ class TestDummyTaskRunnerScript(DatabaseFixture): @mock.patch("tests.golem.task.dummy.runner.run_simulation") def test_runner_dispatch_requesting( self, mock_run_simulation, mock_run_computing_node, - mock_run_requesting_node): + mock_run_requesting_node, _): args = ["runner.py", runner.REQUESTING_NODE_KIND, self.path, "7"] runner.dispatch(args) self.assertTrue(mock_run_requesting_node.called) @@ -26,7 +27,7 @@ def test_runner_dispatch_requesting( @mock.patch("tests.golem.task.dummy.runner.run_simulation") def test_runner_dispatch_computing( self, mock_run_simulation, mock_run_computing_node, - mock_run_requesting_node): + mock_run_requesting_node, _): args = ["runner.py", runner.COMPUTING_NODE_KIND, self.path, "1.2.3.4:5678", "pid", ] runner.dispatch(args) @@ -44,7 +45,7 @@ def test_runner_dispatch_computing( @mock.patch("tests.golem.task.dummy.runner.run_simulation") def test_runner_dispatch_computing_with_failure( self, mock_run_simulation, mock_run_computing_node, - mock_run_requesting_node): + mock_run_requesting_node, _): args = ["runner.py", runner.COMPUTING_NODE_KIND, self.path, "10.0.255.127:16000", "pid", "25"] runner.dispatch(args) @@ -62,7 +63,7 @@ def test_runner_dispatch_computing_with_failure( @mock.patch("tests.golem.task.dummy.runner.run_simulation") def test_runner_run_simulation( self, mock_run_simulation, mock_run_computing_node, - mock_run_requesting_node): + mock_run_requesting_node, _): args = ["runner.py"] mock_run_simulation.return_value = None runner.dispatch(args) @@ -83,21 +84,18 @@ def test_run_requesting_node(self, mock_reactor, mock_config_logging, *_): client = runner.run_requesting_node(self.path, 3) self.assertTrue(mock_reactor.run.called) - self.assertTrue(mock_enqueue_new_task.called) self.assertTrue(mock_config_logging.called) client.quit() @mock.patch("tests.golem.task.dummy.runner.atexit") @mock.patch("tests.golem.task.dummy.runner.reactor") @mock.patch("golem.core.common.config_logging") - def test_run_computing_node(self, mock_config_logging, mock_reactor, _): + def test_run_computing_node(self, mock_config_logging, mock_reactor, *_): client = runner.run_computing_node( self.path, SocketAddress("127.0.0.1", 40102), "pid", ) - assert task.DummyTask.ENVIRONMENT_NAME in \ - client.environments_manager.environments mock_reactor.run.assert_called_once_with() mock_config_logging.assert_called_once_with( datadir=mock.ANY, @@ -107,7 +105,7 @@ def test_run_computing_node(self, mock_config_logging, mock_reactor, _): client.quit() @mock.patch("subprocess.Popen") - def test_run_simulation(self, mock_popen): + def test_run_simulation(self, mock_popen, _): mock_process = mock.MagicMock() mock_process.pid = 12345 mock_popen.return_value = mock_process diff --git a/tests/golem/task/server/test_helpers.py b/tests/golem/task/server/test_helpers.py index 14dc5aa0f6..370a8af9c3 100644 --- a/tests/golem/task/server/test_helpers.py +++ b/tests/golem/task/server/test_helpers.py @@ -76,7 +76,6 @@ def test_basic(self, add_mock, put_mock, get_mock, *_): self.assertEqual(node_id, self.wtr.owner.key) self.assertIsInstance(rct, message.tasks.ReportComputedTask) self.assertEqual(rct.subtask_id, self.wtr.subtask_id) - self.assertEqual(rct.node_name, self.task_server.node.node_name) self.assertEqual(rct.address, self.task_server.node.prv_addr) self.assertEqual(rct.port, self.task_server.cur_port) self.assertEqual(rct.extra_data, []) diff --git a/tests/golem/task/server/test_queue.py b/tests/golem/task/server/test_queue.py index 4c57a09ce5..8a01f557c6 100644 --- a/tests/golem/task/server/test_queue.py +++ b/tests/golem/task/server/test_queue.py @@ -6,10 +6,12 @@ from golem_messages.factories import tasks as tasks_factories from golem import testutils +from golem.envs.manager import EnvironmentManager as NewEnvManager from golem.network.transport import tcpserver from golem.task import taskkeeper from golem.task.server import queue_ as srv_queue + class TestTaskQueueMixin( testutils.DatabaseFixture, testutils.TestWithClient, @@ -22,7 +24,8 @@ def setUp(self): self.server.task_manager = self.client.task_manager self.server.client = self.client self.server.task_keeper = taskkeeper.TaskHeaderKeeper( - environments_manager=self.client.environments_manager, + old_env_manager=self.client.environments_manager, + new_env_manager=NewEnvManager(), node=self.client.node, min_price=0 ) diff --git a/tests/golem/task/server/test_resources.py b/tests/golem/task/server/test_resources.py index 4331fc80df..80c6820b7b 100644 --- a/tests/golem/task/server/test_resources.py +++ b/tests/golem/task/server/test_resources.py @@ -1,12 +1,18 @@ # pylint: disable=protected-access from unittest import mock +from twisted.internet.defer import Deferred + from golem_messages import cryptography from golem_messages import utils as msg_utils +from golem_messages.factories.datastructures import p2p as dt_p2p_factory from golem_messages.factories import helpers as msg_helpers +from golem.network.p2p.local_node import LocalNode +from golem.envs.manager import EnvironmentManager as NewEnvManager from golem.task.taskkeeper import TaskHeaderKeeper from golem.task.server.resources import TaskResourcesMixin +from golem.task.tasksession import TaskSession from golem.testutils import TestWithClient @@ -17,7 +23,8 @@ def setUp(self): self.server.task_manager = self.client.task_manager self.server.client = self.client self.server.task_keeper = TaskHeaderKeeper( - environments_manager=self.client.environments_manager, + old_env_manager=self.client.environments_manager, + new_env_manager=NewEnvManager(), node=self.client.node, min_price=0 ) @@ -29,15 +36,14 @@ def test_request_resource(self): @mock.patch( "golem.task.server.resources.TaskResourcesMixin._start_handshake_timer", ) -@mock.patch( - "golem.task.server.resources.TaskResourcesMixin._share_handshake_nonce", -) class TestResourceHandhsake(TestWithClient): def setUp(self): super().setUp() self.server = TaskResourcesMixin() + self.server.sessions = {} self.server.resource_handshakes = {} self.server.client = self.client + self.server.node = LocalNode(**dt_p2p_factory.Node().to_dict()) self.server.resource_manager.storage.get_dir.return_value = self.tempdir self.ecc = cryptography.ECCx(None) self.task_id = msg_helpers.fake_golem_uuid(self.public_key) @@ -50,6 +56,10 @@ def public_key(self): def key_id(self): return self.public_key[2:] + @mock.patch( + "golem.task.server.resources." + "TaskResourcesMixin._share_handshake_nonce", + ) def test_start_handshake(self, mock_share, mock_timer, *_): self.server.start_handshake( key_id=self.key_id, @@ -62,6 +72,57 @@ def test_start_handshake(self, mock_share, mock_timer, *_): mock_timer.assert_called_once_with(self.key_id) mock_share.assert_called_once_with(self.key_id) + @mock.patch('golem.task.server.resources.msg_queue.put') + def test_start_handshake_nonce_callback(self, mock_queue, *_): + deferred = Deferred() + self.server.resource_manager.add_file.return_value = deferred + self.server.start_handshake( + key_id=self.key_id, + task_id=self.task_id, + ) + + exception = False + + def exception_on_error(error): + nonlocal exception + exception = error + + deferred.addErrback(exception_on_error) + deferred.callback(('result', None)) + + if exception: + raise Exception(exception) + + mock_queue.assert_called_once_with(node_id=self.key_id, msg=mock.ANY) + + def test_start_handshake_nonce_errback(self, *_): + deferred = Deferred() + self.server.resource_manager.add_file.return_value = deferred + ts = mock.Mock(TaskSession) + self.server.sessions[self.key_id] = ts + self.server.start_handshake( + key_id=self.key_id, + task_id=self.task_id, + ) + + exception = False + + def exception_on_error(error): + nonlocal exception + exception = error + + deferred.addErrback(exception_on_error) + deferred.errback(('result', None)) + + if exception: + raise Exception(exception) + + ts._handshake_error.assert_called_once_with(self.key_id, mock.ANY) + + @mock.patch( + "golem.task.server.resources." + "TaskResourcesMixin._share_handshake_nonce", + ) @mock.patch( "golem.resource.resourcehandshake.ResourceHandshake.start", side_effect=RuntimeError("Intentional error"), diff --git a/tests/golem/task/test_concent_logic.py b/tests/golem/task/test_concent_logic.py index da5c5dfaa7..d780e97685 100644 --- a/tests/golem/task/test_concent_logic.py +++ b/tests/golem/task/test_concent_logic.py @@ -50,13 +50,19 @@ def setUp(self): self.msg.want_to_compute_task.sign_message(self.keys.raw_privkey) # noqa pylint: disable=no-member self.msg.generate_ethsig(self.requestor_keys.raw_privkey) self.msg.sign_promissory_note(self.requestor_keys.raw_privkey) + self.ethereum_config = EthereumConfig() self.msg.sign_concent_promissory_note( deposit_contract_address=getattr( - EthereumConfig, 'deposit_contract_address'), + self.ethereum_config, 'deposit_contract_address'), private_key=self.requestor_keys.raw_privkey ) self.msg.sign_message(self.requestor_keys.raw_privkey) # noqa go home pylint, you're drunk pylint: disable=no-value-for-parameter self.task_session = tasksession.TaskSession(mock.MagicMock()) + + self.task_session.task_server.client\ + .transaction_system.deposit_contract_address = \ + self.ethereum_config.deposit_contract_address + self.task_session.concent_service.enabled = True self.task_session.task_computer.has_assigned_task.return_value = False self.task_session.task_server.keys_auth.ecc.raw_pubkey = \ @@ -211,7 +217,7 @@ def test_bad_promissory_note_sig(self, send_mock, *_): def test_bad_concent_promissory_note_sig(self, send_mock, *_): self.msg.sign_concent_promissory_note( deposit_contract_address=getattr( - EthereumConfig, 'deposit_contract_address'), + self.ethereum_config, 'deposit_contract_address'), private_key=self.different_keys.raw_privkey ) self.task_session._react_to_task_to_compute(self.msg) @@ -449,7 +455,6 @@ def test_provider_without_concent_requestor_with_concent( self.assert_allowed(send_mock) def test_concent_disabled_wtct_concent_flag_none(self, send_mock): - task_manager = self.task_session.task_manager self.msg.concent_enabled = None task_session = self.task_session task_session.concent_service.enabled = False @@ -468,6 +473,11 @@ def test_concent_disabled_wtct_concent_flag_none(self, send_mock): task_manager.tasks = {ctd['task_id']: task} task_manager.tasks_states = {ctd['task_id']: task_state} + class X: + pass + + task_session.task_server.get_share_options.return_value = X() + with mock.patch( 'golem.task.tasksession.taskkeeper.compute_subtask_value', mock.Mock(return_value=667), diff --git a/tests/golem/task/test_rpc.py b/tests/golem/task/test_rpc.py index f06b9ec510..fa0eac4ae6 100644 --- a/tests/golem/task/test_rpc.py +++ b/tests/golem/task/test_rpc.py @@ -8,7 +8,7 @@ import faker from ethereum.utils import denoms from golem_messages.factories.datastructures import p2p as dt_p2p_factory -from mock import Mock +from mock import call, Mock from twisted.internet import defer from apps.dummy.task import dummytaskstate @@ -57,7 +57,8 @@ class ProviderBase(test_client.TestClientBase): 'concent_enabled': False, } - def setUp(self): + @mock.patch('golem.task.taskserver.NonHypervisedDockerCPUEnvironment') + def setUp(self, _): super().setUp() self.client.sync = mock.Mock() self.client.p2pservice = mock.Mock(peers={}) @@ -121,7 +122,11 @@ def test_create_task(self, *_): t = dummytaskstate.DummyTaskDefinition() t.name = "test" - result = self.provider.create_task(t.to_dict()) + def execute(f, *args, **kwargs): + return defer.succeed(f(*args, **kwargs)) + + with mock.patch('golem.core.deferred.deferToThread', execute): + result = self.provider.create_task(t.to_dict()) rpc.enqueue_new_task.assert_called() self.assertEqual(result, ('task_id', None)) @@ -189,8 +194,8 @@ def test_validate_lock_funds_possibility_raises_if_not_enough_funds(self): with self.assertRaises(exceptions.NotEnoughFunds) as e: client_provider._validate_lock_funds_possibility( - total_price_gnt=required_gnt, - number_of_tasks=1 + subtask_price=required_gnt, + subtask_count=1 ) expected = f'Not enough funds available.\n' \ f'Required GNT: {required_gnt / denoms.ether:f}, ' \ @@ -204,8 +209,10 @@ class TestRestartTask(ProviderBase): @mock.patch('os.path.getsize', return_value=123) @mock.patch('golem.network.concent.client.ConcentClientService.start') @mock.patch('golem.client.SystemMonitor') + @mock.patch('golem.task.rpc.ClientProvider.' + '_validate_enough_funds_to_pay_for_task') @mock.patch('golem.client.P2PService.connect_to_network') - def test_restart_task(self, connect_to_network, *_): + def test_restart_task(self, connect_to_network, mock_validate_funds, *_): self.client.apps_manager.load_all_apps() deferred = defer.Deferred() @@ -255,9 +262,17 @@ def add_resources(*_args, **_kwargs): task = self.client.task_manager.create_task(task_dict) golem_deferred.sync_wait(rpc.enqueue_new_task(self.client, task)) - with mock.patch('golem.task.rpc.enqueue_new_task') as enq_mock: + with mock.patch('golem.task.rpc._prepare_task') as prep_mock: new_task_id, error = self.provider.restart_task(task.header.task_id) - enq_mock.assert_called_once() + prep_mock.assert_called_once() + + mock_validate_funds.assert_called_once_with( + task.subtask_price, + task.get_total_tasks(), + task.header.concent_enabled, + False + ) + assert new_task_id assert not error @@ -448,7 +463,8 @@ def _run(_self: tasktester.TaskTester): _self.success_callback(result, estimated_memory, time_spent, **more) with mock.patch('golem.task.tasktester.TaskTester.run', _run): - golem_deferred.sync_wait(rpc._run_test_task(self.client, {})) + golem_deferred.sync_wait(rpc._run_test_task(self.client, + {'name': 'test task'})) self.assertIsInstance(self.client.task_test_result, dict) self.assertEqual(self.client.task_test_result, { @@ -469,7 +485,8 @@ def _run(_self: tasktester.TaskTester): _self.error_callback(*error, **more) with mock.patch('golem.client.TaskTester.run', _run): - golem_deferred.sync_wait(rpc._run_test_task(self.client, {})) + golem_deferred.sync_wait(rpc._run_test_task(self.client, + {'name': 'test task'})) self.assertIsInstance(self.client.task_test_result, dict) self.assertEqual(self.client.task_test_result, { @@ -490,6 +507,7 @@ def test_run_test_task_params(self, *_): 'type': 'blender', 'resources': ['_.blend'], 'subtasks_count': 1, + 'name': 'test task', })) @@ -532,7 +550,6 @@ def test_computed_subtasks(self, calculate_mock, *_): @mock.patch('os.path.getsize') @mock.patch('golem.task.taskmanager.TaskManager.dump_task') -@mock.patch("golem.task.rpc._restart_subtasks") class TestRestartSubtasks(ProviderBase): def setUp(self): super().setUp() @@ -542,21 +559,148 @@ def setUp(self): rpc.enqueue_new_task(self.client, self.task), ) - def test_empty(self, restart_mock, *_): - force = fake.pybool() - self.provider.restart_subtasks_from_task( - task_id=self.task.header.task_id, + self.task_id = self.task.header.task_id + self.subtask_ids = ['subtask-id-1', 'subtask-id-2'] + self.provider.task_manager.subtask2task_mapping = \ + {sub_id: self.task_id for sub_id in self.subtask_ids} + + @mock.patch('golem.task.rpc.ClientProvider.' + '_validate_lock_funds_possibility') + @mock.patch('golem.task.rpc.enqueue_new_task') + @mock.patch('golem.task.taskstate.TaskStatus.is_active', return_value=False) + @mock.patch('golem.task.rpc._restart_subtasks') + def test_empty_subtasks_list(self, restart_subtasks_mock, *_): + ignore_gas_price = fake.pybool() + disable_concent = fake.pybool() + + self.task.subtasks_given = { + 'finished-subtask-id': {'status': taskstate.SubtaskStatus.finished}, + 'failed-subtask-id-1': {'status': taskstate.SubtaskStatus.failure}, + 'failed-subtask-id-2': {'status': taskstate.SubtaskStatus.failure} + } + self.task.get_total_tasks = lambda: len(self.task.subtasks_given) + + self.provider.restart_subtasks( + task_id=self.task_id, subtask_ids=[], - force=force, + ignore_gas_price=ignore_gas_price, + disable_concent=disable_concent, + ) + + restart_subtasks_mock.assert_called_once_with( + client=self.client, + old_task_id=self.task_id, + task_dict=mock.ANY, + subtask_ids_to_copy={'finished-subtask-id'}, + ignore_gas_price=ignore_gas_price + ) + + @mock.patch('golem.task.taskstate.TaskStatus.is_active', return_value=True) + @mock.patch('golem.client.Client.restart_subtask') + @mock.patch('golem.task.rpc.ClientProvider.' + '_validate_enough_funds_to_pay_for_task') + def test_task_active(self, validate_funds_mock, restart_mock, *_): + ignore_gas_price = fake.pybool() + disable_concent = fake.pybool() + + self.provider.restart_subtasks( + task_id=self.task_id, + subtask_ids=self.subtask_ids, + ignore_gas_price=ignore_gas_price, + disable_concent=disable_concent, + ) + + validate_funds_mock.assert_called_once_with( + self.task.subtask_price, + len(self.subtask_ids), + self.task.header.concent_enabled, + ignore_gas_price + ) + + restart_mock.assert_has_calls( + map(lambda subtask_id: call(subtask_id), self.subtask_ids) + ) + + @mock.patch('golem.task.taskstate.TaskStatus.is_active', return_value=False) + @mock.patch('golem.task.rpc._restart_subtasks') + @mock.patch('golem.task.rpc.ClientProvider.' + '_validate_enough_funds_to_pay_for_task') + def test_task_inactive(self, validate_funds_mock, restart_mock, *_): + ignore_gas_price = fake.pybool() + disable_concent = fake.pybool() + self.task.subtasks_given = { + 'subtask-id-1': {'status': taskstate.SubtaskStatus.finished}, + 'subtask-id-2': {'status': taskstate.SubtaskStatus.finished}, + 'finished-subtask-id': {'status': taskstate.SubtaskStatus.finished}, + 'failed-subtask-id-1': {'status': taskstate.SubtaskStatus.failure}, + 'failed-subtask-id-2': {'status': taskstate.SubtaskStatus.failure} + } + self.task.get_total_tasks = lambda: len(self.task.subtasks_given) + + self.provider.restart_subtasks( + task_id=self.task_id, + subtask_ids=self.subtask_ids, + ignore_gas_price=ignore_gas_price, + disable_concent=disable_concent, + ) + + validate_funds_mock.assert_called_once_with( + self.task.subtask_price, + # there's one finished subtask which is not in subtasks to restart + len(self.task.subtasks_given) - 1, + self.task.header.concent_enabled, + ignore_gas_price ) + restart_mock.assert_called_once_with( client=self.client, - subtask_ids_to_copy=set(), - old_task_id=self.task.header.task_id, + old_task_id=self.task_id, task_dict=mock.ANY, - force=force, + subtask_ids_to_copy={'finished-subtask-id'}, + ignore_gas_price=ignore_gas_price + ) + + def test_task_unknown(self, *_): + task_id = 'unknown-task-uuid' + + error = self.provider.restart_subtasks( + task_id=task_id, + subtask_ids=self.subtask_ids ) + self.assertIn(task_id, error) + + def test_subtask_mismatch(self, *_): + subtask_ids = ['im-not-from-this-task'] + + error = self.provider.restart_subtasks( + task_id=self.task_id, + subtask_ids=subtask_ids + ) + + self.assertEqual(error, f'Subtask does not belong to the given task.' + f'task_id: {self.task_id}, ' + f'subtask_id: {subtask_ids[0]}') + + @mock.patch('golem.task.rpc.ClientProvider.' + '_validate_enough_funds_to_pay_for_task') + def test_insufficient_funds(self, mock_validate_funds, *_): + mock_validate_funds.side_effect =\ + exceptions.NotEnoughFunds.single_currency( + required=0.1 * denoms.ether, + available=0, + currency='ETH' + ) + + error = self.provider.restart_subtasks( + task_id=self.task.header.task_id, + subtask_ids=self.subtask_ids + ) + + self.assertEqual(error['error_type'], 'NotEnoughFunds') + self.assertIsNotNone(error['error_msg']) + self.assertIsNotNone(error['error_details']) + class TestRestartFrameSubtasks(ProviderBase): def setUp(self): @@ -567,9 +711,8 @@ def setUp(self): rpc.enqueue_new_task(self.client, self.task), ) - @mock.patch('golem.task.rpc.ClientProvider.restart_subtasks_from_task') - @mock.patch('golem.client.Client.restart_subtask') - def test_no_frames(self, mock_restart_single, mock_restart_multiple, *_): + @mock.patch('golem.task.rpc.ClientProvider.restart_subtasks') + def test_no_frames(self, mock_restart, *_): with mock.patch( 'golem.task.taskmanager.TaskManager.get_frame_subtasks', return_value=None @@ -579,70 +722,10 @@ def test_no_frames(self, mock_restart_single, mock_restart_multiple, *_): frame=1 ) - mock_restart_single.assert_not_called() - mock_restart_multiple.assert_not_called() - - @mock.patch('golem.task.taskstate.TaskStatus.is_active', return_value=True) - @mock.patch('golem.task.rpc.ClientProvider.restart_subtasks_from_task') - @mock.patch('golem.client.Client.restart_subtask') - def test_task_active(self, mock_restart_single, mock_restart_multiple, *_): - mock_subtask_id_1 = 'mock-subtask-id-1' - mock_subtask_id_2 = 'mock-subtask-id-2' - mock_frame_subtasks = { - mock_subtask_id_1: Mock(), - mock_subtask_id_2: Mock() - } - - with mock.patch( - 'golem.task.taskmanager.TaskManager.get_frame_subtasks', - return_value=mock_frame_subtasks - ): - self.provider.restart_frame_subtasks( - task_id=self.task.header.task_id, - frame=1 - ) - - mock_restart_multiple.assert_not_called() - mock_restart_single.assert_has_calls( - [mock.call(mock_subtask_id_1), mock.call(mock_subtask_id_2)] - ) - - @mock.patch('golem.task.taskstate.TaskStatus.is_active', return_value=False) - @mock.patch('golem.task.rpc.ClientProvider.restart_subtasks_from_task') - @mock.patch('golem.client.Client.restart_subtask') - def test_task_finished( - self, mock_restart_single, mock_restart_multiple, *_): - mock_subtask_id_1 = 'mock-subtask-id-1' - mock_subtask_id_2 = 'mock-subtask-id-2' - mock_frame_subtasks = frozenset(( - mock_subtask_id_1, - mock_subtask_id_2, - )) - - with mock.patch( - 'golem.task.taskmanager.TaskManager.get_frame_subtasks', - return_value=mock_frame_subtasks - ): - self.provider.restart_frame_subtasks( - task_id=self.task.header.task_id, - frame=1 - ) - - mock_restart_single.assert_not_called() - mock_restart_multiple.assert_called_once_with( - self.task.header.task_id, - mock_frame_subtasks, - ) + mock_restart.assert_not_called() - @mock.patch('golem.task.rpc.ClientProvider.restart_subtasks_from_task') - @mock.patch('golem.client.Client.restart_subtask') - @mock.patch('golem.task.rpc.logger') - def test_task_unknown( - self, - mock_logger, - mock_restart_single, - mock_restart_multiple, - *_): + def test_task_unknown(self, *_): + task_id = 'unknown-task-id' mock_subtask_id_1 = 'mock-subtask-id-1' mock_subtask_id_2 = 'mock-subtask-id-2' mock_frame_subtasks = { @@ -654,14 +737,12 @@ def test_task_unknown( 'golem.task.taskmanager.TaskManager.get_frame_subtasks', return_value=mock_frame_subtasks ): - self.provider.restart_frame_subtasks( - task_id='unknown-task-id', + error = self.provider.restart_frame_subtasks( + task_id=task_id, frame=1 ) - mock_logger.error.assert_called_once() - mock_restart_single.assert_not_called() - mock_restart_multiple.assert_not_called() + self.assertEqual(error, f'Task not found: {task_id!r}') @mock.patch('os.path.getsize') @@ -674,6 +755,7 @@ def setUp(self): rpc.enqueue_new_task(self.client, self.task), ) + @mock.patch('twisted.internet.reactor', mock.Mock()) @mock.patch("golem.task.rpc.prepare_and_validate_task_dict") def test_create_task(self, mock_method, *_): t = dummytaskstate.DummyTaskDefinition() @@ -715,6 +797,10 @@ def setUp(self): ts.eth_for_batch_payment.return_value = 10000 ts.eth_for_deposit.return_value = 20000 + self.task_id = 'task-uuid' + self.task = Mock() + self.provider.task_manager.tasks[self.task_id] = self.task + def test_basic(self, *_): subtasks = 5 @@ -746,15 +832,12 @@ def test_basic(self, *_): self.transaction_system.eth_for_deposit.assert_called_once_with() def test_full_restart(self, *_): - task_id = 'task-uuid' - mock_task = Mock() - mock_task.get_total_tasks.return_value = 10 - mock_task.subtask_price = 1 - self.provider.task_manager.tasks[task_id] = mock_task + self.task.get_total_tasks.return_value = 10 + self.task.subtask_price = 1 result, error = self.provider.get_estimated_cost( "task_type", - task_id=task_id + task_id=self.task_id ) self.assertIsNone(error) @@ -772,15 +855,12 @@ def test_full_restart(self, *_): ) def test_partial_restart(self, *_): - task_id = 'task-uuid' - mock_task = Mock() - mock_task.get_tasks_left.return_value = 2 - mock_task.subtask_price = 2 - self.provider.task_manager.tasks[task_id] = mock_task + self.task.get_tasks_left.return_value = 2 + self.task.subtask_price = 2 result, error = self.provider.get_estimated_cost( 'task_type', - task_id=task_id, + task_id=self.task_id, partial=True ) @@ -820,6 +900,124 @@ def test_no_parameters(self, *_): ) +class TestGetEstimatedSubtasksCost(ProviderBase): + def setUp(self): + super().setUp() + self.transaction_system = ts = self.client.transaction_system + ts.eth_for_batch_payment.return_value = 10000 + ts.eth_for_deposit.return_value = 20000 + + self.task_id = 'mock-task-uuid' + self.task = Mock() + self.task.subtask_price = 2 + self.provider.task_manager.tasks[self.task_id] = self.task + + self.subtask_ids = ['subtask-uuid-1', 'subtask-uuid-2'] + self.provider.task_manager.subtask2task_mapping = \ + {sub_id: self.task_id for sub_id in self.subtask_ids} + + @mock.patch('golem.task.taskmanager.TaskManager.task_finished', + return_value=False) + def test_active_task(self, *_): + result, error = self.provider.get_estimated_subtasks_cost( + task_id=self.task_id, + subtask_ids=self.subtask_ids + ) + + self.assertIsNone(error) + self.assertEqual( + result, + { + 'GNT': '4', + 'ETH': '10000', + 'deposit': { + 'GNT_required': '8', + 'GNT_suggested': '16', + 'ETH': '20000', + }, + }, + ) + + @mock.patch('golem.task.taskmanager.TaskManager.task_finished', + return_value=True) + def test_finished_task(self, *_): + subtasks_given = { + 'failed-subtask-uuid': {'status': taskstate.SubtaskStatus.failure} + } + self.provider.task_manager.subtask2task_mapping = \ + {sub_id: self.task_id for sub_id in self.subtask_ids} + self.task.subtasks_given = subtasks_given + + result, error = self.provider.get_estimated_subtasks_cost( + task_id=self.task_id, + subtask_ids=self.subtask_ids + ) + + self.assertIsNone(error) + self.assertEqual( + result, + { + 'GNT': '6', + 'ETH': '10000', + 'deposit': { + 'GNT_required': '12', + 'GNT_suggested': '24', + 'ETH': '20000', + }, + }, + ) + + def test_subtask_mismatch(self, *_): + subtask_ids = ['im-not-from-this-task'] + + result, error = self.provider.get_estimated_subtasks_cost( + task_id=self.task_id, + subtask_ids=subtask_ids + ) + + self.assertIsNone(result) + self.assertEqual(error, f'Subtask does not belong to the given task.' + f'task_id: {self.task_id}, ' + f'subtask_id: {subtask_ids[0]}') + + def test_task_not_found(self, *_): + task_id = 'task-which-doesnt-exist' + + result, error = self.provider.get_estimated_subtasks_cost( + task_id=task_id, + subtask_ids=self.subtask_ids + ) + + self.assertIsNone(result) + self.assertEqual(error, f'Task not found: {task_id}') + + @mock.patch('golem.task.taskmanager.TaskManager.task_finished', + return_value=False) + def test_removing_duplicate_subtasks(self, *_): + subtask_ids = ['subtask-uuid-1', 'subtask-uuid-1', 'subtask-uuid-2'] + self.provider.task_manager.subtask2task_mapping = \ + {sub_id: self.task_id for sub_id in subtask_ids} + + result, error = self.provider.get_estimated_subtasks_cost( + task_id=self.task_id, + subtask_ids=subtask_ids + ) + + self.assertIsNone(error) + self.assertEqual( + result, + { + 'GNT': '4', + 'ETH': '10000', + 'deposit': { + 'GNT_required': '8', + 'GNT_suggested': '16', + 'ETH': '20000', + }, + }, + ) + + @mock.patch('golem.task.taskmanager.TaskManager.get_subtask_dict', return_value=Mock()) class TestGetFragments(ProviderBase): diff --git a/tests/golem/task/test_taskcomputer.py b/tests/golem/task/test_taskcomputer.py index 0cb7be53a2..be94b7281b 100644 --- a/tests/golem/task/test_taskcomputer.py +++ b/tests/golem/task/test_taskcomputer.py @@ -1,17 +1,20 @@ import os import random +from pathlib import Path from threading import Lock import time import unittest.mock as mock import uuid from golem_messages.message import ComputeTaskDef +from twisted.internet.defer import Deferred from golem.client import ClientTaskComputerEventListener from golem.clientconfigdescriptor import ClientConfigDescriptor from golem.core.common import timeout_to_deadline from golem.core.deferred import sync_wait from golem.docker.manager import DockerManager +from golem.envs.docker.cpu import DockerCPUConfig, DockerCPUEnvironment from golem.task.taskcomputer import TaskComputer, PyTaskThread from golem.testutils import DatabaseFixture from golem.tools.ci import ci_skip @@ -19,76 +22,45 @@ from golem.tools.os_info import OSInfo -@ci_skip -class TestTaskComputer(DatabaseFixture, LogTestCase): +class TestTaskComputerBase(DatabaseFixture, LogTestCase): + + @mock.patch('golem.task.taskcomputer.TaskComputer.change_docker_config') + @mock.patch('golem.task.taskcomputer.DockerManager') + def setUp(self, docker_manager, _): + super().setUp() # pylint: disable=arguments-differ - def setUp(self): - super(TestTaskComputer, self).setUp() task_server = mock.MagicMock() task_server.benchmark_manager.benchmarks_needed.return_value = False task_server.get_task_computer_root.return_value = self.path task_server.config_desc = ClientConfigDescriptor() - self.task_server = task_server - def test_init(self): - task_server = self.task_server - tc = TaskComputer(task_server, use_docker_manager=False) - self.assertIsInstance(tc, TaskComputer) - - def test_run(self): - task_server = self.task_server - task_server.config_desc.task_request_interval = 0.5 - task_server.config_desc.accept_tasks = True - task_server.get_task_computer_root.return_value = self.path - tc = TaskComputer(task_server, use_docker_manager=False) - self.assertIsNone(tc.counting_thread) - tc.last_task_request = 0 - tc.run() - task_server.request_task.assert_called_with() - task_server.request_task = mock.MagicMock() - task_server.config_desc.accept_tasks = False - tc2 = TaskComputer(task_server, use_docker_manager=False) - tc2.counting_thread = None - tc2.last_task_request = 0 + self.docker_cpu_env = mock.Mock(spec=DockerCPUEnvironment) + self.docker_manager = mock.Mock(spec=DockerManager, hypervisor=None) + docker_manager.install.return_value = self.docker_manager - tc2.run() - task_server.request_task.assert_not_called() - - tc2.runnable = True - tc2.compute_tasks = True - - tc2.last_task_request = 0 - tc2.counting_thread = None - - tc2.run() - - assert task_server.request_task.called - - task_server.request_task.called = False - - tc2.last_checking = 10 ** 10 - - tc2.run() - - def test_resource_failure(self): - task_server = self.task_server + self.task_computer = TaskComputer( + self.task_server, + self.docker_cpu_env) - tc = TaskComputer(task_server, use_docker_manager=False) + self.docker_manager.reset_mock() + self.docker_cpu_env.reset_mock() - task_id = 'xyz' - subtask_id = 'xxyyzz' - tc.resource_failure(task_id, 'reason') - assert not task_server.send_task_failed.called +@ci_skip +class TestTaskComputer(TestTaskComputerBase): - tc.assigned_subtask = ComputeTaskDef( - task_id=task_id, - subtask_id=subtask_id, - ) + def test_init(self): + tc = TaskComputer( + self.task_server, + self.docker_cpu_env, + use_docker_manager=False) + self.assertIsInstance(tc, TaskComputer) - tc.resource_failure(task_id, 'reason') - assert task_server.send_task_failed.called + def test_check_timeout(self): + self.task_computer.counting_thread = mock.Mock() + self.task_computer.check_timeout() + self.task_computer.counting_thread.check_timeout.assert_called_once() def test_computation(self): # pylint: disable=too-many-statements # FIXME Refactor too single tests and remove disable too many @@ -117,18 +89,19 @@ def test_computation(self): # pylint: disable=too-many-statements } mock_finished = mock.Mock() - tc = TaskComputer(task_server, use_docker_manager=False, - finished_cb=mock_finished) + tc = TaskComputer( + task_server, + self.docker_cpu_env, + use_docker_manager=False, + finished_cb=mock_finished) self.assertEqual(tc.assigned_subtask, None) tc.task_given(ctd) self.assertEqual(tc.assigned_subtask, ctd) self.assertLessEqual(tc.assigned_subtask['deadline'], timeout_to_deadline(10)) - tc.task_server.request_resource.assert_called_with( - "xyz", "xxyyzz", ["abcd", "efgh"]) - assert tc.resource_collected("xyz") + tc.start_computation() assert tc.counting_thread is None assert tc.assigned_subtask is None task_server.send_task_failed.assert_called_with( @@ -136,7 +109,7 @@ def test_computation(self): # pylint: disable=too-many-statements tc.support_direct_computation = True tc.task_given(ctd) - assert tc.resource_collected("xyz") + tc.start_computation() assert tc.counting_thread is not None self.assertGreater(tc.counting_thread.time_to_compute, 8) self.assertLessEqual(tc.counting_thread.time_to_compute, 10) @@ -163,9 +136,7 @@ def test_computation(self): # pylint: disable=too-many-statements self.assertEqual(tc.assigned_subtask, ctd) self.assertLessEqual(tc.assigned_subtask['deadline'], timeout_to_deadline(5)) - tc.task_server.request_resource.assert_called_with( - "xyz", "aabbcc", ["abcd", "efgh"]) - self.assertTrue(tc.resource_collected("xyz")) + tc.start_computation() self.__wait_for_tasks(tc) self.assertIsNone(tc.counting_thread) @@ -179,7 +150,7 @@ def test_computation(self): # pylint: disable=too-many-statements ctd['extra_data']['src_code'] = "print('Hello world')" ctd['deadline'] = timeout_to_deadline(5) tc.task_given(ctd) - self.assertTrue(tc.resource_collected("xyz")) + tc.start_computation() self.__wait_for_tasks(tc) task_server.send_task_failed.assert_called_with( @@ -193,7 +164,7 @@ def test_computation(self): # pylint: disable=too-many-statements ctd['extra_data']['src_code'] = "output={'data': 0, 'result_type': 0}" ctd['deadline'] = timeout_to_deadline(40) tc.task_given(ctd) - self.assertTrue(tc.resource_collected("xyz")) + tc.start_computation() self.assertIsNotNone(tc.counting_thread) self.assertGreater(tc.counting_thread.time_to_compute, 10) self.assertLessEqual(tc.counting_thread.time_to_compute, 20) @@ -202,7 +173,7 @@ def test_computation(self): # pylint: disable=too-many-statements ctd['subtask_id'] = "xxyyzz2" ctd['deadline'] = timeout_to_deadline(1) tc.task_given(ctd) - self.assertTrue(tc.resource_collected("xyz")) + tc.start_computation() mock_finished.assert_called_once_with() mock_finished.reset_mock() tt = tc.counting_thread @@ -219,41 +190,22 @@ def test_computation(self): # pylint: disable=too-many-statements def test_host_state(self): task_server = self.task_server - tc = TaskComputer(task_server, use_docker_manager=False) + tc = TaskComputer( + task_server, + self.docker_cpu_env, + use_docker_manager=False) self.assertEqual(tc.get_host_state(), "Idle") tc.counting_thread = mock.Mock() self.assertEqual(tc.get_host_state(), "Computing") - def test_change_config(self): - task_server = self.task_server - - tc = TaskComputer(task_server, use_docker_manager=False) - tc.docker_manager = mock.Mock(spec=DockerManager, hypervisor=None) - - tc.use_docker_manager = False - tc.change_config(mock.Mock(), in_background=False) - assert not tc.docker_manager.update_config.called - - tc.use_docker_manager = True - - def _update_config(status_callback, *_, **__): - status_callback() - tc.docker_manager.update_config = _update_config - - tc.change_config(mock.Mock(), in_background=False) - - # pylint: disable=unused-argument - def _update_config_2(status_callback, done_callback, *_, **__): - done_callback(False) - tc.docker_manager.update_config = _update_config_2 - - tc.change_config(mock.Mock(), in_background=False) - def test_event_listeners(self): client = mock.Mock() task_server = self.task_server - tc = TaskComputer(task_server, use_docker_manager=False) + tc = TaskComputer( + task_server, + self.docker_cpu_env, + use_docker_manager=False) tc.lock_config(True) tc.lock_config(False) @@ -273,7 +225,7 @@ def test_compute_task(self, start): task_id = str(uuid.uuid4()) subtask_id = str(uuid.uuid4()) task_computer = mock.Mock() - compute_task = TaskComputer._TaskComputer__compute_task # pylint: disable=no-member + compute_task = TaskComputer.start_computation dir_manager = task_computer.dir_manager dir_manager.get_task_resource_dir.return_value = self.tempdir + '_res' @@ -285,25 +237,21 @@ def test_compute_task(self, start): task_computer.assigned_subtask = ComputeTaskDef( task_id=task_id, subtask_id=subtask_id, + docker_images=[], + extra_data=mock.Mock(), + deadline=time.time() + 3600 ) task_computer.task_server.task_keeper.task_headers = { task_id: None } - args = (task_computer, subtask_id) - kwargs = dict( - docker_images=[], - extra_data=mock.Mock(), - subtask_deadline=time.time() + 3600 - ) - - compute_task(*args, **kwargs) + compute_task(task_computer) assert not start.called header = mock.Mock(deadline=time.time() + 3600) task_computer.task_server.task_keeper.task_headers[task_id] = header - compute_task(*args, **kwargs) + compute_task(task_computer) assert start.called @staticmethod @@ -314,7 +262,10 @@ def __wait_for_tasks(tc): print('counting thread is None') def test_get_environment_no_assigned_subtask(self): - tc = TaskComputer(self.task_server, use_docker_manager=False) + tc = TaskComputer( + self.task_server, + self.docker_cpu_env, + use_docker_manager=False) assert tc.get_environment() is None def test_get_environment(self): @@ -325,7 +276,10 @@ def test_get_environment(self): ) } - tc = TaskComputer(task_server, use_docker_manager=False) + tc = TaskComputer( + task_server, + self.docker_cpu_env, + use_docker_manager=False) tc.assigned_subtask = ComputeTaskDef() tc.assigned_subtask['task_id'] = "task_id" assert tc.get_environment() == "env" @@ -333,13 +287,22 @@ def test_get_environment(self): @ci_skip class TestTaskThread(DatabaseFixture): + + @mock.patch( + 'golem.envs.docker.cpu.deferToThread', + lambda f, *args, **kwargs: f(*args, **kwargs)) def test_thread(self): ts = mock.MagicMock() ts.config_desc = ClientConfigDescriptor() + ts.config_desc.max_memory_size = 1024 * 1024 # 1 GiB + ts.config_desc.num_cores = 1 ts.benchmark_manager.benchmarks_needed.return_value = False ts.get_task_computer_root.return_value = self.new_path - tc = TaskComputer(ts, use_docker_manager=False) + tc = TaskComputer( + ts, + mock.Mock(spec=DockerCPUEnvironment), + use_docker_manager=False) tt = self._new_task_thread(tc) sync_wait(tt.start()) @@ -379,6 +342,7 @@ def _new_task_thread(self, task_computer): class TestTaskMonitor(DatabaseFixture): + def test_task_computed(self): """golem.monitor signal""" from golem.monitor.model.nodemetadatamodel import NodeMetadataModel @@ -400,10 +364,15 @@ def test_task_computed(self): MONITOR_CONFIG) task_server = mock.MagicMock() task_server.config_desc = ClientConfigDescriptor() + task_server.config_desc.max_memory_size = 1024 * 1024 # 1 GiB + task_server.config_desc.num_cores = 1 task_server.benchmark_manager.benchmarks_needed.return_value = False task_server.get_task_computer_root.return_value = self.new_path - task = TaskComputer(task_server, use_docker_manager=False) + task = TaskComputer( + task_server, + mock.Mock(spec=DockerCPUEnvironment), + use_docker_manager=False) task_thread = mock.MagicMock() task_thread.start_time = time.time() @@ -450,3 +419,257 @@ def check(expected): prepare() task_thread.result = None check(False) + + +@mock.patch('golem.task.taskcomputer.TaskComputer.change_docker_config') +class TestChangeConfig(TestTaskComputerBase): + + @mock.patch('golem.task.taskcomputer.TaskComputer.change_docker_config') + @mock.patch('golem.task.taskcomputer.DockerManager') + def setUp(self, *_): + super().setUp() + self.task_computer = TaskComputer( + self.task_server, + self.docker_cpu_env) + + def test_root_path(self, change_docker_config): + self.task_server.get_task_computer_root.return_value = '/test' + config_desc = ClientConfigDescriptor() + self.task_computer.change_config(config_desc) + self.assertEqual(self.task_computer.dir_manager.root_path, '/test') + change_docker_config.assert_called_once_with( + config_desc=config_desc, + work_dir=Path('/test'), + run_benchmarks=False, + in_background=True + ) + + def _test_compute_tasks(self, accept_tasks, in_shutdown, expected): + config_desc = ClientConfigDescriptor() + config_desc.accept_tasks = accept_tasks + config_desc.in_shutdown = in_shutdown + self.task_computer.change_config(config_desc) + self.assertEqual(self.task_computer.compute_tasks, expected) + + def test_compute_tasks(self, _): + self._test_compute_tasks( + accept_tasks=True, + in_shutdown=True, + expected=False + ) + self._test_compute_tasks( + accept_tasks=True, + in_shutdown=False, + expected=True + ) + self._test_compute_tasks( + accept_tasks=False, + in_shutdown=True, + expected=False + ) + self._test_compute_tasks( + accept_tasks=False, + in_shutdown=False, + expected=False + ) + + def test_not_in_background(self, change_docker_config): + config_desc = ClientConfigDescriptor() + self.task_computer.change_config(config_desc, in_background=False) + change_docker_config.assert_called_once_with( + config_desc=config_desc, + work_dir=mock.ANY, + run_benchmarks=False, + in_background=False + ) + + def test_run_benchmarks(self, change_docker_config): + config_desc = ClientConfigDescriptor() + self.task_computer.change_config(config_desc, run_benchmarks=True) + change_docker_config.assert_called_once_with( + config_desc=config_desc, + work_dir=mock.ANY, + run_benchmarks=True, + in_background=True + ) + + +@mock.patch('golem.task.taskcomputer.ProviderTimer') +class TestTaskGiven(TestTaskComputerBase): + + def test_ok(self, provider_timer): + ctd = mock.Mock() + self.task_computer.task_given(ctd) + self.assertEqual(self.task_computer.assigned_subtask, ctd) + provider_timer.start.assert_called_once_with() + + def test_already_assigned(self, provider_timer): + self.task_computer.assigned_subtask = mock.Mock() + ctd = mock.Mock() + with self.assertRaises(AssertionError): + self.task_computer.task_given(ctd) + provider_timer.start.assert_not_called() + + +class TestChangeDockerConfig(TestTaskComputerBase): + + def test_docket_cpu_env_update(self): + # Given + config_desc = ClientConfigDescriptor() + config_desc.num_cores = 3 + config_desc.max_memory_size = 3000 * 1024 + work_dir = Path('/test') + + # When + self.task_computer.change_docker_config( + config_desc=config_desc, + work_dir=work_dir, + run_benchmarks=False + ) + + # Then + self.docker_cpu_env.clean_up.assert_called_once_with() + self.docker_cpu_env.update_config.assert_called_once_with( + DockerCPUConfig( + work_dir=work_dir, + cpu_count=3, + memory_mb=3000 + )) + self.docker_cpu_env.prepare.assert_called_once_with() + + def test_no_hypervisor_no_benchmark(self): + # Given + config_desc = ClientConfigDescriptor() + work_dir = Path('/test') + + # When + result = self.task_computer.change_docker_config( + config_desc=config_desc, + work_dir=work_dir, + run_benchmarks=False + ) + + # Then + self.assertIsInstance(result, Deferred) + self.docker_manager.build_config.assert_called_once_with(config_desc) + self.docker_manager.update_config.assert_not_called() + self.task_server.benchmark_manager.run_all_benchmarks \ + .assert_not_called() + + def test_no_hypervisor_run_benchmark(self): + # Given + config_desc = ClientConfigDescriptor() + work_dir = Path('/test') + + # When + result = self.task_computer.change_docker_config( + config_desc=config_desc, + work_dir=work_dir, + run_benchmarks=True + ) + + # Then + self.assertIsInstance(result, Deferred) + self.docker_manager.build_config.assert_called_once_with(config_desc) + self.docker_manager.update_config.assert_not_called() + self.task_server.benchmark_manager.run_all_benchmarks\ + .assert_called_once() + + @mock.patch('golem.task.taskcomputer.TaskComputer.lock_config') + def test_with_hypervisor(self, lock_config): + # Given + self.docker_manager.hypervisor = mock.Mock() + config_desc = ClientConfigDescriptor() + work_dir = Path('/test') + + # When + result = self.task_computer.change_docker_config( + config_desc=config_desc, + work_dir=work_dir, + run_benchmarks=False + ) + + # Then + self.assertIsInstance(result, Deferred) + self.docker_manager.build_config.assert_called_once_with(config_desc) + lock_config.assert_called_once_with(True) + self.assertFalse(self.task_computer.runnable) + + self.docker_manager.update_config.assert_called_once() + _, kwargs = self.docker_manager.update_config.call_args + self.assertEqual(kwargs.get('work_dir'), work_dir) + self.assertEqual(kwargs.get('in_background'), True) + + # Check status callback + status_callback = kwargs.get('status_callback') + with mock.patch.object(self.task_computer, 'is_computing') as is_comp: + is_comp.return_value = True + self.assertTrue(status_callback()) + is_comp.assert_called_once() + + # Check done callback -- variant 1: config does not differ + done_callback = kwargs.get('done_callback') + lock_config.reset_mock() + with mock.patch.object(result, 'callback') as result_callback: + done_callback(False) + self.task_server.benchmark_manager.run_all_benchmarks\ + .assert_not_called() + result_callback.assert_called_once_with('Benchmarks not executed') + lock_config.assert_called_once_with(False) + self.assertTrue(self.task_computer.runnable) + + # Check done callback -- variant 1: config does differ + done_callback = kwargs.get('done_callback') + lock_config.reset_mock() + self.task_computer.runnable = False + done_callback(True) + self.task_server.benchmark_manager.run_all_benchmarks \ + .assert_called_once() + lock_config.assert_called_once_with(False) + self.assertTrue(self.task_computer.runnable) + + +class TestTaskInterrupted(TestTaskComputerBase): + + def test_no_task_assigned(self): + with self.assertRaises(AssertionError): + self.task_computer.task_interrupted() + + @mock.patch('golem.task.taskcomputer.TaskComputer._task_finished') + def test_ok(self, task_finished): + self.task_computer.assigned_subtask = mock.Mock() + self.task_computer.task_interrupted() + task_finished.assert_called_once_with() + + +class TestTaskFinished(TestTaskComputerBase): + + def test_no_assigned_subtask(self): + with self.assertRaises(AssertionError): + self.task_computer._task_finished() + + @mock.patch('golem.task.taskcomputer.dispatcher') + @mock.patch('golem.task.taskcomputer.ProviderTimer') + def test_ok(self, provider_timer, dispatcher): + ctd = ComputeTaskDef( + task_id='test_task', + subtask_id='test_subtask', + performance=123 + ) + self.task_computer.assigned_subtask = ctd + self.task_computer.counting_thread = mock.Mock() + self.task_computer.finished_cb = mock.Mock() + + self.task_computer._task_finished() + self.assertIsNone(self.task_computer.assigned_subtask) + self.assertIsNone(self.task_computer.counting_thread) + provider_timer.finish.assert_called_once_with() + dispatcher.send.assert_called_once_with( + signal='golem.taskcomputer', + event='subtask_finished', + subtask_id=ctd['subtask_id'], + min_performance=ctd['performance'] + ) + self.task_server.task_keeper.task_ended.assert_called_once_with( + ctd['task_id']) + self.task_computer.finished_cb.assert_called_once_with() diff --git a/tests/golem/task/test_taskkeeper.py b/tests/golem/task/test_taskkeeper.py index 11e71ece2c..666d20f6cf 100644 --- a/tests/golem/task/test_taskkeeper.py +++ b/tests/golem/task/test_taskkeeper.py @@ -13,12 +13,16 @@ from golem_messages.datastructures.masking import Mask from golem_messages.factories.datastructures import p2p as dt_p2p_factory from golem_messages.message import ComputeTaskDef +from twisted.internet.defer import inlineCallbacks, Deferred +from twisted.trial.unittest import TestCase as TwistedTestCase import golem from golem.core.common import get_timestamp_utc, timeout_to_deadline from golem.environments.environment import Environment, UnsupportReason,\ SupportStatus -from golem.environments.environmentsmanager import EnvironmentsManager +from golem.environments.environmentsmanager import \ + EnvironmentsManager as OldEnvManager +from golem.envs.manager import EnvironmentManager as NewEnvManager from golem.network.hyperdrive.client import HyperdriveClient from golem.task import taskkeeper from golem.task.taskkeeper import TaskHeaderKeeper, CompTaskKeeper, logger @@ -41,62 +45,23 @@ def async_run(request, success=None, error=None): class TestTaskHeaderKeeper(LogTestCase): def test_init(self): tk = TaskHeaderKeeper( - environments_manager=EnvironmentsManager(), + old_env_manager=OldEnvManager(), + new_env_manager=NewEnvManager(), node=dt_p2p_factory.Node(), min_price=10.0) self.assertIsInstance(tk, TaskHeaderKeeper) - def test_is_supported(self): - em = EnvironmentsManager() - em.environments = {} - em.support_statuses = {} - - tk = TaskHeaderKeeper( - environments_manager=EnvironmentsManager(), - node=dt_p2p_factory.Node(), - min_price=10.0) - - header = get_task_header() - header.environment = None - header.max_price = None - header.min_version = None - self.assertFalse(tk.check_support(header)) - - header.environment = Environment.get_id() - header.max_price = 0 - supported = tk.check_support(header) - self.assertFalse(supported) - self.assertIn(UnsupportReason.ENVIRONMENT_MISSING, supported.desc) - - e = Environment() - e.accept_tasks = True - tk.environments_manager.add_environment(e) - supported = tk.check_support(header) - self.assertFalse(supported) - self.assertIn(UnsupportReason.MAX_PRICE, supported.desc) - - header.max_price = 10.0 - self.assertTrue(tk.check_support(header)) - - config_desc = mock.Mock() - config_desc.min_price = 13.0 - tk.change_config(config_desc) - self.assertFalse(tk.check_support(header)) - - config_desc.min_price = 10.0 - tk.change_config(config_desc) - self.assertTrue(tk.check_support(header)) - @mock.patch('golem.task.taskarchiver.TaskArchiver') def test_change_config(self, tar): tk = TaskHeaderKeeper( - environments_manager=EnvironmentsManager(), + old_env_manager=OldEnvManager(), + new_env_manager=NewEnvManager(), node=dt_p2p_factory.Node(), min_price=10.0, task_archiver=tar) e = Environment() e.accept_tasks = True - tk.environments_manager.add_environment(e) + tk.old_env_manager.add_environment(e) task_header = get_task_header() task_id = task_header.task_id @@ -137,12 +102,14 @@ def test_change_config(self, tar): task_id2, SupportStatus(True, {})) def test_get_task(self): - em = EnvironmentsManager() - em.environments = {} - em.support_statuses = {} + old_env_manager = OldEnvManager() + # This is necessary because OldEnvManager is a singleton + old_env_manager.environments = {} + old_env_manager.support_statuses = {} tk = TaskHeaderKeeper( - environments_manager=em, + old_env_manager=old_env_manager, + new_env_manager=NewEnvManager(), node=dt_p2p_factory.Node(), min_price=10) @@ -152,7 +119,7 @@ def test_get_task(self): self.assertIsNone(tk.get_task()) e = Environment() e.accept_tasks = True - tk.environments_manager.add_environment(e) + tk.old_env_manager.add_environment(e) task_header2 = get_task_header("xyz") self.assertTrue(tk.add_task_header(task_header2)) th = tk.get_task() @@ -161,12 +128,13 @@ def test_get_task(self): @freeze_time(as_arg=True) def test_old_tasks(frozen_time, _): # pylint: disable=no-self-argument tk = TaskHeaderKeeper( - environments_manager=EnvironmentsManager(), + old_env_manager=OldEnvManager(), + new_env_manager=NewEnvManager(), node=dt_p2p_factory.Node(), min_price=10) e = Environment() e.accept_tasks = True - tk.environments_manager.add_environment(e) + tk.old_env_manager.add_environment(e) task_header = get_task_header() task_header.deadline = timeout_to_deadline(10) assert tk.add_task_header(task_header) @@ -196,11 +164,12 @@ def test_task_header_update_stats(self, tar): e = Environment() e.accept_tasks = True tk = TaskHeaderKeeper( - environments_manager=EnvironmentsManager(), + old_env_manager=OldEnvManager(), + new_env_manager=NewEnvManager(), node=dt_p2p_factory.Node(), min_price=10, task_archiver=tar) - tk.environments_manager.add_environment(e) + tk.old_env_manager.add_environment(e) task_header = get_task_header("good") assert tk.add_task_header(task_header) tar.add_task.assert_called_with(mock.ANY) @@ -220,7 +189,8 @@ def test_task_header_update_stats(self, tar): @freeze_time(as_arg=True) def test_task_limit(frozen_time, self): # pylint: disable=no-self-argument tk = TaskHeaderKeeper( - environments_manager=EnvironmentsManager(), + old_env_manager=OldEnvManager(), + new_env_manager=NewEnvManager(), node=dt_p2p_factory.Node(), min_price=10) limit = tk.max_tasks_per_requestor @@ -234,7 +204,6 @@ def test_task_limit(frozen_time, self): # pylint: disable=no-self-argument thd = get_task_header("ta") ids.append(thd.task_id) tk.add_task_header(thd) - last_add_time = time.time() for id_ in ids: self.assertIn(id_, tk.task_headers) @@ -248,9 +217,7 @@ def test_task_limit(frozen_time, self): # pylint: disable=no-self-argument self.assertIn(tb_id, tk.task_headers) - while time.time() == last_add_time: - frozen_time.tick( # pylint: disable=no-member - delta=timedelta(milliseconds=100)) + frozen_time.tick(timedelta(seconds=0.1)) # pylint: disable=no-member thd = get_task_header("ta") new_task_id = thd.task_id @@ -261,8 +228,7 @@ def test_task_limit(frozen_time, self): # pylint: disable=no-self-argument self.assertIn(id_, tk.task_headers) self.assertIn(tb_id, tk.task_headers) - frozen_time.tick( # pylint: disable=no-member - delta=timedelta(milliseconds=100)) + frozen_time.tick(timedelta(seconds=0.1)) # pylint: disable=no-member tk.remove_old_tasks() thd = get_task_header("ta") @@ -275,9 +241,13 @@ def test_task_limit(frozen_time, self): # pylint: disable=no-self-argument self.assertIn(ids[i], tk.task_headers) self.assertIn(tb_id, tk.task_headers) - def test_check_max_tasks_per_owner(self): + @freeze_time(as_arg=True) + # pylint: disable=no-self-argument + def test_check_max_tasks_per_owner(freezer, self): + tk = TaskHeaderKeeper( - environments_manager=EnvironmentsManager(), + old_env_manager=OldEnvManager(), + new_env_manager=NewEnvManager(), node=dt_p2p_factory.Node(), min_price=10, max_tasks_per_requestor=10) @@ -289,18 +259,22 @@ def test_check_max_tasks_per_owner(self): thd = get_task_header("ta") ids.append(thd.task_id) tk.add_task_header(thd) - last_add_time = time.time() + + freezer.tick(timedelta(seconds=0.1)) # pylint: disable=no-member thd = get_task_header("tb0") tb0_id = thd.task_id tk.add_task_header(thd) - for id_ in ids: - self.assertIn(id_, tk.task_headers) - self.assertIn(tb0_id, tk.task_headers) + freezer.tick(timedelta(seconds=0.1)) # pylint: disable=no-member - while time.time() == last_add_time: - time.sleep(0.1) + def _assert_headers(ids_, len_): + ids_.append(tb0_id) + for id_ in ids_: + self.assertIn(id_, tk.task_headers) + self.assertEqual(len_, len(tk.task_headers)) + + _assert_headers(ids, len(ids) + 1) new_ids = [] for _ in range(new_limit, limit): @@ -308,37 +282,60 @@ def test_check_max_tasks_per_owner(self): new_ids.append(thd.task_id) tk.add_task_header(thd) - for id_ in ids + new_ids: - self.assertIn(id_, tk.task_headers) - self.assertIn(tb0_id, tk.task_headers) - self.assertEqual(limit + 1, len(tk.task_headers)) + freezer.tick(timedelta(seconds=0.1)) # pylint: disable=no-member + + _assert_headers(ids + new_ids, limit + 1) # shouldn't remove any tasks tk.check_max_tasks_per_owner(thd.task_owner.key) - for id_ in ids + new_ids: - self.assertIn(id_, tk.task_headers) - self.assertIn(tb0_id, tk.task_headers) - self.assertEqual(limit + 1, len(tk.task_headers)) + _assert_headers(ids + new_ids, limit + 1) + + # Test if it skips a running task + running_task_id = ids[0] + tk.task_started(running_task_id) + assert running_task_id in tk.running_tasks + tk.max_tasks_per_requestor = tk.max_tasks_per_requestor - 1 + # shouldn't remove any tasks + tk.check_max_tasks_per_owner(thd.task_owner.key) + + _assert_headers(ids + new_ids, limit + 1) + + # finish the task, restore state + tk.task_ended(running_task_id) + assert running_task_id not in tk.running_tasks tk.max_tasks_per_requestor = new_limit # should remove ta{3..9} tk.check_max_tasks_per_owner(thd.task_owner.key) - for id_ in ids: - self.assertIn(id_, tk.task_headers) - self.assertIn(tb0_id, tk.task_headers) - self.assertEqual(new_limit + 1, len(tk.task_headers)) + _assert_headers(ids, new_limit + 1) + + # Test if it skips a running task + running_task_id = ids[2] + tk.task_started(running_task_id) + assert running_task_id in tk.running_tasks + tk.max_tasks_per_requestor = 1 + # shouldn't remove running_task_id + tk.check_max_tasks_per_owner(thd.task_owner.key) + + # Should keep 0 and 2, since 2 is running + _assert_headers([ids[0], ids[2]], 3) + + # finish the task, restore state + tk.task_ended(running_task_id) + assert running_task_id not in tk.running_tasks def test_get_unsupport_reasons(self): tk = TaskHeaderKeeper( - environments_manager=EnvironmentsManager(), + old_env_manager=OldEnvManager(), + new_env_manager=NewEnvManager(), node=dt_p2p_factory.Node(), min_price=10) e = Environment() e.accept_tasks = True - tk.environments_manager.add_environment(e) + tk.old_env_manager.add_environment(e) # Supported task thd = get_task_header("good") @@ -383,7 +380,8 @@ def test_get_unsupport_reasons(self): def test_get_owner(self): tk = TaskHeaderKeeper( - environments_manager=EnvironmentsManager(), + old_env_manager=OldEnvManager(), + new_env_manager=NewEnvManager(), node=dt_p2p_factory.Node(), min_price=10) header = get_task_header() @@ -417,8 +415,9 @@ def get_dict_task_header(key_id_seed="kkk"): } -def get_task_header(key_id_seed="kkk"): +def get_task_header(key_id_seed="kkk", **kwargs): th_dict_repr = get_dict_task_header(key_id_seed=key_id_seed) + th_dict_repr.update(kwargs) return dt_tasks.TaskHeader(**th_dict_repr) @@ -437,7 +436,7 @@ def _dump_some_tasks(self, tasks_dir): test_headers = [] test_subtasks_ids = [] - for x in range(10): + for _ in range(10): header = get_task_header() header.deadline = timeout_to_deadline(1) header.subtask_timeout = 3 @@ -670,3 +669,341 @@ def test_resources_options(self): res = ctk.get_resources_options(subtask_id) assert isinstance(res, dict) assert res['client_id'] == HyperdriveClient.CLIENT_ID + + +class TestTaskHeaderKeeperBase(TwistedTestCase): + + def setUp(self) -> None: + super().setUp() + self.old_env_manager = mock.Mock(spec=OldEnvManager) + self.new_env_manager = mock.Mock(spec=NewEnvManager) + self.keeper = TaskHeaderKeeper( + old_env_manager=self.old_env_manager, + new_env_manager=self.new_env_manager, + node=dt_p2p_factory.Node() + ) + + def _patch_keeper(self, method): + patch = mock.patch(f'golem.task.taskkeeper.TaskHeaderKeeper.{method}') + self.addCleanup(patch.stop) + return patch.start() + + +class TestCheckSupport(TestTaskHeaderKeeperBase): + + def setUp(self) -> None: + super().setUp() + self.check_new_env = self._patch_keeper('_check_new_environment') + self.check_old_env = self._patch_keeper('_check_old_environment') + self.check_mask = self._patch_keeper('check_mask') + self.check_price = self._patch_keeper('check_price') + + @inlineCallbacks + def test_new_env_unsupported(self): + # Given + status = SupportStatus.err({ + UnsupportReason.ENVIRONMENT_UNSUPPORTED: 'test_env' + }) + self.check_new_env.return_value = Deferred() + self.check_new_env.return_value.callback(status) + self.check_mask.return_value = SupportStatus.ok() + self.check_price.return_value = SupportStatus.ok() + + # When + header = get_task_header( + environment="test_env", + environment_prerequisites={'key': 'value'} + ) + result = yield self.keeper.check_support(header) + + # Then + self.assertEqual(result, status) + self.check_new_env.assert_called_once_with( + header.environment, header.environment_prerequisites) + self.check_old_env.assert_not_called() + self.check_mask.assert_called_once_with(header) + self.check_price.assert_called_once_with(header) + + @inlineCallbacks + def test_new_env_ok(self): + # Given + status = SupportStatus.ok() + self.check_new_env.return_value = Deferred() + self.check_new_env.return_value.callback(status) + self.check_mask.return_value = SupportStatus.ok() + self.check_price.return_value = SupportStatus.ok() + + # When + header = get_task_header( + environment="test_env", + environment_prerequisites={'key': 'value'} + ) + result = yield self.keeper.check_support(header) + + # Then + self.assertEqual(result, status) + self.check_new_env.assert_called_once_with( + header.environment, header.environment_prerequisites) + self.check_old_env.assert_not_called() + self.check_mask.assert_called_once_with(header) + self.check_price.assert_called_once_with(header) + + @inlineCallbacks + def test_old_env_unsupported(self): + # Given + status = SupportStatus.err({ + UnsupportReason.ENVIRONMENT_UNSUPPORTED: 'test_env' + }) + self.check_old_env.return_value = status + self.check_mask.return_value = SupportStatus.ok() + self.check_price.return_value = SupportStatus.ok() + + # When + header = get_task_header(environment="test_env") + result = yield self.keeper.check_support(header) + + # Then + self.assertEqual(result, status) + self.check_new_env.assert_not_called() + self.check_old_env.assert_called_once_with(header.environment) + self.check_mask.assert_called_once_with(header) + self.check_price.assert_called_once_with(header) + + @inlineCallbacks + def test_old_env_ok(self): + # Given + status = SupportStatus.ok() + self.check_old_env.return_value = status + self.check_mask.return_value = SupportStatus.ok() + self.check_price.return_value = SupportStatus.ok() + + # When + header = get_task_header(environment="test_env") + result = yield self.keeper.check_support(header) + + # Then + self.assertEqual(result, status) + self.check_new_env.assert_not_called() + self.check_old_env.assert_called_once_with(header.environment) + self.check_mask.assert_called_once_with(header) + self.check_price.assert_called_once_with(header) + + @inlineCallbacks + def test_mask_mismatch(self): + # Given + status = SupportStatus.err({ + UnsupportReason.MASK_MISMATCH: '0xdeadbeef' + }) + self.check_old_env.return_value = SupportStatus.ok() + self.check_mask.return_value = status + self.check_price.return_value = SupportStatus.ok() + + # When + header = get_task_header(environment="test_env") + result = yield self.keeper.check_support(header) + + # Then + self.assertEqual(result, status) + self.check_new_env.assert_not_called() + self.check_old_env.assert_called_once_with(header.environment) + self.check_mask.assert_called_once_with(header) + self.check_price.assert_called_once_with(header) + + @inlineCallbacks + def test_price_too_low(self): + # Given + status = SupportStatus.err({ + UnsupportReason.MAX_PRICE: 10 + }) + self.check_old_env.return_value = SupportStatus.ok() + self.check_mask.return_value = SupportStatus.ok() + self.check_price.return_value = status + + # When + header = get_task_header(environment="test_env") + result = yield self.keeper.check_support(header) + + # Then + self.assertEqual(result, status) + self.check_new_env.assert_not_called() + self.check_old_env.assert_called_once_with(header.environment) + self.check_mask.assert_called_once_with(header) + self.check_price.assert_called_once_with(header) + + @inlineCallbacks + def test_all_wrong(self): + # Given + env_status = SupportStatus.err({ + UnsupportReason.ENVIRONMENT_MISSING: "test_env" + }) + mask_status = SupportStatus.err({ + UnsupportReason.MASK_MISMATCH: '0xdeadbeef' + }) + price_status = SupportStatus.err({ + UnsupportReason.MAX_PRICE: 10 + }) + self.check_old_env.return_value = env_status + self.check_mask.return_value = mask_status + self.check_price.return_value = price_status + + # When + header = get_task_header(environment="new_env") + result = yield self.keeper.check_support(header) + + # Then + self.assertEqual( + result, env_status.join(mask_status).join(price_status)) + self.check_new_env.assert_not_called() + self.check_old_env.assert_called_once_with(header.environment) + self.check_mask.assert_called_once_with(header) + self.check_price.assert_called_once_with(header) + + +class TestCheckOldEnvironment(TestTaskHeaderKeeperBase): + + def test_ok(self): + # Given + self.old_env_manager.accept_tasks.return_value = True + self.old_env_manager.get_support_status.return_value = \ + SupportStatus.ok() + + # When + env_id = "test_env" + result = self.keeper._check_old_environment(env_id) + + # Then + self.assertEqual(result, SupportStatus.ok()) + self.old_env_manager.accept_tasks.assert_called_once_with(env_id) + self.old_env_manager.get_support_status.assert_called_once_with(env_id) + + def test_not_accepting_tasks(self): + # Given + self.old_env_manager.accept_tasks.return_value = False + self.old_env_manager.get_support_status.return_value = \ + SupportStatus.ok() + + # When + env_id = "test_env" + result = self.keeper._check_old_environment(env_id) + + # Then + self.assertEqual(result, SupportStatus.err({ + UnsupportReason.ENVIRONMENT_NOT_ACCEPTING_TASKS: env_id + })) + self.old_env_manager.accept_tasks.assert_called_once_with(env_id) + self.old_env_manager.get_support_status.assert_called_once_with(env_id) + + def test_env_unsupported(self): + # Given + env_id = "test_env" + status = SupportStatus.err({ + UnsupportReason.ENVIRONMENT_UNSUPPORTED: env_id + }) + self.old_env_manager.accept_tasks.return_value = True + self.old_env_manager.get_support_status.return_value = status + + # When + result = self.keeper._check_old_environment(env_id) + + # Then + self.assertEqual(result, status) + self.old_env_manager.accept_tasks.assert_called_once_with(env_id) + self.old_env_manager.get_support_status.assert_called_once_with(env_id) + + def test_env_unsupported_and_not_accepting_tasks(self): + # Given + env_id = "test_env" + status = SupportStatus.err({ + UnsupportReason.ENVIRONMENT_UNSUPPORTED: env_id + }) + self.old_env_manager.accept_tasks.return_value = False + self.old_env_manager.get_support_status.return_value = status + + # When + result = self.keeper._check_old_environment(env_id) + + # Then + self.assertEqual(result, status.join(SupportStatus.err({ + UnsupportReason.ENVIRONMENT_NOT_ACCEPTING_TASKS: env_id + }))) + self.old_env_manager.accept_tasks.assert_called_once_with(env_id) + self.old_env_manager.get_support_status.assert_called_once_with(env_id) + + +class TestCheckNewEnvironment(TestTaskHeaderKeeperBase): + + @inlineCallbacks + def test_env_missing(self): + # Given + self.new_env_manager.environment.side_effect = KeyError("test") + + # When + env_id = "test_env" + result = yield self.keeper._check_new_environment(env_id, {}) + + # Then + self.assertEqual(result, SupportStatus.err({ + UnsupportReason.ENVIRONMENT_MISSING: env_id + })) + self.new_env_manager.environment.assert_called_once_with(env_id) + + @inlineCallbacks + def test_prerequisites_parsing_error(self): + # Given + env = self.new_env_manager.environment.return_value + env.parse_prerequisites.side_effect = ValueError("test") + + # When + env_id = "test_env" + prereqs_dict = {"key": "value"} + result = yield self.keeper._check_new_environment(env_id, prereqs_dict) + + # Then + self.assertEqual(result, SupportStatus.err({ + UnsupportReason.ENVIRONMENT_UNSUPPORTED: env_id + })) + self.new_env_manager.environment.assert_called_once_with(env_id) + env.parse_prerequisites.assert_called_once_with(prereqs_dict) + env.install_prerequisites.assert_not_called() + + @inlineCallbacks + def test_prerequisites_installation_error(self): + # Given + install_result = Deferred() + install_result.callback(False) # False means installation failed + env = self.new_env_manager.environment.return_value + env.install_prerequisites.return_value = install_result + + # When + env_id = "test_env" + prereqs_dict = {"key": "value"} + result = yield self.keeper._check_new_environment(env_id, prereqs_dict) + + # Then + self.assertEqual(result, SupportStatus.err({ + UnsupportReason.ENVIRONMENT_UNSUPPORTED: env_id + })) + self.new_env_manager.environment.assert_called_once_with(env_id) + env.parse_prerequisites.assert_called_once_with(prereqs_dict) + env.install_prerequisites.assert_called_once_with( + env.parse_prerequisites()) + + @inlineCallbacks + def test_ok(self): + # Given + install_result = Deferred() + install_result.callback(True) # True means installation succeeded + env = self.new_env_manager.environment.return_value + env.install_prerequisites.return_value = install_result + + # When + env_id = "test_env" + prereqs_dict = {"key": "value"} + result = yield self.keeper._check_new_environment(env_id, prereqs_dict) + + # Then + self.assertEqual(result, SupportStatus.ok()) + self.new_env_manager.environment.assert_called_once_with(env_id) + env.parse_prerequisites.assert_called_once_with(prereqs_dict) + env.install_prerequisites.assert_called_once_with( + env.parse_prerequisites()) diff --git a/tests/golem/task/test_taskmanager.py b/tests/golem/task/test_taskmanager.py index f5d5cde3a1..ccfcb91000 100644 --- a/tests/golem/task/test_taskmanager.py +++ b/tests/golem/task/test_taskmanager.py @@ -47,6 +47,7 @@ from golem.tools.testwithreactor import TestDatabaseWithReactor from tests.factories.task import taskstate as taskstate_factory +from tests.factories.model import CachedNode as CachedNodeFactory fake = Faker() @@ -194,6 +195,8 @@ def _get_test_dummy_task(self, task_id): dtb = DummyTaskBuilder(dt_p2p_factory.Node(node_name="MyNode"), tdd, dm) dummy_task = dtb.build() + dummy_task.initialize(dtb.dir_manager) + header = self._get_task_header(task_id=task_id, timeout=120, subtask_timeout=120) dummy_task.header = header @@ -260,7 +263,7 @@ def test_got_wants_to_compute(self, *_): self.tm.add_new_task(task_mock) (handler, checker) = self._connect_signal_handler() - self.tm.got_wants_to_compute("xyz", "1234", "a name") + self.tm.got_wants_to_compute("xyz") checker([("xyz", None, TaskOp.WORK_OFFER_RECEIVED)]) del handler @@ -268,7 +271,7 @@ def test_get_next_subtask_not_my_task(self, *_): wrong_task = not self.tm.is_my_task("xyz") subtask = self.tm.get_next_subtask( - "DEF", "DEF", "xyz", 1000, 10, 5, 10, "10.10.10.10") + "DEF", "xyz", 1000, 10, 5, 10) assert subtask is None assert wrong_task @@ -282,7 +285,7 @@ def test_get_next_subtask_wait_for_node(self, *_): assert self.tm.is_my_task("xyz") subtask = self.tm.get_next_subtask( - "DEF", "DEF", "xyz", 1000, 10, 5, 10, "10.10.10.10") + "DEF", "xyz", 1000, 10, 5, 10) assert subtask is None @@ -298,7 +301,7 @@ def test_get_next_subtask_progress_completed(self, *_): wrong_task = not self.tm.is_my_task("xyz") assert not wrong_task subtask = self.tm.get_next_subtask( - "DEF", "DEF", "xyz", 1000, 10, 5, 10, "10.10.10.10") + "DEF", "xyz", 1000, 10, 5, 10) assert subtask is None @@ -312,62 +315,72 @@ def test_get_next_subtask(self, *_): (handler, checker) = self._connect_signal_handler() wrong_task = not self.tm.is_my_task("xyz") + + cached_node = CachedNodeFactory() + cached_node.save() + subtask = self.tm.get_next_subtask( - "DEF", "DEF", "xyz", 1000, 10, 5, 10, "10.10.10.10") + cached_node.node, "xyz", 1000, 10, 5, 10) assert subtask is not None assert not wrong_task checker([("xyz", subtask['subtask_id'], SubtaskOp.ASSIGNED)]) del handler - self.tm.tasks_states["xyz"].status = self.tm.activeStatus[0] + task_state = self.tm.tasks_states["xyz"] + self.assertEqual( + task_state.subtask_states[subtask['subtask_id']].node_name, + cached_node.node_field.node_name + ) + + task_state.status = TaskStatus.computing wrong_task = not self.tm.is_my_task("xyz") subtask = self.tm.get_next_subtask( - "DEF", "DEF", "xyz", 1000, 10, 1, 10, "10.10.10.10") + "DEF", "xyz", 1000, 10, 1, 10) assert subtask is None assert not wrong_task wrong_task = not self.tm.is_my_task("xyz") subtask = self.tm.get_next_subtask( - "DEF", "DEF", "xyz", 1000, 10, 5, 2, "10.10.10.10") + "DEF", "xyz", 1000, 10, 5, 2) assert subtask is None assert not wrong_task wrong_task = not self.tm.is_my_task("xyz") subtask = self.tm.get_next_subtask( - "DEF", "DEF", "xyz", 1000, 10, 5, 10, "10.10.10.10") + "DEF", "xyz", 1000, 10, 5, 10) assert subtask is None assert not wrong_task task_mock.query_extra_data_return_value.ctd['subtask_id'] = "xyzxyz" wrong_task = not self.tm.is_my_task("xyz") subtask = self.tm.get_next_subtask( - "DEF", "DEF", "xyz", 1000, 10, 5, 10, "10.10.10.10") + "DEF", "xyz", 1000, 10, 5, 10) self.assertIsInstance(subtask, ComputeTaskDef) assert not wrong_task task_mock.query_extra_data_return_value.ctd['subtask_id'] = "xyzxyz2" wrong_task = not self.tm.is_my_task("xyz") subtask = self.tm.get_next_subtask( - "DEF", "DEF", "xyz", 1000, 20000, 5, 10, "10.10.10.10") + "DEF", "xyz", 1000, 20000, 5, 10) assert subtask is None assert not wrong_task wrong_task = not self.tm.is_my_task("xyz") subtask = self.tm.get_next_subtask( - "DEF", "DEF", "xyz", 1000, 10, 5, 10, "10.10.10.10") + "DEF", "xyz", 1000, 10, 5, 10) assert isinstance(subtask, ComputeTaskDef) assert not wrong_task del self.tm.subtask2task_mapping["xyzxyz2"] wrong_task = not self.tm.is_my_task("xyz") subtask = self.tm.get_next_subtask( - "DEF", "DEF", "xyz", 1000, 10, 5, 10, "10.10.10.10") + "DEF", "xyz", 1000, 10, 5, 10) assert subtask is None del self.tm.tasks_states["xyz"].subtask_states["xyzxyz2"] wrong_task = not self.tm.is_my_task("xyz") subtask = self.tm.get_next_subtask( - "DEF", "DEF", "xyz", 1000, 10, 5, 10, "10.10.10.10") + "DEF", "xyz", 1000, 10, 5, 10) assert isinstance(subtask, ComputeTaskDef) self.tm.delete_task("xyz") @@ -459,8 +472,8 @@ def accept_client(self, node_id): self.tm.start_task(t.header.task_id) wrong_task = not self.tm.is_my_task("xyz") should_wait = self.tm.should_wait_for_node("xyz", "DEF") - ctd = self.tm.get_next_subtask("DEF", "DEF", "xyz", 1030, 10, 10000, - 10000, 10000) + ctd = self.tm.get_next_subtask("DEF", "xyz", 1030, 10, 10000, + 10000) assert not wrong_task assert ctd['subtask_id'] == "xxyyzz" assert not should_wait @@ -490,8 +503,8 @@ def accept_client(self, node_id): assert progress != {} wrong_task = not self.tm.is_my_task("abc") should_wait = self.tm.should_wait_for_node("abc", "DEF") - ctd = self.tm.get_next_subtask("DEF", "DEF", "abc", 1030, 10, 10000, - 10000, 10000) + ctd = self.tm.get_next_subtask("DEF", "abc", 1030, 10, 10000, + 10000) assert not wrong_task assert ctd['subtask_id'] == "aabbcc" assert not should_wait @@ -516,8 +529,8 @@ def accept_client(self, node_id): self.tm.start_task(t3.header.task_id) wrong_task = not self.tm.is_my_task("qwe") should_wait = self.tm.should_wait_for_node("qwe", "DEF") - ctd = self.tm.get_next_subtask("DEF", "DEF", "qwe", 1030, 10, 10000, - 10000, 10000) + ctd = self.tm.get_next_subtask("DEF", "qwe", 1030, 10, 10000, + 10000) assert not wrong_task assert ctd['subtask_id'] == "qqwwee" (handler, checker) = self._connect_signal_handler() @@ -543,8 +556,7 @@ def accept_client(self, node_id): self.tm.start_task(t2.header.task_id) wrong_task = not self.tm.is_my_task("task4") should_wait = self.tm.should_wait_for_node("task4", "DEF") - ctd = self.tm.get_next_subtask("DEF", "DEF", "task4", 1000, 10, 5, 10, - "10.10.10.10") + ctd = self.tm.get_next_subtask("DEF", "task4", 1000, 10, 5, 10) assert not wrong_task assert ctd['subtask_id'] == "ttt4" (handler, checker) = self._connect_signal_handler() @@ -558,8 +570,7 @@ def accept_client(self, node_id): assert self.tm.verification_finished.call_count == 5 wrong_task = not self.tm.is_my_task("task4") should_wait = self.tm.should_wait_for_node("task4", "DEF") - ctd = self.tm.get_next_subtask("DEF", "DEF", "task4", 1000, 10, 5, 10, - "10.10.10.10") + ctd = self.tm.get_next_subtask("DEF", "task4", 1000, 10, 5, 10) assert not wrong_task assert ctd['subtask_id'] == "sss4" self.tm.computed_task_received("sss4", [], @@ -661,7 +672,7 @@ def test_task_computation_failure(self, *_): self.tm.add_new_task(task_mock) self.tm.start_task(task_mock.header.task_id) task_mock.query_extra_data_return_value.ctd['subtask_id'] = "aabbcc" - self.tm.get_next_subtask("NODE", "NODE", "xyz", 1000, 100, 10000, 10000) + self.tm.get_next_subtask("NODE", "xyz", 1000, 100, 10000, 10000) (handler, checker) = self._connect_signal_handler() assert self.tm.task_computation_failure("aabbcc", "something went wrong") @@ -684,7 +695,7 @@ def test_task_computation_cancelled(self, *_): self.tm.add_new_task(task_mock) self.tm.start_task(task_mock.header.task_id) task_mock.query_extra_data_return_value.ctd['subtask_id'] = "aabbcc" - self.tm.get_next_subtask("NODE", "NODE", "xyz", 1000, 100, 10000, 10000) + self.tm.get_next_subtask("NODE", "xyz", 1000, 100, 10000, 10000) (handler, checker) = self._connect_signal_handler() reason = message.tasks.CannotComputeTask.REASON.WrongCTD assert self.tm.task_computation_cancelled("aabbcc", @@ -711,7 +722,7 @@ def test_task_computation_cancelled_after_timeout(self, *_): self.tm.add_new_task(task_mock) self.tm.start_task(task_mock.header.task_id) task_mock.query_extra_data_return_value.ctd['subtask_id'] = "aabbcc" - self.tm.get_next_subtask("NODE", "NODE", "xyz", 1000, 100, 10000, 10000) + self.tm.get_next_subtask("NODE", "xyz", 1000, 100, 10000, 10000) (handler, checker) = self._connect_signal_handler() reason = message.tasks.CannotComputeTask.REASON.WrongCTD assert self.tm.task_computation_cancelled("aabbcc", @@ -737,7 +748,7 @@ def test_task_computation_cancelled_offer_cancelled(self, *_): self.tm.add_new_task(task_mock) self.tm.start_task(task_mock.header.task_id) task_mock.query_extra_data_return_value.ctd['subtask_id'] = subtask_id - self.tm.get_next_subtask("NODE", "NODE", "xyz", 1000, 100, 10000, 10000) + self.tm.get_next_subtask("NODE", "xyz", 1000, 100, 10000, 10000) self.tm.task_computation_cancelled( subtask_id, reason, @@ -767,15 +778,15 @@ def test_get_subtasks(self, *_): assert self.tm.get_subtasks("xyz") == [] assert self.tm.get_subtasks("TASK 1") == [] - self.tm.get_next_subtask("NODEID", "NODENAME", "xyz", 1000, 100, 10000, + self.tm.get_next_subtask("NODEID", "xyz", 1000, 100, 10000, 10000) - self.tm.get_next_subtask("NODEID", "NODENAME", "TASK 1", 1000, 100, + self.tm.get_next_subtask("NODEID", "TASK 1", 1000, 100, 10000, 10000) task_mock.query_extra_data_return_value.ctd['subtask_id'] = "aabbcc" - self.tm.get_next_subtask("NODEID2", "NODENAME", "xyz", 1000, 100, 10000, + self.tm.get_next_subtask("NODEID2", "xyz", 1000, 100, 10000, 10000) task_mock.query_extra_data_return_value.ctd['subtask_id'] = "ddeeff" - self.tm.get_next_subtask("NODEID3", "NODENAME", "xyz", 1000, 100, 10000, + self.tm.get_next_subtask("NODEID3", "xyz", 1000, 100, 10000, 10000) self.assertEqual(set(self.tm.get_subtasks("xyz")), {"xxyyzz", "aabbcc", "ddeeff"}) @@ -829,7 +840,7 @@ def test_check_timeouts(self, *_): self.tm.start_task(t.header.task_id) self.assertIn( self.tm.tasks_states["xyz"].status, - self.tm.activeStatus, + self.tm.ACTIVE_STATUS, ) with freeze_time(start_time + datetime.timedelta(seconds=2)): self.tm.check_timeouts() @@ -847,8 +858,7 @@ def test_check_timeouts(self, *_): self.tm.add_new_task(t2) self.tm.start_task(t2.header.task_id) self.tm.get_next_subtask( - "ABC", "ABC", "abc", 1000, 10, 5, 10, - "10.10.10.10", + "ABC", "abc", 1000, 10, 5, 10, ) with freeze_time( start_time + datetime.timedelta( @@ -879,8 +889,7 @@ def test_check_timeouts(self, *_): self.tm.add_new_task(t3) self.tm.start_task(t3.header.task_id) self.tm.get_next_subtask( - "ABC", "ABC", "qwe", 1000, 10, 5, 10, - "10.10.10.10", + "ABC", "qwe", 1000, 10, 5, 10, ) with freeze_time( start_time + datetime.timedelta( @@ -1351,7 +1360,7 @@ def test_check_timeouts_removes_output_directory(self, mock_get_dir, *_): self.tm.start_task(task.header.task_id) self.assertIn( self.tm.tasks_states['xyz'].status, - self.tm.activeStatus, + self.tm.ACTIVE_STATUS, ) with freeze_time(start_time + datetime.timedelta(seconds=2)): @@ -1480,7 +1489,6 @@ def verify(_): self.assertEqual(new_subtask_state.stderr, 'stderr') self.assertEqual(new_subtask_state.results, ['result']) - patch.object(self.tm, 'notice_task_updated').start() deferred = self.tm._copy_subtask_results( old_task=old_task, @@ -1543,7 +1551,6 @@ def setUp(self): definition.max_price = 1 * 10 ** 18 definition.resolution = [1920, 1080] definition.resources = [str(uuid.uuid4()) for _ in range(5)] - #definition.output_file = os.path.join(self.tempdir, 'somefile') definition.main_scene_file = dummy_path definition.options.frames = [1] self.task = BlenderRenderTask( @@ -1564,5 +1571,14 @@ def test_task_doesnt_need_computation(self, *_): self.task.last_task = self.task.total_tasks self.assertFalse(self.tm.task_needs_computation(self.task_id)) - def test_needs_computation(self, *_): + def test_needs_computation_while_creating(self, *_): + self.assertFalse(self.tm.task_needs_computation(self.task_id)) + + def test_needs_computation_when_added(self, *_): + keys_auth = Mock() + keys_auth._private_key = b'a' * 32 + keys_auth.sign.return_value = 'sig' + + self.tm.keys_auth = keys_auth + self.tm.add_new_task(self.task) self.assertTrue(self.tm.task_needs_computation(self.task_id)) diff --git a/tests/golem/task/test_taskserver.py b/tests/golem/task/test_taskserver.py index 8909d50c8b..fa0bc56570 100644 --- a/tests/golem/task/test_taskserver.py +++ b/tests/golem/task/test_taskserver.py @@ -1,5 +1,6 @@ # pylint: disable=protected-access, too-many-lines import os +import time from datetime import datetime, timedelta import random import tempfile @@ -10,19 +11,18 @@ from pydispatch import dispatcher import freezegun -from golem_messages import idgenerator from golem_messages import factories as msg_factories from golem_messages.datastructures import tasks as dt_tasks from golem_messages.datastructures.masking import Mask from golem_messages.factories.datastructures import p2p as dt_p2p_factory from golem_messages.message import ComputeTaskDef -from golem_messages.utils import encode_hex as encode_key_id +from golem_messages.utils import encode_hex as encode_key_id, pubkey_to_address from requests import HTTPError from golem import testutils from golem.appconfig import AppConfig from golem.clientconfigdescriptor import ClientConfigDescriptor -from golem.core.common import node_info_str +from golem.core import common from golem.core.keysauth import KeysAuth from golem.environments.environment import SupportStatus, UnsupportReason from golem.network.hyperdrive.client import HyperdriveClientOptions, \ @@ -32,6 +32,7 @@ from golem.resource.hyperdrive.resourcesmanager import HyperdriveResourceManager from golem.task import tasksession from golem.task.acl import DenyReason as AclDenyReason +from golem.task.result.resultmanager import EncryptedResultPackageManager from golem.task.server import concent as server_concent from golem.task.taskbase import AcceptClientVerdict from golem.task.taskserver import ( @@ -40,7 +41,7 @@ WaitingTaskFailure, WaitingTaskResult, ) -from golem.task.taskstate import TaskState, TaskOp +from golem.task.taskstate import TaskState, TaskOp, TaskStatus from golem.tools.assertlogs import LogTestCase from golem.tools.testwithreactor import TestDatabaseWithReactor @@ -106,22 +107,23 @@ def _assert_log_msg(logger_mock, msg): class TaskServerTestBase(LogTestCase, testutils.DatabaseFixture, testutils.TestWithClient): - def setUp(self): - super().setUp() + + @patch('golem.task.taskserver.NonHypervisedDockerCPUEnvironment') + @patch('golem.network.concent.handlers_library.HandlersLibrary' + '.register_handler') + def setUp(self, *_): + super().setUp() # pylint: disable=arguments-differ random.seed() self.ccd = ClientConfigDescriptor() self.ccd.init_from_app_config( AppConfig.load_config(tempfile.mkdtemp(), 'cfg')) self.client.concent_service.enabled = False - with patch( - 'golem.network.concent.handlers_library.HandlersLibrary' - '.register_handler',): - self.ts = TaskServer( - node=dt_p2p_factory.Node(), - config_desc=self.ccd, - client=self.client, - use_docker_manager=False, - ) + self.ts = TaskServer( + node=dt_p2p_factory.Node(), + config_desc=self.ccd, + client=self.client, + use_docker_manager=False, + ) self.ts.resource_manager.storage.get_dir.return_value = self.tempdir def tearDown(self): @@ -140,6 +142,8 @@ class TestTaskServer(TaskServerTestBase): # noqa pylint: disable=too-many-publi '.register_handler', ) @patch('golem.task.taskarchiver.TaskArchiver') + @patch('golem.task.taskserver.NonHypervisedDockerCPUEnvironment') + # pylint: disable=too-many-locals,too-many-statements def test_request(self, tar, *_): ccd = ClientConfigDescriptor() ccd.min_price = 10 @@ -157,7 +161,7 @@ def test_request(self, tar, *_): ts.client.get_suggested_addr.return_value = "10.10.10.10" ts.client.get_requesting_trust.return_value = 0.3 self.assertIsInstance(ts, TaskServer) - self.assertIsNone(ts.request_task()) + self.assertIsNone(ts._request_random_task()) keys_auth = KeysAuth(self.path, 'prv_key', '') task_header = get_example_task_header(keys_auth.public_key) @@ -172,8 +176,9 @@ def test_request(self, tar, *_): handshake.remote_result = True self.ts.get_environment_by_id = Mock(return_value=None) self.ts.get_key_id = Mock(return_value='0'*128) + self.ts.keys_auth.eth_addr = pubkey_to_address('0' * 128) ts.add_task_header(task_header) - self.assertEqual(ts.request_task(), task_id) + self.assertEqual(ts._request_random_task(), task_id) self.assertIn(task_id, ts.requested_tasks) assert ts.remove_task_header(task_id) self.assertNotIn(task_id, ts.requested_tasks) @@ -193,7 +198,7 @@ def test_request(self, tar, *_): task_header = get_example_task_header(keys_auth.public_key) task_id3 = task_header.task_id ts.add_task_header(task_header) - self.assertIsNone(ts.request_task()) + self.assertIsNone(ts._request_random_task()) tar.add_support_status.assert_called_with( task_id3, SupportStatus( @@ -208,7 +213,7 @@ def test_request(self, tar, *_): task_id4 = task_header.task_id task_header.max_price = 1 ts.add_task_header(task_header) - self.assertIsNone(ts.request_task()) + self.assertIsNone(ts._request_random_task()) tar.add_support_status.assert_called_with( task_id4, SupportStatus( @@ -222,7 +227,7 @@ def test_request(self, tar, *_): task_header = get_example_task_header(keys_auth.public_key) task_id5 = task_header.task_id ts.add_task_header(task_header) - self.assertIsNone(ts.request_task()) + self.assertIsNone(ts._request_random_task()) tar.add_support_status.assert_called_with( task_id5, SupportStatus( @@ -238,13 +243,14 @@ def test_request_task_concent_required(self, *_): self.ts.config_desc.min_price = 0 self.ts.client.concent_service.enabled = True self.ts.task_archiver = Mock() + self.ts._last_task_request_time = 0.0 keys_auth = KeysAuth(self.path, 'prv_key', '') task_header = get_example_task_header(keys_auth.public_key) task_header.concent_enabled = False task_header.sign(private_key=keys_auth._private_key) # pylint: disable=no-value-for-parameter self.ts.add_task_header(task_header) - self.assertIsNone(self.ts.request_task()) + self.assertIsNone(self.ts._request_random_task()) self.ts.task_archiver.add_support_status.assert_called_once_with( task_header.task_id, SupportStatus( @@ -253,69 +259,6 @@ def test_request_task_concent_required(self, *_): ), ) - @patch("golem.task.taskserver.Trust") - def test_send_results(self, trust, *_): - self.ts.config_desc.min_price = 11 - keys_auth = KeysAuth(self.path, 'priv_key', '') - task_header = get_example_task_header(keys_auth.public_key) - n = task_header.task_owner - - ts = self.ts - ts._is_address_accessible = Mock(return_value=True) - ts.verify_header_sig = lambda x: True - ts.client.get_suggested_addr.return_value = "10.10.10.10" - ts.client.get_requesting_trust.return_value = ts.max_trust - task_id = task_header.task_id - # pylint: disable=no-member - task_owner_key = task_header.task_owner.key - self.ts.start_handshake( - key_id=task_owner_key, - task_id=task_id, - ) - handshake = self.ts.resource_handshakes[task_owner_key] - handshake.local_result = True - handshake.remote_result = True - self.ts.get_environment_by_id = Mock(return_value=None) - self.ts.get_key_id = Mock(return_value='0'*128) - - fd, result_file = tempfile.mkstemp() - os.close(fd) - results = {"data": [result_file]} - task_header = get_example_task_header(keys_auth.public_key) - task_id = task_header.task_id - assert ts.add_task_header(task_header) - assert ts.request_task() - subtask_id = idgenerator.generate_new_id_from_id(task_id) - subtask_id2 = idgenerator.generate_new_id_from_id(task_id) - ts.send_results(subtask_id, task_id, results) - ts.send_results(subtask_id2, task_id, results) - wtr = ts.results_to_send[subtask_id] - self.assertIsInstance(wtr, WaitingTaskResult) - self.assertEqual(wtr.subtask_id, subtask_id) - self.assertEqual(wtr.result, [result_file]) - self.assertEqual(wtr.last_sending_trial, 0) - self.assertEqual(wtr.delay_time, 0) - self.assertEqual(wtr.owner, n) - self.assertEqual(wtr.already_sending, False) - - self.assertIsNotNone(ts.task_keeper.task_headers.get(task_id)) - - ctd = ComputeTaskDef() - ctd['task_id'] = task_id - ctd['subtask_id'] = subtask_id - ttc = msg_factories.tasks.TaskToComputeFactory(price=1) - ttc.compute_task_def = ctd - ts.task_manager.comp_task_keeper.receive_subtask(ttc) - - prev_call_count = trust.PAYMENT.increase.call_count - ts.increase_trust_payment("xyz", 1) - self.assertGreater(trust.PAYMENT.increase.call_count, prev_call_count) - prev_call_count = trust.PAYMENT.decrease.call_count - ts.decrease_trust_payment("xyz") - self.assertGreater(trust.PAYMENT.decrease.call_count, prev_call_count) - - os.remove(result_file) - def test_change_config(self, *_): ts = self.ts @@ -323,12 +266,9 @@ def test_change_config(self, *_): ccd2.task_session_timeout = 124 ccd2.min_price = 0.0057 ccd2.task_request_interval = 31 - # ccd2.use_waiting_ttl = False ts.change_config(ccd2) self.assertEqual(ts.config_desc, ccd2) self.assertEqual(ts.task_keeper.min_price, 0.0057) - self.assertEqual(ts.task_computer.task_request_frequency, 31) - # self.assertEqual(ts.task_computer.use_waiting_ttl, False) @patch("golem.task.taskserver.TaskServer._sync_pending") def test_sync(self, mock_sync_pending, *_): @@ -417,15 +357,14 @@ def test_should_accept_provider_no_such_task(self, *_args): dispatcher.connect(listener, signal='golem.taskserver') ts = self.ts node_id = "0xdeadbeef" - node_name = "deadbeef" - node_name_id = node_info_str(node_name, node_id) + node_name_id = common.short_node_id(node_id) task_id = "tid" ids = f'provider={node_name_id}, task_id={task_id}' with self.assertLogs(logger, level='INFO') as cm: assert not ts.should_accept_provider( - node_id, "127.0.0.1", node_name, 'tid', 27.18, 1, 1) + node_id, "127.0.0.1", 'tid', 27.18, 1, 1) _assert_log_msg( cm, f'INFO:{logger.name}:Cannot find task in my tasks: {ids}') @@ -454,8 +393,7 @@ def test_should_accept_provider_insufficient_performance(self, *_args): ts = self.ts node_id = "0xdeadbeef" - node_name = "deadbeef" - node_name_id = node_info_str(node_name, node_id) + node_name_id = common.short_node_id(node_id) task = get_mock_task() task_id = task.header.task_id @@ -468,7 +406,7 @@ def test_should_accept_provider_insufficient_performance(self, *_args): with self.assertLogs(logger, level='INFO') as cm: # when accepted = ts.should_accept_provider( - node_id, "127.0.0.1", node_name, task_id, + node_id, "127.0.0.1", task_id, provider_perf, DEFAULT_MAX_MEMORY_SIZE_KB, 1) @@ -499,8 +437,7 @@ def test_should_accept_provider_insufficient_memory_size(self, *_args): ts = self.ts node_id = "0xdeadbeef" - node_name = "deadbeef" - node_name_id = node_info_str(node_name, node_id) + node_name_id = common.short_node_id(node_id) task = get_mock_task(estimated_memory=estimated_memory) task_id = task.header.task_id @@ -513,7 +450,7 @@ def test_should_accept_provider_insufficient_memory_size(self, *_args): with self.assertLogs(logger, level='INFO') as cm: # when accepted = ts.should_accept_provider( - node_id, "127.0.0.1", node_name, task_id, DEFAULT_PROVIDER_PERF, + node_id, "127.0.0.1", task_id, DEFAULT_PROVIDER_PERF, DEFAULT_MAX_RESOURCE_SIZE_KB, DEFAULT_MAX_MEMORY_SIZE_KB) # then @@ -542,8 +479,7 @@ def test_should_accept_provider_insufficient_trust(self, *_args): dispatcher.connect(listener, signal='golem.taskserver') ts = self.ts node_id = "0xdeadbeef" - node_name = "deadbeef" - node_name_id = node_info_str(node_name, node_id) + node_name_id = common.short_node_id(node_id) task = get_mock_task() task_id = task.header.task_id @@ -562,7 +498,7 @@ def test_should_accept_provider_insufficient_trust(self, *_args): ts.config_desc.computing_trust + 0.2 # when/then assert ts.should_accept_provider( - node_id, "127.0.0.1", node_name, task_id, DEFAULT_PROVIDER_PERF, + node_id, "127.0.0.1", task_id, DEFAULT_PROVIDER_PERF, DEFAULT_MAX_RESOURCE_SIZE_KB, DEFAULT_MAX_MEMORY_SIZE_KB) # given @@ -570,7 +506,7 @@ def test_should_accept_provider_insufficient_trust(self, *_args): ts.config_desc.computing_trust # when/then assert ts.should_accept_provider( - node_id, "127.0.0.1", node_name, task_id, DEFAULT_PROVIDER_PERF, + node_id, "127.0.0.1", task_id, DEFAULT_PROVIDER_PERF, DEFAULT_MAX_RESOURCE_SIZE_KB, DEFAULT_MAX_MEMORY_SIZE_KB) # given @@ -579,7 +515,7 @@ def test_should_accept_provider_insufficient_trust(self, *_args): with self.assertLogs(logger, level='INFO') as cm: # when accepted = ts.should_accept_provider( - node_id, "127.0.0.1", node_name, task_id, DEFAULT_PROVIDER_PERF, + node_id, "127.0.0.1", task_id, DEFAULT_PROVIDER_PERF, DEFAULT_MAX_RESOURCE_SIZE_KB, DEFAULT_MAX_MEMORY_SIZE_KB) # then @@ -607,8 +543,7 @@ def test_should_accept_provider_masking(self, *_args): dispatcher.connect(listener, signal='golem.taskserver') ts = self.ts node_id = "0xdeadbeef" - node_name = "deadbeef" - node_name_id = node_info_str(node_name, node_id) + node_name_id = common.short_node_id(node_id) task = get_mock_task() task_id = task.header.task_id @@ -625,7 +560,7 @@ def test_should_accept_provider_masking(self, *_args): with self.assertLogs(logger, level='INFO') as cm: # when accepted = ts.should_accept_provider( - node_id, "127.0.0.1", node_name, task_id, DEFAULT_PROVIDER_PERF, + node_id, "127.0.0.1", task_id, DEFAULT_PROVIDER_PERF, DEFAULT_MAX_RESOURCE_SIZE_KB, DEFAULT_MAX_MEMORY_SIZE_KB) # then @@ -649,7 +584,6 @@ def test_should_accept_provider_rejected(self, *_args): dispatcher.connect(listener, signal='golem.taskserver') ts = self.ts node_id = "0xdeadbeef" - node_name = "deadbeef" task = get_mock_task() task_id = task.header.task_id @@ -664,7 +598,7 @@ def test_should_accept_provider_rejected(self, *_args): with self.assertLogs(logger, level='INFO') as cm: # when accepted = ts.should_accept_provider(node_id, "127.0.0.1", - node_name, task_id, 99, 3, 4) + task_id, 99, 3, 4) # then assert not accepted @@ -692,8 +626,7 @@ def test_should_accept_provider_acl(self, *_args): dispatcher.connect(listener, signal='golem.taskserver') ts = self.ts node_id = "0xdeadbeef" - node_name = "deadbeef" - node_name_id = node_info_str(node_name, node_id) + node_name_id = common.short_node_id(node_id) task = get_mock_task() task_id = task.header.task_id @@ -713,7 +646,6 @@ def test_should_accept_provider_acl(self, *_args): assert not ts.should_accept_provider( node_id=node_id, address="127.0.0.1", - node_name=node_name, task_id=task_id, provider_perf=DEFAULT_PROVIDER_PERF, max_resource_size=DEFAULT_MAX_RESOURCE_SIZE_KB, @@ -737,7 +669,7 @@ def test_should_accept_provider_acl(self, *_args): ts.acl_ip.disallow("127.0.0.1") # then assert not ts.should_accept_provider( - "XYZ", "127.0.0.1", node_name, task_id, DEFAULT_PROVIDER_PERF, + "XYZ", "127.0.0.1", task_id, DEFAULT_PROVIDER_PERF, DEFAULT_MAX_RESOURCE_SIZE_KB, DEFAULT_MAX_MEMORY_SIZE_KB) listener.assert_called_once_with( sender=ANY, @@ -911,6 +843,8 @@ def tearDown(self): def _get_config_desc(self): ccd = ClientConfigDescriptor() ccd.root_path = self.path + ccd.max_memory_size = 1024 * 1024 # 1 GiB + ccd.num_cores = 1 return ccd @@ -937,23 +871,23 @@ def test_results(self, trust, *_): ts.task_manager.keys_auth._private_key = b'a' * 32 ts.task_manager.add_new_task(task_mock) - ts.task_manager.tasks_states[task_id].status = \ - ts.task_manager.activeStatus[0] + ts.task_manager.tasks_states[task_id].status = TaskStatus.computing subtask = ts.task_manager.get_next_subtask( - "DEF", "DEF", task_id, 1000, 10, - 5, 10, - "10.10.10.10") + 5, 10) assert subtask is not None expected_value = ceil(1031 * 1010 / 3600) prev_calls = trust.COMPUTED.increase.call_count ts.accept_result("xxyyzz", "key", "eth_address", expected_value) ts.client.transaction_system.add_payment_info.assert_called_with( - "xxyyzz", - expected_value, - "eth_address") + subtask_id="xxyyzz", + value=expected_value, + eth_address="eth_address", + node_id=task_mock.header.task_owner.key, # pylint: disable=no-member + task_id=task_mock.header.task_id, + ) self.assertGreater(trust.COMPUTED.increase.call_count, prev_calls) def test_disconnect(self, *_): @@ -966,6 +900,7 @@ def test_disconnect(self, *_): # pylint: disable=too-many-ancestors class TestSubtaskWaiting(TaskServerBase): + def test_requested_tasks(self, *_): task_id = str(uuid.uuid4()) subtask_id = str(uuid.uuid4()) @@ -977,7 +912,8 @@ def test_requested_tasks(self, *_): class TestRestoreResources(LogTestCase, testutils.DatabaseFixture, testutils.TestWithClient): - def setUp(self): + @patch('golem.task.taskserver.NonHypervisedDockerCPUEnvironment') + def setUp(self, _): for parent in self.__class__.__bases__: parent.setUp(self) @@ -1125,3 +1061,227 @@ def test_finished_task_listener(self, *_): op=TaskOp.TIMEOUT) assert remove_task.call_count == 2 assert remove_task_funds_lock.call_count == 2 + + +class TestSendResults(TaskServerTestBase): + + def test_wrong_result_format(self): + with self.assertRaises(AttributeError): + self.ts.send_results('subtask_id', 'task_id', {'foo': 'bar'}) + + def test_subtask_already_sent(self): + self.ts.results_to_send['subtask_id'] = Mock(spec=WaitingTaskResult) + with self.assertRaises(RuntimeError): + self.ts.send_results('subtask_id', 'task_id', {'data': 'data'}) + + @patch('golem.task.taskserver.Trust') + def test_ok(self, trust): + result_secret = Mock() + result_hash = Mock() + result_path = Mock() + package_sha1 = Mock() + package_size = Mock() + package_path = Mock() + + result_manager = Mock(spec=EncryptedResultPackageManager) + result_manager.gen_secret.return_value = result_secret + result_manager.create.return_value = ( + result_hash, + result_path, + package_sha1, + package_size, + package_path + ) + + header = MagicMock() + self.ts.task_keeper.task_headers['task_id'] = header + + with patch.object( + self.ts.task_manager, 'task_result_manager', result_manager + ): + self.ts.send_results('subtask_id', 'task_id', {'data': 'data'}) + + result = self.ts.results_to_send.get('subtask_id') + self.assertIsInstance(result, WaitingTaskResult) + self.assertEqual(result.task_id, 'task_id') + self.assertEqual(result.subtask_id, 'subtask_id') + self.assertEqual(result.result, 'data') + self.assertEqual(result.last_sending_trial, 0) + self.assertEqual(result.delay_time, 0) + self.assertEqual(result.owner, header.task_owner) + self.assertEqual(result.result_secret, result_secret) + self.assertEqual(result.result_hash, result_hash) + self.assertEqual(result.result_path, result_path) + self.assertEqual(result.package_sha1, package_sha1) + self.assertEqual(result.result_size, package_size) + self.assertEqual(result.package_path, package_path) + + trust.REQUESTED.increase.assert_called_once_with(header.task_owner.key) + + +@patch('golem.task.taskcomputer.TaskComputer.has_assigned_task') +@patch('golem.task.taskcomputer.TaskComputer.task_given') +@patch('golem.task.taskserver.TaskServer.request_resource') +@patch('golem.task.taskserver.update_requestor_assigned_sum') +@patch('golem.task.taskserver.dispatcher') +@patch('golem.task.taskserver.logger') +class TestTaskGiven(TaskServerTestBase): + # pylint: disable=too-many-arguments + + def test_ok( + self, logger_mock, dispatcher_mock, update_requestor_assigned_sum, + request_resource, task_given, has_assigned_task): + + has_assigned_task.return_value = False + node_id = 'test_node' + task_id = 'test_task' + subtask_id = 'test_subtask' + resources = ['test_resource'] + ctd = ComputeTaskDef( + task_id=task_id, + subtask_id=subtask_id, + resources=resources + ) + price = 123 + + result = self.ts.task_given(node_id, ctd, price) + self.assertEqual(result, True) + + task_given.assert_called_once_with(ctd) + request_resource.assert_called_once_with(task_id, subtask_id, resources) + update_requestor_assigned_sum.assert_called_once_with(node_id, price) + dispatcher_mock.send.assert_called_once_with( + signal='golem.subtask', + event='started', + subtask_id=subtask_id, + price=price + ) + logger_mock.error.assert_not_called() + + def test_already_assigned( + self, logger_mock, dispatcher_mock, update_requestor_assigned_sum, + request_resource, task_given, has_assigned_task): + + has_assigned_task.return_value = True + result = self.ts.task_given('', Mock(), 0) + self.assertEqual(result, False) + + task_given.assert_not_called() + request_resource.assert_not_called() + update_requestor_assigned_sum.assert_not_called() + dispatcher_mock.send.assert_not_called() + logger_mock.error.assert_called() + + +@patch('golem.task.taskserver.logger') +@patch('golem.task.taskcomputer.TaskComputer.start_computation') +class TestResourceCollected(TaskServerTestBase): + + def test_wrong_task_id(self, start_computation, logger_mock): + self.ts.task_computer.assigned_subtask = ComputeTaskDef(task_id='test') + result = self.ts.resource_collected('wrong_id') + self.assertFalse(result) + logger_mock.error.assert_called_once() + start_computation.assert_not_called() + + def test_ok(self, start_computation, logger_mock): + self.ts.task_computer.assigned_subtask = ComputeTaskDef(task_id='test') + result = self.ts.resource_collected('test') + self.assertTrue(result) + logger_mock.error.assert_not_called() + start_computation.assert_called_once_with() + + +@patch('golem.task.taskserver.logger') +@patch('golem.task.taskserver.TaskServer.send_task_failed') +@patch('golem.task.taskcomputer.TaskComputer.task_interrupted') +class TestResourceFailure(TaskServerTestBase): + + def test_wrong_task_id(self, interrupted, send_task_failed, logger_mock): + self.ts.task_computer.assigned_subtask = ComputeTaskDef(task_id='test') + self.ts.resource_failure('wrong_id', 'reason') + logger_mock.error.assert_called_once() + interrupted.assert_not_called() + send_task_failed.assert_not_called() + + def test_ok(self, interrupted, send_task_failed, logger_mock): + self.ts.task_computer.assigned_subtask = ComputeTaskDef( + task_id='test_task', subtask_id='test_subtask' + ) + self.ts.resource_failure('test_task', 'test_reason') + logger_mock.error.assert_not_called() + interrupted.assert_called_once_with() + send_task_failed.assert_called_once_with( + 'test_subtask', + 'test_task', + 'Error downloading resources: test_reason' + ) + + +@freezegun.freeze_time() +class TestRequestRandomTask(TaskServerTestBase): + + def setUp(self): + super().setUp() + self.ts.task_computer = MagicMock() + self.ts.task_keeper = MagicMock() + + def test_request_interval(self): + self.ts.config_desc.task_request_interval = 1.0 + self.ts._last_task_request_time = time.time() + + self.assertIsNone(self.ts._request_random_task()) + + def test_task_already_assigned(self): + self.ts.config_desc.task_request_interval = 1.0 + self.ts._last_task_request_time = time.time() - 1.0 + self.ts.task_computer.has_assigned_task.return_value = True + self.ts.task_computer.compute_tasks = True + self.ts.task_computer.runnable = True + + self.assertIsNone(self.ts._request_random_task()) + + def test_task_computer_not_accepting_tasks(self): + self.ts.config_desc.task_request_interval = 1.0 + self.ts._last_task_request_time = time.time() - 1.0 + self.ts.task_computer.has_assigned_task.return_value = False + self.ts.task_computer.compute_tasks = False + self.ts.task_computer.runnable = True + + self.assertIsNone(self.ts._request_random_task()) + + def test_task_computer_not_runnable(self): + self.ts.config_desc.task_request_interval = 1.0 + self.ts._last_task_request_time = time.time() - 1.0 + self.ts.task_computer.has_assigned_task.return_value = False + self.ts.task_computer.compute_tasks = True + self.ts.task_computer.runnable = False + + self.assertIsNone(self.ts._request_random_task()) + + def test_no_supported_tasks_in_task_keeper(self): + self.ts.config_desc.task_request_interval = 1.0 + self.ts._last_task_request_time = time.time() - 1.0 + self.ts.task_computer.has_assigned_task.return_value = False + self.ts.task_computer.compute_tasks = True + self.ts.task_computer.runnable = True + self.ts.task_keeper.get_task.return_value = None + + self.assertIsNone(self.ts._request_random_task()) + + @patch('golem.task.taskserver.TaskServer._request_task') + def test_ok(self, request_task): + self.ts.config_desc.task_request_interval = 1.0 + self.ts._last_task_request_time = time.time() - 1.0 + self.ts.task_computer.has_assigned_task.return_value = False + self.ts.task_computer.compute_tasks = True + self.ts.task_computer.runnable = True + task_header = Mock() + self.ts.task_keeper.get_task.return_value = task_header + + result = self.ts._request_random_task() + self.assertEqual(result, request_task.return_value) + self.assertEqual(self.ts._last_task_request_time, time.time()) + self.ts.task_computer.stats.increase_stat.assert_called_once_with( + 'tasks_requested') + request_task.assert_called_once_with(task_header) diff --git a/tests/golem/task/test_tasksession.py b/tests/golem/task/test_tasksession.py index 5fc5097968..90e9f61e55 100644 --- a/tests/golem/task/test_tasksession.py +++ b/tests/golem/task/test_tasksession.py @@ -38,6 +38,7 @@ from golem.resource.base.resourceserver import BaseResourceServer from golem.resource.dirmanager import DirManager from golem.resource.hyperdrive.resourcesmanager import HyperdriveResourceManager +from golem.task import taskserver from golem.task import taskstate from golem.task.result.resultpackage import ZipPackager from golem.task.taskkeeper import CompTaskKeeper @@ -121,6 +122,9 @@ def setUp(self): resource_manager=resource_manager, client=server.client ) + self.ethereum_config = EthereumConfig() + self.conn.server.client.transaction_system.deposit_contract_address = \ + EthereumConfig().deposit_contract_address def _get_task_session(self): ts = TaskSession(self.conn) @@ -144,7 +148,6 @@ def _get_requestor_tasksession(self, accept_provider=True): def _get_task_parameters(self): return { - 'node_name': self.node_name, 'perf_index': 1030, 'price': 30, 'max_resource_size': 3, @@ -275,7 +278,7 @@ def _fake_send_ttc(self): ctd = msg_factories.tasks.ComputeTaskDefFactory(task_id=wtct.task_id) ctd["resources"] = self.additional_dir_content([5, [2], [4]]) ctd["deadline"] = timeout_to_deadline(120) - _task_state = self._set_task_state() + self._set_task_state() ts.task_manager.get_next_subtask.return_value = ctd ts.task_manager.should_wait_for_node.return_value = False @@ -321,7 +324,7 @@ def test_request_task(self, *_): ttc._get_promissory_note().sign(self.requestor_keys.raw_privkey)], ['concent_promissory_note_sig', ttc._get_concent_promissory_note( - getattr(EthereumConfig, 'deposit_contract_address') + getattr(self.ethereum_config, 'deposit_contract_address') ).sign( self.requestor_keys.raw_privkey)], ] @@ -336,7 +339,7 @@ def test_task_to_compute_promissory_notes(self): ttc, _, __, ___, ____ = self._fake_send_ttc() self.assertTrue(ttc.verify_promissory_note()) self.assertTrue(ttc.verify_concent_promissory_note( - getattr(EthereumConfig, 'deposit_contract_address') + getattr(self.ethereum_config, 'deposit_contract_address') )) @@ -350,13 +353,14 @@ def setUp(self): super().setUp() random.seed() self.conn = Mock() + self.conn.server.client.transaction_system.deposit_contract_address = \ + EthereumConfig().deposit_contract_address self.task_session = TaskSession(self.conn) self.task_session.key_id = 'deadbeef' self.task_session.task_server.get_share_options.return_value = \ hyperdrive_client.HyperdriveClientOptions('1', 1.0) self.keys = KeysAuth( datadir=self.path, - difficulty=4, private_key_name='prv', password='', ) @@ -365,6 +369,7 @@ def setUp(self): self.task_session.task_manager.task_finished.return_value = False self.pubkey = self.keys.public_key self.privkey = self.keys._private_key + self.ethereum_config = EthereumConfig() class TaskSessionReactToTaskToComputeTest(TaskSessionTestBase): @@ -516,7 +521,7 @@ def test_fail_different_docker_images(self): class TestTaskSession(TaskSessionTestBase): @patch('golem.task.tasksession.TaskSession.send') def test_hello(self, send_mock, *_): - self.task_session.conn.server.get_key_id.return_value = key_id = \ + self.task_session.conn.server.get_key_id.return_value = \ 'key id%d' % (random.random() * 1000,) node = dt_p2p_factory.Node() self.task_session.task_server.client.node = node @@ -524,11 +529,9 @@ def test_hello(self, send_mock, *_): expected = [ ['rand_val', self.task_session.rand_val], ['proto_id', variables.PROTOCOL_CONST.ID], - ['node_name', None], ['node_info', node.to_dict()], ['port', None], ['client_ver', golem.__version__], - ['client_key_id', key_id], ['solve_challenge', None], ['challenge', None], ['difficulty', None], @@ -601,7 +604,7 @@ def concent_deposit(**_): self.assertIsInstance(srv, message.concents.SubtaskResultsVerify) self.assertEqual(srv.subtask_results_rejected, srr) self.assertTrue(srv.verify_concent_promissory_note( - getattr(EthereumConfig, 'deposit_contract_address') + getattr(self.ethereum_config, 'deposit_contract_address') )) @patch('golem.task.taskkeeper.ProviderStatsManager', Mock()) @@ -802,7 +805,6 @@ def setUp(self): self.ts.task_server.get_node_name.return_value = "Zażółć gęślą jaźń" requestor_keys = KeysAuth( datadir=self.path, - difficulty=4, private_key_name='prv', password='', ) @@ -813,7 +815,6 @@ def setUp(self): keys_auth = KeysAuth( datadir=self.path, - difficulty=4, private_key_name='prv', password='', ) @@ -863,7 +864,6 @@ def setUp(self): keys_auth = KeysAuth( datadir=self.path, - difficulty=4, private_key_name='prv', password='', ) @@ -918,7 +918,11 @@ class SubtaskResultsAcceptedTest(TestCase): def setUp(self): self.task_session = TaskSession(Mock()) self.task_session.verified = True - self.task_server = Mock() + self.task_server = Mock(spec=taskserver.TaskServer) + self.task_server.keys_auth = Mock() + self.task_server.task_manager = Mock() + self.task_server.client = Mock() + self.task_server.pending_sessions = set() self.task_session.conn.server = self.task_server self.requestor_keys = cryptography.ECCx(None) self.requestor_key_id = encode_hex(self.requestor_keys.raw_pubkey) @@ -957,11 +961,12 @@ def test_react_to_subtask_results_accepted(self): # then self.task_server.subtask_accepted.assert_called_once_with( - self.requestor_key_id, - sra.subtask_id, - sra.task_to_compute.requestor_ethereum_address, # noqa pylint:disable=no-member - sra.task_to_compute.price, # noqa pylint:disable=no-member - sra.payment_ts, + sender_node_id=self.requestor_key_id, + task_id=sra.task_id, + subtask_id=sra.subtask_id, + payer_address=sra.task_to_compute.requestor_ethereum_address, # noqa pylint:disable=no-member + value=sra.task_to_compute.price, # noqa pylint:disable=no-member + accepted_ts=sra.payment_ts, ) cancel = self.task_session.concent_service.cancel_task_message cancel.assert_called_once_with( @@ -1016,7 +1021,6 @@ def setUp(self): super().setUp() keys_auth = KeysAuth( datadir=self.path, - difficulty=4, private_key_name='prv', password='', ) @@ -1122,7 +1126,6 @@ class HelloTest(testutils.TempDirFixture): def setUp(self): super().setUp() self.msg = msg_factories.base.HelloFactory( - client_key_id='deadbeef', node_info=dt_p2p_factory.Node(), proto_id=variables.PROTOCOL_CONST.ID, ) @@ -1137,7 +1140,6 @@ def setUp(self): ), ) self.task_session = TaskSession(conn) - self.task_session.task_server.config_desc.key_difficulty = 1 self.task_session.task_server.sessions = {} @patch('golem.task.tasksession.TaskSession.send_hello') @@ -1184,32 +1186,12 @@ def test_react_to_hello_invalid_protocol_version( mock_disconnect.assert_called_once_with( message.base.Disconnect.REASON.ProtocolVersion) - def test_react_to_hello_key_not_difficult( - self, - _mock_store, - mock_disconnect, - *_, - ): - # given - self.task_session.task_server.config_desc.key_difficulty = 80 - - # when - with self.assertLogs(logger, level='INFO'): - self.task_session._react_to_hello(self.msg) - - # then - mock_disconnect.assert_called_with( - message.base.Disconnect.REASON.KeyNotDifficult, - ) - @patch('golem.task.tasksession.TaskSession.send_hello') - def test_react_to_hello_key_difficult(self, mock_hello, *_): + def test_react_to_hello(self, mock_hello, *_): # given - difficulty = 4 - self.task_session.task_server.config_desc.key_difficulty = difficulty - ka = KeysAuth(datadir=self.path, difficulty=difficulty, + ka = KeysAuth(datadir=self.path, private_key_name='prv', password='') - self.msg.client_key_id = ka.key_id + self.msg.node_info.key = ka.key_id # when self.task_session._react_to_hello(self.msg) diff --git a/tests/golem/test_client.py b/tests/golem/test_client.py index 631f62afa5..a328e22e32 100644 --- a/tests/golem/test_client.py +++ b/tests/golem/test_client.py @@ -1,5 +1,4 @@ # pylint: disable=protected-access,too-many-lines -import datetime import os import time import uuid @@ -7,6 +6,7 @@ from unittest import TestCase from unittest.mock import ( ANY, + create_autospec, MagicMock, Mock, patch, @@ -41,10 +41,13 @@ from golem.rpc.mapping.rpceventnames import UI, Environment, Golem from golem.task import taskstate from golem.task.acl import Acl +from golem.task.taskcomputer import TaskComputer from golem.task.taskserver import TaskServer +from golem.task.taskmanager import TaskManager from golem.tools import testwithreactor from golem.tools.assertlogs import LogTestCase +from tests.factories import model as model_factory from tests.factories.task import taskstate as taskstate_factory random = Random(__name__) @@ -88,7 +91,6 @@ def make_mock_ets(eth=100, gnt=100): ) ets.eth_for_batch_payment.return_value = 0.0001 * denoms.ether ets.eth_base_for_batch_payment.return_value = 0.001 * denoms.ether - ets.get_payment_address.return_value = '0x' + 40 * 'a' return ets @@ -100,9 +102,14 @@ def make_mock_ets(eth=100, gnt=100): @patch('signal.signal') @patch('golem.network.p2p.local_node.LocalNode.collect_network_info') def make_client(*_, **kwargs): + config_desc = ClientConfigDescriptor() + config_desc.max_memory_size = 1024 * 1024 # 1 GiB + config_desc.num_cores = 1 + config_desc.hyperdrive_rpc_address = DEFAULT_HYPERDRIVE_RPC_ADDRESS + config_desc.hyperdrive_rpc_port = DEFAULT_HYPERDRIVE_RPC_PORT default_kwargs = { 'app_config': Mock(), - 'config_desc': ClientConfigDescriptor(), + 'config_desc': config_desc, 'keys_auth': Mock( _private_key=b'a' * 32, key_id='a' * 64, @@ -115,10 +122,6 @@ def make_client(*_, **kwargs): 'use_monitor': False, 'concent_variant': CONCENT_CHOICES['disabled'], } - default_kwargs['config_desc'].hyperdrive_rpc_address = \ - DEFAULT_HYPERDRIVE_RPC_ADDRESS - default_kwargs['config_desc'].hyperdrive_rpc_port = \ - DEFAULT_HYPERDRIVE_RPC_PORT default_kwargs.update(kwargs) client = Client(**default_kwargs) return client @@ -144,29 +147,6 @@ class TestClient(TestClientBase): # this may completely break. Issue #2456 # pylint: disable=attribute-defined-outside-init - def test_get_gas_price(self, *_): - test_gas_price = 1234 - test_price_limit = 12345 - ets = self.client.transaction_system - ets.gas_price = test_gas_price - ets.gas_price_limit = test_price_limit - - result = self.client.get_gas_price() - - self.assertEqual(result["current_gas_price"], str(test_gas_price)) - self.assertEqual(result["gas_price_limit"], str(test_price_limit)) - - def test_get_payments(self, *_): - ets = self.client.transaction_system - assert self.client.get_payments_list() == \ - ets.get_payments_list.return_value - - def test_get_incomes(self, *_): - ets = self.client.transaction_system - ets.get_incomes_list.return_value = [] - self.client.get_incomes_list() - ets.get_incomes_list.assert_called_once_with() - def test_withdraw(self, *_): ets = self.client.transaction_system ets.return_value = ets @@ -181,11 +161,6 @@ def test_get_withdraw_gas_cost(self, *_): self.client.get_withdraw_gas_cost('123', dest, 'ETH') ets.get_withdraw_gas_cost.assert_called_once_with(123, dest, 'ETH') - def test_payment_address(self, *_): - payment_address = self.client.get_payment_address() - self.assertIsInstance(payment_address, str) - self.assertTrue(len(payment_address) > 0) - def test_remove_resources(self, *_): def unique_dir(): d = os.path.join(self.path, str(uuid.uuid4())) @@ -621,7 +596,7 @@ def setUp(self): '.register_handler', ): self.client.task_server = TaskServer( node=dt_p2p_factory.Node(), - config_desc=ClientConfigDescriptor(), + config_desc=self.client.config_desc, client=self.client, use_docker_manager=False, apps_manager=self.client.apps_manager, @@ -679,8 +654,11 @@ def unique_dir(): def test_get_balance(self, *_): c = self.client + ethconfig = EthereumConfig() - c.transaction_system = Mock() + c.transaction_system = Mock( + contract_addresses=ethconfig.CONTRACT_ADDRESSES + ) result = { 'gnt_available': 2, @@ -707,7 +685,7 @@ def test_get_balance(self, *_): 'contract_addresses': { contract.name: address for contract, address - in EthereumConfig.CONTRACT_ADDRESSES.items() + in ethconfig.CONTRACT_ADDRESSES.items() } } assert all(isinstance(entry, str) for entry in balance) @@ -1103,7 +1081,7 @@ def test_provider_status_idle(self, *_): def test_golem_status_no_publisher(self, *_): component = 'component' - status = 'method', 'stage', 'data' + status = 'method', 'stage', {'status': 'message', 'value': 'data'} # status published, no rpc publisher StatusPublisher.publish(component, *status) @@ -1112,7 +1090,7 @@ def test_golem_status_no_publisher(self, *_): @inlineCallbacks def test_golem_status_with_publisher(self, *_): component = 'component' - status = 'method', 'stage', 'data' + status = 'method', 'stage', {'status': 'message', 'value': 'data'} # status published, with rpc publisher StatusPublisher._rpc_publisher = Mock() @@ -1230,39 +1208,6 @@ def test_locked(self, *_): self.assertEqual(result['status'], 'locked') -class DepositPaymentsListTest(TestClientBase): - - def test_empty(self): - self.assertEqual(self.client.get_deposit_payments_list(), []) - - def test_one(self): - tx_hash = \ - '0x5e9880b3e9349b609917014690c7a0afcdec6dbbfbef3812b27b60d246ca10ae' - value = 31337 - ts = 1514761200.0 - dt = datetime.datetime.fromtimestamp(ts) - model.DepositPayment.create( - value=value, - tx=tx_hash, - created_date=dt, - modified_date=dt, - ) - expected = [ - { - 'created': ts, - 'modified': ts, - 'fee': None, - 'status': 'awaiting', - 'transaction': tx_hash, - 'value': str(value), - }, - ] - self.assertEqual( - expected, - self.client.get_deposit_payments_list(), - ) - - @patch( 'golem.network.concent.client.ConcentClientService.__init__', return_value=None, @@ -1287,6 +1232,24 @@ def test_no_contract(self, CCS, *_): ) +class TestGetTask(TestClientBase): + def test_all_sent(self): + self.client.task_server = create_autospec(TaskServer) + self.client.task_server.task_manager = create_autospec(TaskManager) + self.client.task_server.task_computer = create_autospec(TaskComputer) + self.client.transaction_system.get_subtasks_payments.return_value \ + = [ + model_factory.TaskPayment( + wallet_operation__status=model.WalletOperation.STATUS.sent, + ), + model_factory.TaskPayment( + wallet_operation__status=model.WalletOperation.STATUS.sent, + wallet_operation__gas_cost=1, + ), + ] + self.client.get_task(uuid.uuid4()) + + class TestClientPEP8(TestCase, testutils.PEP8MixIn): PEP8_FILES = [ "golem/client.py", diff --git a/tests/golem/test_model.py b/tests/golem/test_model.py index d2b99275a2..c9a5e37603 100644 --- a/tests/golem/test_model.py +++ b/tests/golem/test_model.py @@ -1,90 +1,49 @@ -from datetime import datetime +from datetime import ( + datetime, + timezone, +) -from golem_messages.datastructures import p2p as dt_p2p -from golem_messages.factories.datastructures import p2p as dt_p2p_factory from peewee import IntegrityError import golem.model as m from golem.testutils import DatabaseFixture +from tests.factories import model as m_factory -class TestPayment(DatabaseFixture): - def test_default_fields(self): - p = m.Payment() - self.assertGreaterEqual(datetime.now(), p.created_date) - self.assertGreaterEqual(datetime.now(), p.modified_date) - def test_create(self): - p = m.Payment(payee=b"\xDE", subtask="xyz", value=5, - status=m.PaymentStatus.awaiting) - self.assertEqual(p.save(force_insert=True), 1) +class TestBaseModel(DatabaseFixture): + def test_utc(self): + instance = m.GenericKeyValue.create(key='test') + instance_copy = m.GenericKeyValue.get() + self.assertEqual(instance.created_date, instance_copy.created_date) + # pylint: disable=no-member + self.assertIs(instance.created_date.tzinfo, timezone.utc) + self.assertIs(instance_copy.created_date.tzinfo, timezone.utc) - with self.assertRaises(IntegrityError): - m.Payment.create(payee=b"\xDE", subtask="xyz", value=5, - status=m.PaymentStatus.awaiting) - m.Payment.create(payee=b"\xDE", subtask="xyz2", value=4, - status=m.PaymentStatus.confirmed) - m.Payment.create(payee=b"\xDE\xF2", subtask="xyz4", value=5, - status=m.PaymentStatus.sent) - - self.assertEqual(3, len([payment for payment in m.Payment.select()])) - - def test_status_base_type(self): - payee = b"\xab" - subtask = 'zz' - m.Payment.create(payee=payee, subtask=subtask, value=5, - status=m.PaymentStatus.awaiting.value) - p2 = m.Payment.get(payee=payee, subtask=subtask) - self.assertEqual(p2.status, m.PaymentStatus.awaiting) - - def test_invalid_status(self): - with self.assertRaises(TypeError): - m.Payment.create(payee=b"\xab", subtask="zz", value=5, status=667) - - def test_invalid_value_type(self): - with self.assertRaises(TypeError): - m.Payment.create(payee=b"\xab", subtask="float", value=5.5, - status=m.PaymentStatus.sent) - with self.assertRaises(TypeError): - m.Payment.create(payee=b"\xab", subtask="str", value="500", - status=m.PaymentStatus.sent) - - def test_payment_details(self): - p1 = m.Payment(payee=b"\xab", subtask="T1000", value=123456) - p2 = m.Payment(payee=b"\xcd", subtask="T900", value=654321) - self.assertNotEqual(p1.payee, p2.payee) - self.assertNotEqual(p1.subtask, p2.subtask) - self.assertNotEqual(p1.value, p2.value) - self.assertEqual(p1.details, m.PaymentDetails()) - self.assertEqual(p1.details, p2.details) - self.assertIsNot(p1.details, p2.details) - p1.details.check = True - self.assertTrue(p1.details.check) - self.assertEqual(p2.details.check, None) +class TestPayment(DatabaseFixture): def test_payment_big_value(self): value = 10000 * 10**18 - assert value > 2**64 - m.Payment.create(payee=b"\xab", subtask="T1000", value=value, - status=m.PaymentStatus.sent) - - def test_payment_details_serialization(self): - p = m.PaymentDetails(node_info=dt_p2p_factory.Node(), - fee=700) - dct = p.to_dict() - self.assertIsInstance(dct, dict) - self.assertIsInstance(dct['node_info'], dict) - pd = m.PaymentDetails.from_dict(dct) - self.assertIsInstance(pd.node_info, dt_p2p.Node) - self.assertEqual(p, pd) + self.assertGreater(value, 2**64) + payment = m_factory.TaskPayment( + value=value, + ) + payment.wallet_operation.save(force_insert=True) + payment.save(force_insert=True) class TestLocalRank(DatabaseFixture): def test_default_fields(self): # pylint: disable=no-member r = m.LocalRank() - self.assertGreaterEqual(datetime.now(), r.created_date) - self.assertGreaterEqual(datetime.now(), r.modified_date) + self.assertGreaterEqual( + datetime.now(tz=timezone.utc), + r.created_date, + ) + self.assertGreaterEqual( + datetime.now(tz=timezone.utc), + r.modified_date, + ) self.assertEqual(0, r.positive_computed) self.assertEqual(0, r.negative_computed) self.assertEqual(0, r.wrong_computed) @@ -100,8 +59,8 @@ def test_default_fields(self): class TestGlobalRank(DatabaseFixture): def test_default_fields(self): r = m.GlobalRank() - self.assertGreaterEqual(datetime.now(), r.created_date) - self.assertGreaterEqual(datetime.now(), r.modified_date) + self.assertGreaterEqual(datetime.now(tz=timezone.utc), r.created_date) + self.assertGreaterEqual(datetime.now(tz=timezone.utc), r.modified_date) self.assertEqual(m.NEUTRAL_TRUST, r.requesting_trust_value) self.assertEqual(m.NEUTRAL_TRUST, r.computing_trust_value) self.assertEqual(0, r.gossip_weight_computing) @@ -111,8 +70,8 @@ def test_default_fields(self): class TestNeighbourRank(DatabaseFixture): def test_default_fields(self): r = m.NeighbourLocRank() - self.assertGreaterEqual(datetime.now(), r.created_date) - self.assertGreaterEqual(datetime.now(), r.modified_date) + self.assertGreaterEqual(datetime.now(tz=timezone.utc), r.created_date) + self.assertGreaterEqual(datetime.now(tz=timezone.utc), r.modified_date) self.assertEqual(m.NEUTRAL_TRUST, r.requesting_trust_value) self.assertEqual(m.NEUTRAL_TRUST, r.computing_trust_value) @@ -120,15 +79,15 @@ def test_default_fields(self): class TestTaskPreset(DatabaseFixture): def test_default_fields(self): tp = m.TaskPreset() - assert datetime.now() >= tp.created_date - assert datetime.now() >= tp.modified_date + assert datetime.now(tz=timezone.utc) >= tp.created_date + assert datetime.now(tz=timezone.utc) >= tp.modified_date class TestPerformance(DatabaseFixture): def test_default_fields(self): perf = m.Performance() - assert datetime.now() >= perf.created_date - assert datetime.now() >= perf.modified_date + assert datetime.now(tz=timezone.utc) >= perf.created_date + assert datetime.now(tz=timezone.utc) >= perf.modified_date assert perf.value == 0.0 def test_constraints(self): diff --git a/tests/golem/test_opt_node.py b/tests/golem/test_opt_node.py index 5d826636af..79000d60f0 100644 --- a/tests/golem/test_opt_node.py +++ b/tests/golem/test_opt_node.py @@ -138,7 +138,8 @@ def test_geth_address_should_be_passed_to_node(self, mock_node, *_): ], use_monitor=None, use_talkback=None, - password=None) + password=None, + crossbar_serializer=None) @patch('golem.node.TransactionSystem') def test_geth_address_should_be_passed_to_transaction_system( @@ -213,7 +214,8 @@ def test_mainnet_should_be_passed_to_node(self, mock_node, *_): concent_variant=concent_disabled, use_monitor=None, use_talkback=None, - password=None) + password=None, + crossbar_serializer=None) @patch('golem.node.Client') def test_mainnet_should_be_passed_to_client(self, mock_client, *_): @@ -248,20 +250,23 @@ def test_net_testnet_should_be_passed_to_node(self, mock_node, *_): with mock_config(): return_value = runner.invoke(start, args) - from golem.config.active import IS_MAINNET - assert IS_MAINNET is False + from golem.config.active import EthereumConfig + assert EthereumConfig().IS_MAINNET is False # then assert return_value.exit_code == 0 - mock_node.assert_called_with(datadir=path.join(self.path, 'rinkeby'), - app_config=ANY, - config_desc=ANY, - geth_address=None, - peers=[], - concent_variant=concent_disabled, - use_monitor=None, - use_talkback=None, - password=None) + mock_node.assert_called_with( + datadir=path.join(self.path, 'rinkeby'), + app_config=ANY, + config_desc=ANY, + geth_address=None, + peers=[], + concent_variant=variables.CONCENT_CHOICES['test'], + use_monitor=None, + use_talkback=None, + password=None, + crossbar_serializer=None, + ) @patch('golem.node.Node') def test_net_mainnet_should_be_passed_to_node(self, mock_node, *_): @@ -275,8 +280,8 @@ def test_net_mainnet_should_be_passed_to_node(self, mock_node, *_): with mock_config(): return_value = runner.invoke(start, args) - from golem.config.active import IS_MAINNET - assert IS_MAINNET is True + from golem.config.active import EthereumConfig + assert EthereumConfig().IS_MAINNET is True # then assert return_value.exit_code == 0 @@ -288,7 +293,8 @@ def test_net_mainnet_should_be_passed_to_node(self, mock_node, *_): concent_variant=concent_disabled, use_monitor=None, use_talkback=None, - password=None) + password=None, + crossbar_serializer=None) @patch('golem.node.Node') def test_config_change(self, *_): @@ -296,10 +302,12 @@ def test_config_change(self, *_): def compare_config(m): from golem.config import active as a - assert a.IS_MAINNET == m.IS_MAINNET - assert a.ACTIVE_NET == m.ACTIVE_NET + def objattrs(obj): + return [(a, getattr(obj, a)) + for a in dir(obj) if not a.startswith('__')] + assert a.DATA_DIR == m.DATA_DIR - assert a.EthereumConfig == m.EthereumConfig + assert objattrs(a.EthereumConfig()) == objattrs(m.EthereumConfig()) assert a.P2P_SEEDS == m.P2P_SEEDS assert a.PROTOCOL_CONST.ID == m.PROTOCOL_CONST.ID assert a.APP_MANAGER_CONFIG_FILES == m.APP_MANAGER_CONFIG_FILES diff --git a/tests/golem/test_utils.py b/tests/golem/test_utils.py index 96397a8ed0..cd26ee9b21 100644 --- a/tests/golem/test_utils.py +++ b/tests/golem/test_utils.py @@ -1,7 +1,5 @@ -import os import unittest -from eth_utils import encode_hex, is_checksum_address import faker import semantic_version @@ -47,7 +45,8 @@ def test_None(self): self.assertFalse(utils.is_version_compatible(None, self.spec)) def test_invalid(self): - self.assertFalse(utils.is_version_compatible(fake.word(), self.spec)) # noqa pylint: disable=no-member + self.assertFalse(utils.is_version_compatible(fake.word(), + self.spec)) # noqa pylint: disable=no-member class GetMinVersionTest(unittest.TestCase): @@ -57,9 +56,3 @@ def setUp(self): def test_basic(self): min_version = utils.get_min_version(self.version) self.assertEqual(min_version, semantic_version.Version('0.11.0')) - - -def test_pubkeytoaddr(): - pubkey = encode_hex(os.urandom(64)) - addr = utils.pubkeytoaddr(pubkey) - assert is_checksum_address(addr) diff --git a/tests/golem/vm/test_memorychecker.py b/tests/golem/vm/test_memorychecker.py index 3603d518be..0d5483c93d 100644 --- a/tests/golem/vm/test_memorychecker.py +++ b/tests/golem/vm/test_memorychecker.py @@ -1,4 +1,4 @@ -from time import sleep as originalsleep +from typing import Iterable from unittest import TestCase from unittest.mock import patch @@ -7,18 +7,88 @@ class TestMemoryChecker(TestCase): - @patch('time.sleep', return_value=None) # speed up tests - @patch("golem.vm.memorychecker.psutil") - def test_memory(self, psutil_mock, _): - psutil_mock.virtual_memory.return_value.used = 1200000 - with MemoryChecker() as mc: - assert isinstance(mc._thread, MemoryCheckerThread) - psutil_mock.virtual_memory.return_value.used = 1200050 - originalsleep(0.01) - psutil_mock.virtual_memory.return_value.used = 1100030 - originalsleep(0.01) - psutil_mock.virtual_memory.return_value.used = 1200030 - originalsleep(0.01) - assert mc.estm_mem == 50 - assert mc._thread.max_mem == 1200050 - assert mc._thread.min_mem == 1100030 + @patch('golem.vm.memorychecker.MemoryCheckerThread') + def test_memory_checker(self, mc_thread): + with MemoryChecker() as memory_checker: + mc_thread().start.assert_called_once() + self.assertEqual(memory_checker.estm_mem, mc_thread().estm_mem) + mc_thread.stop.assert_not_called() + mc_thread().stop.assert_called_once() + + +# pylint: disable=no-value-for-parameter +class TestMemoryCheckerThread(TestCase): + + @patch('golem.vm.memorychecker.psutil.virtual_memory') + def test_not_started(self, virtual_memory): + virtual_memory().used = 2137 + mc_thread = MemoryCheckerThread() + self.assertEqual(mc_thread.estm_mem, 0) + + @patch('golem.vm.memorychecker.time.sleep') + @patch('golem.vm.memorychecker.psutil.virtual_memory') + def _generic_test( # pylint: disable=too-many-arguments + self, + virtual_memory, + sleep, + initial_mem_usage: int, + mem_usage: Iterable[int], + exp_estimation: Iterable[int] + ) -> None: + + virtual_memory().used = initial_mem_usage + mc_thread = MemoryCheckerThread() + + # We are using patched sleep() function to synchronize with the thread's + # run() method. When the thread calls sleep() all instructions up to the + # next yield will be executed. + def _advance(): + for used, expected in zip(mem_usage, exp_estimation): + virtual_memory().used = used + yield + self.assertEqual(mc_thread.estm_mem, expected) + mc_thread.stop() + yield + + advance = _advance() + sleep.side_effect = lambda _: next(advance) + + # Just calling run() instead of actually starting the thread because + # logic is the same but raising an exception in a different thread + # wouldn't fail the test. + mc_thread.run() + + def test_memory_usage_constant(self): + self._generic_test( + initial_mem_usage=1000, + mem_usage=(1000, 1000, 1000), + exp_estimation=(0, 0, 0) + ) + + def test_memory_usage_rising(self): + self._generic_test( + initial_mem_usage=1000, + mem_usage=(2000, 3000, 4000), + exp_estimation=(1000, 2000, 3000) + ) + + def test_memory_usage_sinking(self): + self._generic_test( + initial_mem_usage=4000, + mem_usage=(4000, 3000, 2000), + exp_estimation=(0, 1000, 2000) + ) + + def test_memory_usage_rising_then_sinking(self): + self._generic_test( + initial_mem_usage=2000, + mem_usage=(2000, 3000, 2000, 1000), + exp_estimation=(0, 1000, 1000, 1000) + ) + + def test_memory_usage_sinking_then_rising(self): + self._generic_test( + initial_mem_usage=3000, + mem_usage=(2000, 3000, 4000, 5000), + exp_estimation=(1000, 1000, 1000, 2000) + ) diff --git a/tests/test_clientconfigdescriptor.py b/tests/test_clientconfigdescriptor.py index 64be2d0b24..30838ce04f 100644 --- a/tests/test_clientconfigdescriptor.py +++ b/tests/test_clientconfigdescriptor.py @@ -1,6 +1,5 @@ from unittest import TestCase from golem.clientconfigdescriptor import ClientConfigDescriptor, ConfigApprover -from golem.core.variables import KEY_DIFFICULTY class TestClientConfigDescriptor(TestCase): @@ -16,7 +15,6 @@ def test_approve(self): config = ClientConfigDescriptor() config.num_cores = '1' config.computing_trust = '1' - config.key_difficulty = '0' approved_config = ConfigApprover(config).approve() @@ -25,11 +23,3 @@ def test_approve(self): assert isinstance(approved_config.computing_trust, float) assert approved_config.computing_trust == 1.0 - - assert isinstance(approved_config.key_difficulty, int) - assert approved_config.key_difficulty == KEY_DIFFICULTY - - def test_max_value_error(self): - key_error_var = 1 - assert ConfigApprover._max_value(key_error_var, 'does_not_exist') is \ - key_error_var