From 96091f36ff3a47a5f24c3f2f8f409a58b342a5c4 Mon Sep 17 00:00:00 2001 From: Kostiantyn Kostiuk Date: Fri, 3 Apr 2026 19:43:10 +0300 Subject: [PATCH 1/3] Define new NotificationProvider and NotificationManager Signed-off-by: Kostiantyn Kostiuk --- lib/all.rb | 2 + .../notification_manager.rb | 134 ++++++++++++++++++ .../notification_provider_interface.rb | 83 +++++++++++ 3 files changed, 219 insertions(+) create mode 100644 lib/notificationmanagers/notification_manager.rb create mode 100644 lib/notificationmanagers/notification_provider_interface.rb diff --git a/lib/all.rb b/lib/all.rb index b87e95ad..b85b5163 100644 --- a/lib/all.rb +++ b/lib/all.rb @@ -56,6 +56,8 @@ module AutoHCK autoload_relative :MachineRunError, 'setupmanagers/exceptions' autoload_relative :Models, 'models' autoload_relative :MultiLogger, 'auxiliary/multi_logger' + autoload_relative :NotificationManager, 'notificationmanagers/notification_manager' + autoload_relative :NotificationProviderInterface, 'notificationmanagers/notification_provider_interface' autoload_relative :NotImplementedError, 'exceptions' autoload_relative :OpenJsonError, 'exceptions' autoload_relative :PackageManager, 'auxiliary/package_manager' diff --git a/lib/notificationmanagers/notification_manager.rb b/lib/notificationmanagers/notification_manager.rb new file mode 100644 index 00000000..be671000 --- /dev/null +++ b/lib/notificationmanagers/notification_manager.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +module AutoHCK + class NotificationManager + class ProviderFactory + PROVIDERS = Dir.each_child(__dir__).filter_map do |provider_name| + full_path = "#{__dir__}/#{provider_name}" + next unless File.directory?(full_path) + + file = "#{full_path}/#{provider_name}.rb" + require file + # Convert 'result_uploader' (file name) -> 'ResultUploader' (class name) + class_name = provider_name.camelize + # Convert 'ResultUploader' (class name) -> ResultUploader (declared constant) + # or + # Convert 'ResultUploader' (class name) -> AutoHCK::ResultUploader (declared constant) + [provider_name, AutoHCK.const_get(class_name)] + end.to_h.freeze + + def self.create(type, project) + PROVIDERS[type].new(project) + end + + def self.can_create?(type) + !PROVIDERS[type].nil? + end + end + + def initialize(scope, project) + @scope = scope + @providers = {} + types = Array(project.config['notification_providers']).compact.uniq + types.each do |type| + if ProviderFactory.can_create?(type) + @scope << (@providers[type] = ProviderFactory.create(type, project)) + else + project.logger.info("Unknown notification provider #{type}, (ignoring)") + end + end + end + + def post_project_init(project) + @providers.each_value { |p| p.post_project_init(project) } + end + + def pre_project_prepare(project) + @providers.each_value { |p| p.pre_project_prepare(project) } + end + + def post_project_prepare(project) + @providers.each_value { |p| p.post_project_prepare(project) } + end + + def pre_project_run(project) + @providers.each_value { |p| p.pre_project_run(project) } + end + + def post_project_run(project) + @providers.each_value { |p| p.post_project_run(project) } + end + + def post_engine_init(engine) + @providers.each_value { |p| p.post_engine_init(engine) } + end + + def pre_engine_run(engine) + @providers.each_value { |p| p.pre_engine_run(engine) } + end + + def post_engine_run(engine) + @providers.each_value { |p| p.post_engine_run(engine) } + end + + def post_setup_manager_init(setup_manager) + @providers.each_value { |p| p.post_setup_manager_init(setup_manager) } + end + + def pre_setup_manager_run_hck_studio(setup_manager, run_opts) + @providers.each_value { |p| p.pre_setup_manager_run_hck_studio(setup_manager, run_opts) } + end + + def post_setup_manager_run_hck_studio(setup_manager, run_opts) + @providers.each_value { |p| p.post_setup_manager_run_hck_studio(setup_manager, run_opts) } + end + + def pre_setup_manager_run_studio(setup_manager, run_opts) + @providers.each_value { |p| p.pre_setup_manager_run_studio(setup_manager, run_opts) } + end + + def post_setup_manager_run_studio(setup_manager, run_opts) + @providers.each_value { |p| p.post_setup_manager_run_studio(setup_manager, run_opts) } + end + + def pre_setup_manager_run_hck_client(setup_manager, studio, name, run_opts) + @providers.each_value { |p| p.pre_setup_manager_run_hck_client(setup_manager, studio, name, run_opts) } + end + + def post_setup_manager_run_hck_client(setup_manager, studio, name, run_opts) + @providers.each_value { |p| p.post_setup_manager_run_hck_client(setup_manager, studio, name, run_opts) } + end + + def pre_setup_manager_run_client(setup_manager, name, run_opts) + @providers.each_value { |p| p.pre_setup_manager_run_client(setup_manager, name, run_opts) } + end + + def post_setup_manager_run_client(setup_manager, name, run_opts) + @providers.each_value { |p| p.post_setup_manager_run_client(setup_manager, name, run_opts) } + end + + def pre_tests_run(tests) + @providers.each_value { |p| p.pre_tests_run(tests) } + end + + def post_tests_run(tests) + @providers.each_value { |p| p.post_tests_run(tests) } + end + + def pre_tests_queue_test(test, wait) + @providers.each_value { |p| p.pre_tests_queue_test(test, wait) } + end + + def pre_tests_on_test_start(test) + @providers.each_value { |p| p.pre_tests_on_test_start(test) } + end + + def pre_tests_handle_finished_test_results(results, tests_stats) + @providers.each_value { |p| p.pre_tests_handle_finished_test_results(results, tests_stats) } + end + + def pre_tests_handle_finished_test_result(test, result) + @providers.each_value { |p| p.pre_tests_handle_finished_test_result(test, result) } + end + end +end diff --git a/lib/notificationmanagers/notification_provider_interface.rb b/lib/notificationmanagers/notification_provider_interface.rb new file mode 100644 index 00000000..527ed726 --- /dev/null +++ b/lib/notificationmanagers/notification_provider_interface.rb @@ -0,0 +1,83 @@ +# typed: true +# frozen_string_literal: true + +module AutoHCK + module NotificationProviderInterface + extend T::Sig + extend T::Helpers + + interface! + + sig { abstract.void } + def close; end + + sig { abstract.params(project: Project).void } + def post_project_init(project); end + + sig { abstract.params(project: Project).void } + def pre_project_prepare(project); end + + sig { abstract.params(project: Project).void } + def post_project_prepare(project); end + + sig { abstract.params(project: Project).void } + def pre_project_run(project); end + + sig { abstract.params(project: Project).void } + def post_project_run(project); end + + sig { abstract.params(engine: T.any(HCKTest, HCKInstall, ConfigManager)).void } + def post_engine_init(engine); end + + sig { abstract.params(engine: T.any(HCKTest, HCKInstall, ConfigManager)).void } + def pre_engine_run(engine); end + + sig { abstract.params(engine: T.any(HCKTest, HCKInstall, ConfigManager)).void } + def post_engine_run(engine); end + + sig { abstract.params(setup_manager: T.any(PhysHCK, QemuHCK)).void } + def post_setup_manager_init(setup_manager); end + + sig { abstract.params(setup_manager: T.any(PhysHCK, QemuHCK), run_opts: T::Hash[String, T.untyped]).void } + def pre_setup_manager_run_hck_studio(setup_manager, run_opts); end + + sig { abstract.params(setup_manager: T.any(PhysHCK, QemuHCK), run_opts: T::Hash[String, T.untyped]).void } + def post_setup_manager_run_hck_studio(setup_manager, run_opts); end + + sig { abstract.params(setup_manager: T.any(PhysHCK, QemuHCK), run_opts: T::Hash[String, T.untyped]).void } + def pre_setup_manager_run_studio(setup_manager, run_opts); end + + sig { abstract.params(setup_manager: T.any(PhysHCK, QemuHCK), run_opts: T::Hash[String, T.untyped]).void } + def post_setup_manager_run_studio(setup_manager, run_opts); end + + sig { abstract.params(setup_manager: T.any(PhysHCK, QemuHCK), studio: HCKStudio, name: String, run_opts: T::Hash[String, T.untyped]).void } + def pre_setup_manager_run_hck_client(setup_manager, studio, name, run_opts); end + + sig { abstract.params(setup_manager: T.any(PhysHCK, QemuHCK), studio: HCKStudio, name: String, run_opts: T::Hash[String, T.untyped]).void } + def post_setup_manager_run_hck_client(setup_manager, studio, name, run_opts); end + + sig { abstract.params(setup_manager: T.any(PhysHCK, QemuHCK), name: String, run_opts: T::Hash[String, T.untyped]).void } + def pre_setup_manager_run_client(setup_manager, name, run_opts); end + + sig { abstract.params(setup_manager: T.any(PhysHCK, QemuHCK), name: String, run_opts: T::Hash[String, T.untyped]).void } + def post_setup_manager_run_client(setup_manager, name, run_opts); end + + sig { abstract.params(tests: T::Array[Models::HLK::Test]).void } + def pre_tests_run(tests); end + + sig { abstract.params(tests: T::Array[Models::HLK::Test]).void } + def post_tests_run(tests); end + + sig { abstract.params(test: Models::HLK::Test, wait: T::Boolean).void } + def pre_tests_queue_test(test, wait); end + + sig { abstract.params(test: Models::HLK::Test).void } + def pre_tests_on_test_start(test); end + + sig { abstract.params(results: T::Array[T::Hash[String, T.untyped]], tests_stats: T::Hash[String, Integer]).void } + def pre_tests_handle_finished_test_results(results, tests_stats); end + + sig { abstract.params(test: Models::HLK::Test, result: T::Hash[String, T.untyped]).void } + def pre_tests_handle_finished_test_result(test, result); end + end +end From ab0f28110fabbd0ac4902fb1778edadde8933dc7 Mon Sep 17 00:00:00 2001 From: Kostiantyn Kostiuk Date: Fri, 3 Apr 2026 19:46:36 +0300 Subject: [PATCH 2/3] Implement NotificationManager Signed-off-by: Kostiantyn Kostiuk --- lib/engines/config_manager/config_manager.rb | 8 ++++++ lib/engines/hckinstall/hckinstall.rb | 4 +++ lib/engines/hcktest/hcktest.rb | 4 +++ lib/engines/hcktest/tests.rb | 7 ++++++ lib/project.rb | 26 ++++++++++++++------ lib/setupmanagers/physhck/physhck.rb | 17 +++++++++++-- lib/setupmanagers/qemuhck/qemuhck.rb | 13 ++++++++++ 7 files changed, 69 insertions(+), 10 deletions(-) diff --git a/lib/engines/config_manager/config_manager.rb b/lib/engines/config_manager/config_manager.rb index c79d602a..d368d185 100644 --- a/lib/engines/config_manager/config_manager.rb +++ b/lib/engines/config_manager/config_manager.rb @@ -12,6 +12,7 @@ def initialize(project) @project = project @logger = project.logger @project.append_multilog("#{self.class.tag}.log") + @project.notification_manager.post_engine_init(self) end def self.tag(*) @@ -23,11 +24,14 @@ def test_steps end def run + @project.notification_manager.pre_engine_run(self) ResourceScope.open do |scope| @project.logger.info('Stating result uploader token initialization') @result_uploader = ResultUploader.new(scope, @project) @result_uploader.ask_token end + ensure + @project.notification_manager.post_engine_run(self) end def drivers @@ -41,5 +45,9 @@ def self.platform(*) def result_uploader_needed? false end + + def target + nil + end end end diff --git a/lib/engines/hckinstall/hckinstall.rb b/lib/engines/hckinstall/hckinstall.rb index 22cbdb90..4292202e 100644 --- a/lib/engines/hckinstall/hckinstall.rb +++ b/lib/engines/hckinstall/hckinstall.rb @@ -28,6 +28,7 @@ def initialize(project) validate_paths prepare_extra_sw @logger.debug('HCKInstall: initialized') + @project.notification_manager.post_engine_init(self) end def test_steps @@ -438,6 +439,7 @@ def plan_client end def run + @project.notification_manager.pre_engine_run(self) @logger.debug('HCKInstall: run') prepare_setup_scripts_config @@ -449,6 +451,8 @@ def run run_first(studio:, client:) run_second(client:) end + ensure + @project.notification_manager.post_engine_run(self) end end end diff --git a/lib/engines/hcktest/hcktest.rb b/lib/engines/hcktest/hcktest.rb index eeb097b0..02b049f2 100644 --- a/lib/engines/hcktest/hcktest.rb +++ b/lib/engines/hcktest/hcktest.rb @@ -28,6 +28,7 @@ def initialize(project) @extensions = find_extensions prepare_extra_sw validate_paths unless @driver_path.nil? + @project.notification_manager.post_engine_init(self) end def test_steps @@ -345,6 +346,7 @@ def group_tests_by_config end def run + @project.notification_manager.pre_engine_run(self) upload_driver_package unless @driver_path.nil? if @project.options.test.dump @@ -358,6 +360,8 @@ def run else auto_run end + ensure + @project.notification_manager.post_engine_run(self) end def result_uploader_needed? diff --git a/lib/engines/hcktest/tests.rb b/lib/engines/hcktest/tests.rb index 3affb7e4..127a5eda 100644 --- a/lib/engines/hcktest/tests.rb +++ b/lib/engines/hcktest/tests.rb @@ -308,6 +308,7 @@ def perform_queue_test_commands(test) sig { params(test: Models::HLK::Test, wait: T::Boolean).void } def queue_test(test, wait: false) + @project.notification_manager.pre_tests_queue_test(test, wait) retries = 0 begin perform_queue_test_commands(test) @@ -367,6 +368,7 @@ def done_test_results sig { params(test: Models::HLK::Test).void } def on_test_start(test) + @project.notification_manager.pre_tests_on_test_start(test) @logger.info(">>> Currently running: #{test.name} [#{test.estimatedruntime}]") test.started_at = DateTime.now @@ -571,6 +573,7 @@ def collect_memory_dumps(test) sig { params(test: Models::HLK::Test, test_result: T::Hash[String, T.untyped]).void } def handle_finished_test_result(test, test_result) + @project.notification_manager.pre_tests_handle_finished_test_result(test, test_result) collect_memory_dumps(test) print_test_results(test, test_result) @@ -590,6 +593,7 @@ def test_for_result(result) end def handle_finished_test_results(results) + @project.notification_manager.pre_tests_handle_finished_test_results(results, tests_stats) @project.update_test_stats(tests_stats) results.each do |result| @@ -787,6 +791,7 @@ def retry_tests(current_run_tests) sig { params(tests: T::Array[Models::HLK::Test]).void } def run(tests) + @project.notification_manager.pre_tests_run(tests) load_clients_system_info update_summary_results_log tests.each do |test| @@ -804,6 +809,8 @@ def run(tests) return if @project.run_terminated retry_tests(tests) unless @project.options.test.auto_retry_failed_tests.zero? + ensure + @project.notification_manager.post_tests_run(tests) end end end diff --git a/lib/project.rb b/lib/project.rb index 92241d81..18a27335 100644 --- a/lib/project.rb +++ b/lib/project.rb @@ -9,7 +9,7 @@ class Project JUNIT_RESULT = 'junit.xml' attr_reader :config, :logger, :timestamp, :setup_manager, :engine, :id, - :workspace_path, :result_uploader, :engine_tag, + :workspace_path, :result_uploader, :notification_manager, :engine_tag, :engine_platform, :engine_type, :options, :extra_sw_manager, :run_terminated, :engine_name, :string_log @@ -26,26 +26,36 @@ def initialize(scope, options) # Project uses ResultUploader on close, so ResultUploader must exist # when project is closed @result_uploader = ResultUploader.new(@scope, self) + @notification_manager = NotificationManager.new(@scope, self) scope << self + @notification_manager.post_project_init(self) end def prepare - @extra_sw_manager = ExtraSoftwareManager.new(self) + @notification_manager.pre_project_prepare(self) + begin + @extra_sw_manager = ExtraSoftwareManager.new(self) - @engine = @engine_type.new(self) - Sentry.set_tags('autohck.tag': @engine_tag) + @engine = @engine_type.new(self) + Sentry.set_tags('autohck.tag': @engine_tag) - configure_result_uploader if @engine.result_uploader_needed? - return false unless github_handling(@options.test.commit) + configure_result_uploader if @engine.result_uploader_needed? + return false unless github_handling(@options.test.commit) - @setup_manager = @setup_manager_type&.new(self) + @setup_manager = @setup_manager_type&.new(self) - true + true + ensure + @notification_manager.post_project_prepare(self) + end end def run + @notification_manager.pre_project_run(self) @engine.run + ensure + @notification_manager.post_project_run(self) end def prep_stream_for_log(stream) diff --git a/lib/setupmanagers/physhck/physhck.rb b/lib/setupmanagers/physhck/physhck.rb index a8a03504..f0be28a0 100644 --- a/lib/setupmanagers/physhck/physhck.rb +++ b/lib/setupmanagers/physhck/physhck.rb @@ -36,6 +36,7 @@ def initialize(project) @setup = find_setup @id = project.id @kit = @setup['kit'] + @project.notification_manager.post_setup_manager_init(self) end def find_setup @@ -77,12 +78,18 @@ def create_client_image(_name) @logger.info('Image creating is currently not supported for physical machines') end - def run_studio(*) + def run_studio(_scope, run_opts = nil) + @project.notification_manager.pre_setup_manager_run_studio(self, run_opts) Runner.new(@logger) + ensure + @project.notification_manager.post_setup_manager_run_studio(self, run_opts) end - def run_client(*) + def run_client(_scope, name, run_opts = nil) + @project.notification_manager.pre_setup_manager_run_client(self, name, run_opts) Runner.new(@logger) + ensure + @project.notification_manager.post_setup_manager_run_client(self, name, run_opts) end def client_post_start_commands @@ -90,12 +97,18 @@ def client_post_start_commands end def run_hck_studio(scope, run_opts) + @project.notification_manager.pre_setup_manager_run_hck_studio(self, run_opts) studio_ip = @setup['st_ip'] HCKStudio.new(self, scope, run_opts) { studio_ip } + ensure + @project.notification_manager.post_setup_manager_run_hck_studio(self, run_opts) end def run_hck_client(scope, studio, name, run_opts) + @project.notification_manager.pre_setup_manager_run_hck_client(self, studio, name, run_opts) HCKClient.new(self, scope, studio, name, run_opts) + ensure + @project.notification_manager.post_setup_manager_run_hck_client(self, studio, name, run_opts) end def self.enter(*); end diff --git a/lib/setupmanagers/qemuhck/qemuhck.rb b/lib/setupmanagers/qemuhck/qemuhck.rb index d01705b6..db2cc5d7 100644 --- a/lib/setupmanagers/qemuhck/qemuhck.rb +++ b/lib/setupmanagers/qemuhck/qemuhck.rb @@ -24,6 +24,7 @@ def initialize(project) initialize_studio_vm initialize_clients_vm create_qemuhck_log_file + @project.notification_manager.post_setup_manager_init(self) end def initialize_project(project) @@ -203,19 +204,31 @@ def client_option_config(name, option) end def run_studio(scope, run_opts = nil) + @project.notification_manager.pre_setup_manager_run_studio(self, run_opts) @studio_vm.run(scope, run_opts) + ensure + @project.notification_manager.post_setup_manager_run_studio(self, run_opts) end def run_client(scope, name, run_opts = nil) + @project.notification_manager.pre_setup_manager_run_client(self, name, run_opts) @clients_vm[name].run(scope, run_opts) + ensure + @project.notification_manager.post_setup_manager_run_client(self, name, run_opts) end def run_hck_studio(scope, run_opts) + @project.notification_manager.pre_setup_manager_run_hck_studio(self, run_opts) HCKStudio.new(self, scope, run_opts) { @studio_vm.find_world_ip } + ensure + @project.notification_manager.post_setup_manager_run_hck_studio(self, run_opts) end def run_hck_client(scope, studio, name, run_opts) + @project.notification_manager.pre_setup_manager_run_hck_client(self, studio, name, run_opts) HCKClient.new(self, scope, studio, name, run_opts) + ensure + @project.notification_manager.post_setup_manager_run_hck_client(self, studio, name, run_opts) end def self.enter(workspace_path) From ec59f1bd9faf74668b7ad4949231322803d69602 Mon Sep 17 00:00:00 2001 From: Kostiantyn Kostiuk Date: Fri, 3 Apr 2026 19:47:06 +0300 Subject: [PATCH 3/3] Implement ExternalScript notification provider Signed-off-by: Kostiantyn Kostiuk --- lib/all.rb | 1 + .../external_script/example_hook.sh | 17 + .../external_script/external_script.json | 27 ++ .../external_script/external_script.rb | 355 ++++++++++++++++++ 4 files changed, 400 insertions(+) create mode 100755 lib/notificationmanagers/external_script/example_hook.sh create mode 100644 lib/notificationmanagers/external_script/external_script.json create mode 100644 lib/notificationmanagers/external_script/external_script.rb diff --git a/lib/all.rb b/lib/all.rb index b85b5163..59faf31e 100644 --- a/lib/all.rb +++ b/lib/all.rb @@ -36,6 +36,7 @@ module AutoHCK autoload_relative :Downloader, 'auxiliary/downloader' autoload_relative :Engine, 'engines/engine' autoload_relative :EngineError, 'engines/exceptions' + autoload_relative :ExternalScript, 'notificationmanagers/external_script/external_script' autoload_relative :ExtraSoftwareBrokenConfig, 'auxiliary/extra_software/exceptions' autoload_relative :ExtraSoftwareManager, 'auxiliary/extra_software/manager' autoload_relative :ExtraSoftwareMissingConfig, 'auxiliary/extra_software/exceptions' diff --git a/lib/notificationmanagers/external_script/example_hook.sh b/lib/notificationmanagers/external_script/example_hook.sh new file mode 100755 index 00000000..a8f1c2b5 --- /dev/null +++ b/lib/notificationmanagers/external_script/example_hook.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Example AutoHCK external notification hook. +# Environment: AUTOHCK_NOTIFICATION_HOOK and one variable per Ruby argument, +# e.g. AUTOHCK_NOTIFICATION_PROJECT holds JSON for the Project snapshot. + +set -euo pipefail + +log_default="${TMPDIR:-/tmp}/autohck_external_notification.log" +log_path="${AUTOHCK_NOTIFICATION_LOG_PATH:-$log_default}" + +{ + printf '%s\n' "=== $(date -Iseconds 2>/dev/null || date) ===" + env | LC_ALL=C sort | grep '^AUTOHCK_NOTIFICATION_' || true + printf '\n' +} >>"$log_path" + +exit 0 diff --git a/lib/notificationmanagers/external_script/external_script.json b/lib/notificationmanagers/external_script/external_script.json new file mode 100644 index 00000000..b2f98c78 --- /dev/null +++ b/lib/notificationmanagers/external_script/external_script.json @@ -0,0 +1,27 @@ +{ + "_readme": "Optional 'hooks' object maps Ruby notification method names to executable scripts. Paths are relative to this file's directory unless absolute. Omit hooks you do not need.", + "hooks": { + "post_project_init": "example_hook.sh", + "pre_project_run": "example_hook.sh", + "post_project_run": "example_hook.sh", + "post_engine_init": "example_hook.sh", + "pre_engine_run": "example_hook.sh", + "post_engine_run": "example_hook.sh", + "post_setup_manager_init": "example_hook.sh", + "pre_setup_manager_run_hck_studio": "example_hook.sh", + "post_setup_manager_run_hck_studio": "example_hook.sh", + "pre_setup_manager_run_studio": "example_hook.sh", + "post_setup_manager_run_studio": "example_hook.sh", + "pre_setup_manager_run_hck_client": "example_hook.sh", + "post_setup_manager_run_hck_client": "example_hook.sh", + "pre_setup_manager_run_client": "example_hook.sh", + "post_setup_manager_run_client": "example_hook.sh", + "pre_tests_run": "example_hook.sh", + "post_tests_run": "example_hook.sh", + "pre_tests_queue_test": "example_hook.sh", + "pre_tests_on_test_start": "example_hook.sh", + "pre_tests_handle_finished_test_results": "example_hook.sh", + "pre_tests_handle_finished_test_result": "example_hook.sh", + "close": "example_hook.sh" + } +} diff --git a/lib/notificationmanagers/external_script/external_script.rb b/lib/notificationmanagers/external_script/external_script.rb new file mode 100644 index 00000000..577ee111 --- /dev/null +++ b/lib/notificationmanagers/external_script/external_script.rb @@ -0,0 +1,355 @@ +# typed: true +# frozen_string_literal: true + +require 'json' +require 'open3' + +module AutoHCK + # Runs shell commands from external_script.json for selected notification hooks. + # Each hook argument is passed as AUTOHCK_NOTIFICATION_=. + + class ExternalScript + extend T::Sig + include Helper + include NotificationProviderInterface + + CONFIG_JSON = 'lib/notificationmanagers/external_script/external_script.json' + # API version for the notification hook script + # Increment API_1 when any field or hook was removed or modified (including name). + # Reset API_2 to 0 when API_1 is updated. + # Increment API_2 when any new field or hook was added. + AUTOHCK_NOTIFICATION_HOOK_API_1 = '1' + AUTOHCK_NOTIFICATION_HOOK_API_2 = '0' + + sig { params(project: Project).void } + def initialize(project) + @logger = project.logger + raw = Json.read_json(CONFIG_JSON, @logger) + @hooks = T.let(normalize_hooks(raw), T::Hash[String, String]) + end + + sig { override.void } + def close; end + + sig { override.params(project: Project).void } + def post_project_init(project) + @project = project + + dispatch(:post_project_init, {}) + end + + sig { override.params(project: Project).void } + def pre_project_prepare(project) + @project = project + + dispatch(:pre_project_prepare, {}) + end + + sig { override.params(project: Project).void } + def post_project_prepare(project) + @project = project + + dispatch(:post_project_prepare, {}) + end + + sig { override.params(project: Project).void } + def pre_project_run(project) + @project = project + + dispatch(:pre_project_run, {}) + end + + sig { override.params(project: Project).void } + def post_project_run(project) + @project = project + + dispatch(:post_project_run, {}) + end + + sig { override.params(engine: T.any(HCKTest, HCKInstall, ConfigManager)).void } + def post_engine_init(engine) + @engine = engine + + dispatch(:post_engine_init, {}) + end + + sig { override.params(engine: T.any(HCKTest, HCKInstall, ConfigManager)).void } + def pre_engine_run(engine) + @engine = engine + + dispatch(:pre_engine_run, {}) + end + + sig { override.params(engine: T.any(HCKTest, HCKInstall, ConfigManager)).void } + def post_engine_run(engine) + @engine = engine + + dispatch(:post_engine_run, {}) + end + + sig { override.params(setup_manager: T.any(PhysHCK, QemuHCK)).void } + def post_setup_manager_init(setup_manager) + @setup_manager = setup_manager + + dispatch(:post_setup_manager_init, {}) + end + + sig { override.params(setup_manager: T.any(PhysHCK, QemuHCK), run_opts: T::Hash[String, T.untyped]).void } + def pre_setup_manager_run_hck_studio(setup_manager, run_opts) + @setup_manager = setup_manager + + dispatch(:pre_setup_manager_run_hck_studio, { run_opts: run_opts }) + end + + sig { override.params(setup_manager: T.any(PhysHCK, QemuHCK), run_opts: T::Hash[String, T.untyped]).void } + def post_setup_manager_run_hck_studio(setup_manager, run_opts) + @setup_manager = setup_manager + + dispatch(:post_setup_manager_run_hck_studio, { run_opts: run_opts }) + end + + sig { override.params(setup_manager: T.any(PhysHCK, QemuHCK), run_opts: T::Hash[String, T.untyped]).void } + def pre_setup_manager_run_studio(setup_manager, run_opts) + @setup_manager = setup_manager + + dispatch(:pre_setup_manager_run_studio, { run_opts: run_opts }) + end + + sig { override.params(setup_manager: T.any(PhysHCK, QemuHCK), run_opts: T::Hash[String, T.untyped]).void } + def post_setup_manager_run_studio(setup_manager, run_opts) + @setup_manager = setup_manager + + dispatch(:post_setup_manager_run_studio, { run_opts: run_opts }) + end + + sig do + override.params( + setup_manager: T.any(PhysHCK, QemuHCK), + _studio: HCKStudio, + name: String, + run_opts: T::Hash[String, T.untyped] + ).void + end + def pre_setup_manager_run_hck_client(setup_manager, _studio, name, run_opts) + @setup_manager = setup_manager + + # No useful data in studio, so we don't pass it to the script. + dispatch(:pre_setup_manager_run_hck_client, { name: name, run_opts: run_opts }) + end + + sig do + override.params( + setup_manager: T.any(PhysHCK, QemuHCK), + _studio: HCKStudio, + name: String, + run_opts: T::Hash[String, T.untyped] + ).void + end + def post_setup_manager_run_hck_client(setup_manager, _studio, name, run_opts) + @setup_manager = setup_manager + + # No useful data in studio, so we don't pass it to the script. + dispatch(:post_setup_manager_run_hck_client, { name: name, run_opts: run_opts }) + end + + sig do + override.params(setup_manager: T.any(PhysHCK, QemuHCK), name: String, + run_opts: T::Hash[String, T.untyped]).void + end + def pre_setup_manager_run_client(setup_manager, name, run_opts) + @setup_manager = setup_manager + + dispatch(:pre_setup_manager_run_client, { name: name, run_opts: run_opts }) + end + + sig do + override.params(setup_manager: T.any(PhysHCK, QemuHCK), name: String, + run_opts: T::Hash[String, T.untyped]).void + end + def post_setup_manager_run_client(setup_manager, name, run_opts) + @setup_manager = setup_manager + + dispatch(:post_setup_manager_run_client, { name: name, run_opts: run_opts }) + end + + sig { override.params(tests: T::Array[Models::HLK::Test]).void } + def pre_tests_run(tests) + dispatch(:pre_tests_run, { tests: tests }) + end + + sig { override.params(tests: T::Array[Models::HLK::Test]).void } + def post_tests_run(tests) + dispatch(:post_tests_run, { tests: tests }) + end + + sig { override.params(test: Models::HLK::Test, wait: T::Boolean).void } + def pre_tests_queue_test(test, wait) + dispatch(:pre_tests_queue_test, { test: test, wait: wait }) + end + + sig { override.params(test: Models::HLK::Test).void } + def pre_tests_on_test_start(test) + dispatch(:pre_tests_on_test_start, { test: test }) + end + + sig do + override.params(results: T::Array[T::Hash[String, T.untyped]], + tests_stats: T::Hash[String, T.untyped]).void + end + def pre_tests_handle_finished_test_results(results, tests_stats) + dispatch(:pre_tests_handle_finished_test_results, + { results: results, tests_stats: tests_stats }) + end + + sig { override.params(test: Models::HLK::Test, result: T::Hash[String, T.untyped]).void } + def pre_tests_handle_finished_test_result(test, result) + dispatch(:pre_tests_handle_finished_test_result, + { test: test, result: result }) + end + + private + + sig { params(raw: T::Hash[String, T.untyped]).returns(T::Hash[String, String]) } + def normalize_hooks(raw) + nested = raw['hooks'] + base = nested.is_a?(Hash) ? T.cast(nested, T::Hash[String, T.untyped]) : raw + base.each_with_object({}) do |(key, val), acc| + k = key.to_s + next if k.start_with?('_') + next if val.nil? + + s = val.to_s.strip + next if s.empty? + + acc[k] = s + end + end + + sig { params(cmd: String).returns(String) } + def resolve_command(cmd) + return cmd if cmd.start_with?('/') + + File.expand_path(cmd, T.must(__dir__)) + end + + # rubocop:disable Metrics/CyclomaticComplexity -- type dispatch for JSON env encoding + sig { params(value: T.untyped).returns(T.untyped) } + def to_json_safe(value) + case value + when nil then nil + when String, Numeric, TrueClass, FalseClass then value + when Symbol then value.to_s + when Hash then value.transform_values { |v| to_json_safe(v) } + when Array then value.map { |v| to_json_safe(v) } + when DateTime, Time then value.iso8601 + when Models::HLK::Test, Models::Driver then value.serialize + else + to_json_safe_unknown(value) + end + end + # rubocop:enable Metrics/CyclomaticComplexity + + sig { params(studio: HCKStudio).returns(T::Hash[String, T.untyped]) } + def hck_studio_payload(studio) + { 'class' => 'HCKStudio', 'engine_tag' => studio.instance_variable_get(:@tag) } + end + + sig { params(value: T.untyped).returns(T.untyped) } + def to_json_safe_unknown(value) + return value.serialize if value.respond_to?(:serialize) + + { 'class' => value.class.name, 'inspect' => value.inspect.byteslice(0, 16_384) } + end + + sig { params(value: T.untyped).returns(String) } + def encode_notification_value(value) + return '' if value.nil? + + JSON.generate(to_json_safe(value)) + end + + sig { returns(T::Hash[Symbol, T.untyped]) } + def bound_project_args + return {} if @project.nil? + + { + timestamp: @project.timestamp, + id: @project.id, + workspace_path: @project.workspace_path, + engine_tag: @project.engine_tag, + engine_platform_name: @project.engine_platform['name'], + engine_type: @project.engine_type&.to_s, + run_terminated: @project.run_terminated, + engine_name: @project.engine_name + } + end + + def bound_engine_args + return {} if @engine.nil? + + { + engine_drivers: @engine.drivers, + engine_target_name: @engine.target&.dig('name') + } + end + + def bound_setup_manager_args + return {} if @setup_manager.nil? + + { + setup_manager_kit: @setup_manager.kit + } + end + + sig { params(hook: Symbol, bound_args: T::Hash[Symbol, T.untyped]).void } + def dispatch(hook, bound_args) + cmd = @hooks[hook.to_s] + return if cmd.nil? || cmd.strip.empty? + + bound_args = bound_args.merge(bound_project_args).merge(bound_engine_args).merge(bound_setup_manager_args) + + merged_env = merge_notification_env(hook, bound_args) + execute_notification_script(hook, merged_env, resolve_command(cmd)) + end + + sig { params(hook: Symbol, bound_args: T::Hash[Symbol, T.untyped]).returns(T::Hash[String, String]) } + def merge_notification_env(hook, bound_args) + env = { + 'AUTOHCK_NOTIFICATION_HOOK' => hook.to_s, + 'AUTOHCK_NOTIFICATION_HOOK_API_1' => AUTOHCK_NOTIFICATION_HOOK_API_1, + 'AUTOHCK_NOTIFICATION_HOOK_API_2' => AUTOHCK_NOTIFICATION_HOOK_API_2, + 'AUTOHCK_NOTIFICATION_VERSION' => AutoHCK::VERSION.to_s + } + + bound_args.each do |name, value| + env[notification_env_key(name)] = encode_notification_value(value) + end + + ENV.to_h.merge(env) + end + + sig { params(name: Symbol).returns(String) } + def notification_env_key(name) + "AUTOHCK_NOTIFICATION_#{name.to_s.upcase}" + end + + sig { params(hook: Symbol, merged_env: T::Hash[String, String], script: String).void } + def execute_notification_script(hook, merged_env, script) + unless File.exist?(script) + @logger.warn("ExternalScript: #{hook} script missing: #{script}") + return + end + unless File.executable?(script) + @logger.warn("ExternalScript: #{hook} script not executable: #{script}") + return + end + + out, status = Open3.capture2e(merged_env, script) + + @logger.info("ExternalScript #{hook} finished with code #{status.exitstatus}") + return if status.success? + + @logger.warn("ExternalScript #{hook} failed (#{status.exitstatus}): #{out}") + end + end +end