diff --git a/app/jobs/r3x/run_workflow_job.rb b/app/jobs/r3x/run_workflow_job.rb index f6b41467..70303e95 100644 --- a/app/jobs/r3x/run_workflow_job.rb +++ b/app/jobs/r3x/run_workflow_job.rb @@ -19,10 +19,13 @@ def perform(workflow_key, options = nil) ctx = WorkflowContext.new( trigger: execution, - workflow_key: workflow_key + workflow_key: workflow_key, + workflow_class: workflow_class ) - workflow_class.new.run(ctx) + guard_network_access(workflow_class) do + workflow_class.new.run(ctx) + end end private @@ -61,5 +64,18 @@ def find_trigger(workflow_class:, trigger_key:) trigger end + + def guard_network_access(workflow_class) + if workflow_class.uses?(:networking) + yield + else + begin + WebMock.disable_net_connect! + yield + ensure + WebMock.allow_net_connect! + end + end + end end end diff --git a/app/lib/r3x/client/discord_webhook.rb b/app/lib/r3x/client/discord_webhook.rb deleted file mode 100644 index 68ce644c..00000000 --- a/app/lib/r3x/client/discord_webhook.rb +++ /dev/null @@ -1,26 +0,0 @@ -require "faraday" -require "faraday/retry" - -module R3x - module Client - class DiscordWebhook - def initialize(webhook_url:) - @webhook_url = webhook_url - @connection = Faraday.new do |f| - f.request :json - f.response :json - end - end - - def deliver(content:) - raise ArgumentError, "Missing Discord webhook URL" if webhook_url.blank? - - connection.post(webhook_url, { content: content }) - end - - private - - attr_reader :webhook_url, :connection - end - end -end diff --git a/lib/r3x/workflow.rb b/lib/r3x/workflow.rb index 8fa33e14..a9672c90 100644 --- a/lib/r3x/workflow.rb +++ b/lib/r3x/workflow.rb @@ -1,9 +1,12 @@ module R3x class Workflow + KNOWN_CAPABILITIES = Set.new(%i[networking filesystem shell]).freeze + class << self def inherited(subclass) super subclass._triggers = TriggerCollection.new + subclass._capabilities = Set.new end def workflow_key @@ -16,6 +19,25 @@ def trigger(type, **options) _triggers.add(trigger_instance) end + def uses(*capabilities) + incoming = Set.new(capabilities.flatten.compact.map(&:to_sym)) + unknown = incoming - KNOWN_CAPABILITIES + raise ArgumentError, "Unknown capabilities: #{unknown.to_a.join(', ')}. Known: #{KNOWN_CAPABILITIES.to_a.join(', ')}" if unknown.any? + + duplicates = incoming & _capabilities + raise ArgumentError, "Capability already declared: #{duplicates.to_a.join(', ')}" if duplicates.any? + + _capabilities.merge(incoming) + end + + def capabilities + _capabilities.dup + end + + def uses?(capability) + _capabilities.include?(capability.to_sym) + end + def triggers _triggers.to_a end @@ -29,6 +51,7 @@ def triggers_by_key end attr_accessor :_triggers + attr_accessor :_capabilities end def run(ctx) diff --git a/lib/r3x/workflow_context.rb b/lib/r3x/workflow_context.rb index 8aab33a5..06372483 100644 --- a/lib/r3x/workflow_context.rb +++ b/lib/r3x/workflow_context.rb @@ -2,11 +2,30 @@ module R3x class WorkflowContext include R3x::Concerns::Logger - attr_reader :trigger, :execution + attr_reader :trigger, :execution, :workflow_class - def initialize(trigger:, workflow_key:) + def initialize(trigger:, workflow_key:, workflow_class: nil) @trigger = trigger + @workflow_class = workflow_class @execution = WorkflowExecution.new(workflow_key: workflow_key) end + + def client + @client ||= ClientProxy.new(workflow_class: workflow_class) + end + + class ClientProxy + def initialize(workflow_class:) + @workflow_class = workflow_class + end + + def http + R3x::Client::Http.new + end + + private + + attr_reader :workflow_class + end end end diff --git a/lib/tasks/r3x_workflows.rake b/lib/tasks/r3x_workflows.rake index 60953869..689ef433 100644 --- a/lib/tasks/r3x_workflows.rake +++ b/lib/tasks/r3x_workflows.rake @@ -52,11 +52,12 @@ namespace :r3x do puts "Running workflow: #{key}" begin workflow_class = R3x::WorkflowRegistry.fetch(key) - result = workflow_class.new.run(R3x::WorkflowContext.new) + trigger = workflow_class.triggers.find(&:manual?) || R3x::Triggers::Manual.new + result = RunWorkflowJob.new.perform(key, trigger_key: trigger.unique_key) puts " ✓ Success: #{result.inspect}" rescue => e puts " ✗ Error: #{e.message}" - raise e if args[:workflow_key].present? # Re-raise if specific workflow failed + raise e if args[:workflow_key].present? end end diff --git a/test/jobs/r3x/run_workflow_job_test.rb b/test/jobs/r3x/run_workflow_job_test.rb index 5400105b..8e9da75c 100644 --- a/test/jobs/r3x/run_workflow_job_test.rb +++ b/test/jobs/r3x/run_workflow_job_test.rb @@ -12,6 +12,7 @@ class RunWorkflowJobTest < ActiveSupport::TestCase teardown do ENV["R3X_WORKFLOW_PATHS"] = @original_workflow_paths + WebMock.reset! end test "performs workflow with manual trigger" do diff --git a/test/lib/r3x/client/hashi_corp_vault_test.rb b/test/lib/r3x/client/hashi_corp_vault_test.rb index 3905cca8..6c3b6f93 100644 --- a/test/lib/r3x/client/hashi_corp_vault_test.rb +++ b/test/lib/r3x/client/hashi_corp_vault_test.rb @@ -28,7 +28,7 @@ class HashiCorpVaultTest < ActiveSupport::TestCase body: { data: { data: { - discord_webhook_url: "https://discord.test", + api_key: "test-api-key", mode: "real" }, metadata: { version: 1 } @@ -40,7 +40,7 @@ class HashiCorpVaultTest < ActiveSupport::TestCase result = HashiCorpVault.read("secret/data/env/r3x") assert_equal({ - "discord_webhook_url" => "https://discord.test", + "api_key" => "test-api-key", "mode" => "real" }, result) end diff --git a/test/lib/r3x/workflow_test.rb b/test/lib/r3x/workflow_test.rb index 6483be5c..f6a68ccc 100644 --- a/test/lib/r3x/workflow_test.rb +++ b/test/lib/r3x/workflow_test.rb @@ -93,7 +93,7 @@ def self.name def self.name "Test" end - trigger :schedule, cron: "0 13 * * *" + trigger :schedule, cron: "0 * * * *" end triggers = klass.triggers @@ -101,6 +101,50 @@ def self.name assert_equal [ :schedule ], triggers.map(&:type) end + test "uses declares workflow capabilities" do + klass = Class.new(R3x::Workflow) do + def self.name + "Workflows::NetworkedWorkflow" + end + + uses :networking + end + + assert_equal Set.new([ :networking ]), klass.capabilities + assert klass.uses?(:networking) + refute klass.uses?(:filesystem) + end + + test "uses raises on duplicate capability" do + klass = Class.new(R3x::Workflow) do + def self.name + "Workflows::DuplicateCap" + end + + uses :networking + end + + error = assert_raises(ArgumentError) do + klass.uses(:networking) + end + + assert_match "Capability already declared", error.message + end + + test "uses raises on unknown capability" do + error = assert_raises(ArgumentError) do + Class.new(R3x::Workflow) do + def self.name + "Workflows::BadCap" + end + + uses :hacking + end + end + + assert_match "Unknown capabilities: hacking", error.message + end + test "trigger :schedule rejects blank cron (empty string and whitespace)" do [ "", diff --git a/test/test_helper.rb b/test/test_helper.rb index 600ccab2..265bca70 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -3,6 +3,8 @@ require "rails/test_help" require "webmock/minitest" +WebMock.disable_net_connect! + module ActiveSupport class TestCase # Run tests in parallel with specified workers