diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff68306..f155a2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,15 @@ name: CI on: push: - branches: [main, develop] + branches: [main, develop, e2e-against-conductor-with-local-script] # TODO: Remove e2e-against-conductor-with-local-script branch after merging pull_request: branches: [main] + workflow_dispatch: + inputs: + oss_conductor_version: + description: 'OSS Conductor image tag (falls back to E2E_TEST_OSS_CONDUCTOR_VERSION org var)' + required: false + type: string jobs: test: @@ -86,7 +92,7 @@ jobs: integration-test: name: Integration Tests runs-on: ubuntu-latest - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' needs: [test, build] steps: - name: Checkout code @@ -105,9 +111,58 @@ jobs: if: env.CONDUCTOR_SERVER_URL != '' env: CONDUCTOR_INTEGRATION: 'true' - CONDUCTOR_SERVER_URL: ${{ secrets.CONDUCTOR_SERVER_URL }} - CONDUCTOR_AUTH_KEY: ${{ secrets.CONDUCTOR_AUTH_KEY }} + CONDUCTOR_SERVER_URL: ${{ vars.CONDUCTOR_SERVER_URL }} + CONDUCTOR_AUTH_KEY: ${{ vars.CONDUCTOR_AUTH_KEY }} CONDUCTOR_AUTH_SECRET: ${{ secrets.CONDUCTOR_AUTH_SECRET }} run: | bundle exec rspec spec/integration/ --format documentation - continue-on-error: true + + # Integration tests (OSS): spins up Conductor OSS + Postgres via + # scripts/docker-compose-oss.yaml and runs the integration spec suite against + # it unauthenticated. The same stack can be run locally with + # scripts/run-integration-oss.sh. Unlike the cloud `integration-test` + # job above, this needs no secrets, so it runs on every push/PR and is not + # continue-on-error. + integration-tests-oss: + name: Integration Tests (OSS) + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: [test, build] + env: + CONDUCTOR_SERVER_URL: http://localhost:8080/api + CONDUCTOR_SERVER_TYPE: oss + CONDUCTOR_INTEGRATION: 'true' + OSS_CONDUCTOR_VERSION: ${{ inputs.oss_conductor_version || vars.E2E_TEST_OSS_CONDUCTOR_VERSION }} + steps: + - name: Verify OSS Conductor version is set + run: | + if [ -z "$OSS_CONDUCTOR_VERSION" ]; then + echo "::error::No Conductor OSS image tag resolved. Set the E2E_TEST_OSS_CONDUCTOR_VERSION organization variable (and ensure its repository access policy includes this repo), or pass the oss_conductor_version input via workflow_dispatch." + exit 1 + fi + echo "Using conductoross/conductor:$OSS_CONDUCTOR_VERSION" + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + bundler-cache: true + + - name: Install dependencies + run: bundle install + + - name: Start Conductor OSS stack + run: docker compose -f scripts/docker-compose-oss.yaml up -d + + - name: Wait for Conductor to be healthy + run: timeout 120 bash -c 'until curl -sf http://localhost:8080/health; do sleep 5; done' + + - name: Run integration tests (OSS) + run: bundle exec rspec spec/integration/ --format documentation + + - name: Dump Conductor logs + if: failure() + run: docker compose -f scripts/docker-compose-oss.yaml logs conductor-server diff --git a/lib/conductor/http/api/scheduler_resource_api.rb b/lib/conductor/http/api/scheduler_resource_api.rb index e503b35..ff14799 100644 --- a/lib/conductor/http/api/scheduler_resource_api.rb +++ b/lib/conductor/http/api/scheduler_resource_api.rb @@ -67,27 +67,24 @@ def delete_schedule(name) end # Pause a schedule + # + # Per-schedule pause/resume is PUT-mapped on OSS Conductor but GET-only + # on some Orkes Conductor deployments. PUT is tried first and a 405 + # response falls back to GET, mirroring the python-sdk/csharp-sdk/rust-sdk + # clients. # @param [String] name Schedule name # @return [void] def pause_schedule(name) - @api_client.call_api( - '/scheduler/schedules/{name}/pause', - 'GET', - path_params: { name: name }, - return_http_data_only: true - ) + call_with_verb_fallback('/scheduler/schedules/{name}/pause', name) end # Resume a schedule + # + # See {#pause_schedule} for the PUT-with-GET-fallback rationale. # @param [String] name Schedule name # @return [void] def resume_schedule(name) - @api_client.call_api( - '/scheduler/schedules/{name}/resume', - 'GET', - path_params: { name: name }, - return_http_data_only: true - ) + call_with_verb_fallback('/scheduler/schedules/{name}/resume', name) end # Pause all schedules @@ -205,6 +202,28 @@ def delete_tag_for_schedule(name, tags) return_http_data_only: true ) end + + private + + # Try PUT first (OSS dialect); fall back to GET on 405 (some Orkes + # deployments only accept GET for these two routes). + def call_with_verb_fallback(templated_path, name) + @api_client.call_api( + templated_path, + 'PUT', + path_params: { name: name }, + return_http_data_only: true + ) + rescue Conductor::ApiError => e + raise unless e.status == 405 + + @api_client.call_api( + templated_path, + 'GET', + path_params: { name: name }, + return_http_data_only: true + ) + end end end end diff --git a/scripts/docker-compose-oss.yaml b/scripts/docker-compose-oss.yaml new file mode 100644 index 0000000..c1e8f66 --- /dev/null +++ b/scripts/docker-compose-oss.yaml @@ -0,0 +1,35 @@ +services: + conductor-server: + image: conductoross/conductor:${OSS_CONDUCTOR_VERSION:-latest} + environment: + - CONFIG_PROP=config-postgres.properties + # Dummy, non-sensitive value so OSS's bundled env-backed SecretsDAO has + # something real to read back in spec/integration/orkes_spec.rb (get/list/ + # exists). OSS Conductor has no authentication at all, so an + # unauthenticated /api/secrets/{key} read doesn't change the threat + # model versus any other unauthenticated OSS endpoint -- don't put a + # real credential here. + - CONDUCTOR_SECRET_RUBY_SDK_INTEGRATION_TEST=ruby-sdk-oss-secret-value + ports: + - "8080:8080" + healthcheck: + test: ["CMD", "curl", "-I", "-XGET", "http://localhost:8080/health"] + interval: 10s + timeout: 10s + retries: 20 + links: + - conductor-postgres:postgresdb + depends_on: + conductor-postgres: + condition: service_healthy + + conductor-postgres: + image: postgres:16 + environment: + - POSTGRES_USER=conductor + - POSTGRES_PASSWORD=conductor + healthcheck: + test: timeout 5 bash -c 'cat < /dev/null > /dev/tcp/localhost/5432' + interval: 5s + timeout: 5s + retries: 12 diff --git a/scripts/run-integration-oss.sh b/scripts/run-integration-oss.sh new file mode 100755 index 0000000..78cd760 --- /dev/null +++ b/scripts/run-integration-oss.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# +# Spin up a local Conductor OSS stack and run the integration spec suite +# against it, mirroring the `integration-tests-oss` job in +# .github/workflows/ci.yml. Orkes-Enterprise-only specs/examples are skipped +# via the existing `skip: !ENV['CONDUCTOR_INTEGRATION']` pattern extended with +# an OSS-aware condition (see the individual spec files for the +# empirically-confirmed gaps). +# +# The stack (Conductor OSS + Postgres) is defined in +# scripts/docker-compose-oss.yaml and is torn down automatically on exit. +# +# Usage: +# scripts/run-integration-oss.sh [--keep-up] [--version ] [-- rspec args] +# Examples: +# scripts/run-integration-oss.sh +# scripts/run-integration-oss.sh --version 3.32.0-rc18 +# scripts/run-integration-oss.sh --keep-up +# scripts/run-integration-oss.sh -- spec/integration/workflow_spec.rb +set -euo pipefail + +KEEP_UP=0 +extra=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --keep-up) KEEP_UP=1; shift ;; + --version) OSS_CONDUCTOR_VERSION="${2:?--version needs a tag}"; shift 2 ;; + -h|--help) + echo "Usage: $0 [--keep-up] [--version ] [-- rspec args]" + exit 0 + ;; + --) shift; extra=("$@"); break ;; + *) echo "Unknown argument: $1" >&2; exit 1 ;; + esac +done + +export OSS_CONDUCTOR_VERSION="${OSS_CONDUCTOR_VERSION:-latest}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +COMPOSE_FILE="${SCRIPT_DIR}/docker-compose-oss.yaml" +cd "${REPO_ROOT}" + +compose() { docker compose -f "${COMPOSE_FILE}" "$@"; } + +cleanup() { + if [[ "${KEEP_UP}" == "1" ]]; then + echo "--keep-up set: leaving the OSS stack running. Tear down with:" + echo " docker compose -f ${COMPOSE_FILE} down -v" + return + fi + echo "Tearing down Conductor OSS stack..." + compose down -v || true +} +trap cleanup EXIT + +echo "Starting Conductor OSS stack (conductoross/conductor:${OSS_CONDUCTOR_VERSION})..." +compose up -d + +echo "Waiting for Conductor to be healthy..." +HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-180}" +deadline=$(( SECONDS + HEALTH_TIMEOUT )) +until curl -sf http://localhost:8080/health >/dev/null 2>&1; do + if (( SECONDS >= deadline )); then + echo "Error: Conductor did not become healthy within ${HEALTH_TIMEOUT}s." >&2 + compose logs conductor-server || true + exit 1 + fi + sleep 5 +done +echo "Conductor is up." + +export CONDUCTOR_SERVER_URL="http://localhost:8080/api" +export CONDUCTOR_SERVER_TYPE="oss" +export CONDUCTOR_INTEGRATION="true" + +bundle exec rspec spec/integration/ --format documentation ${extra[@]+"${extra[@]}"} diff --git a/spec/integration/event_spec.rb b/spec/integration/event_spec.rb index 880a6f2..34279c6 100644 --- a/spec/integration/event_spec.rb +++ b/spec/integration/event_spec.rb @@ -394,8 +394,10 @@ def skip_if_limit_reached(error) event_api.get_queue_config(queue_type, queue_name) end.to raise_error(Conductor::ApiError) { |e| expect(e.status).to eq(404) } rescue Conductor::ApiError => e - # Queue operations may not be available - if e.status == 501 || e.message.include?('not supported') + # Queue operations may not be available. OSS Conductor doesn't register this route at + # all (plain 404), whereas Orkes Cloud registers it but deprecates it in favor of the + # integrations API (400/501/403 below). + if e.status == 404 || e.status == 501 || e.message.include?('not supported') skip 'Queue configuration API not available in this environment' elsif e.status == 400 && e.message.include?('integrations API') skip 'Queue configuration is managed via integrations API in Orkes Cloud' diff --git a/spec/integration/integration_helper.rb b/spec/integration/integration_helper.rb index 14c0c83..c65ac56 100644 --- a/spec/integration/integration_helper.rb +++ b/spec/integration/integration_helper.rb @@ -32,7 +32,7 @@ def self.configuration key_id = ENV.fetch('CONDUCTOR_AUTH_KEY', nil) key_secret = ENV.fetch('CONDUCTOR_AUTH_SECRET', nil) if key_id && key_secret - config.authentication_settings = Conductor::Configuration::AuthenticationSettings.new( + config.authentication_settings = Conductor::AuthenticationSettings.new( key_id: key_id, key_secret: key_secret ) diff --git a/spec/integration/orkes_spec.rb b/spec/integration/orkes_spec.rb index 8412cce..377774a 100644 --- a/spec/integration/orkes_spec.rb +++ b/spec/integration/orkes_spec.rb @@ -36,6 +36,10 @@ def skip_if_limit_reached(error) skip "Orkes free tier limit reached: #{error.message}" end + def oss? + ENV['CONDUCTOR_SERVER_TYPE'] == 'oss' + end + describe 'OrkesClients factory' do it 'creates all client types successfully' do expect(clients.get_workflow_client).to be_a(Conductor::Client::WorkflowClient) @@ -56,6 +60,16 @@ def skip_if_limit_reached(error) let(:secret_key) { "#{test_id}_secret" } let(:secret_value) { "test_secret_value_#{SecureRandom.hex(8)}" } + # OSS Conductor registers a full secrets CRUD controller by default (the + # `agentspan` module's `conductor.integrations.ai.enabled=true` default), + # but only ships read-only SecretsDAO backends: writes (put/delete) return + # a real 501 "read-only backend" rather than succeeding. Reads work + # against an env-backed secret seeded via + # CONDUCTOR_SECRET_RUBY_SDK_INTEGRATION_TEST in scripts/docker-compose-oss.yaml + # -- keep these two constants in sync with that file. + OSS_SEEDED_SECRET_NAME = 'RUBY_SDK_INTEGRATION_TEST' + OSS_SEEDED_SECRET_VALUE = 'ruby-sdk-oss-secret-value' + after do # Clean up: delete the test secret if it exists @@ -65,32 +79,54 @@ def skip_if_limit_reached(error) end it 'performs CRUD operations on secrets' do - # Create - secret_client.put_secret(secret_key, secret_value) + if oss? + # Verify reads work against the pre-seeded env-backed secret, and that + # writes fail with a real 501 (read-only backend) rather than silently + # succeeding or failing for some other reason. + expect(secret_client.get_secret(OSS_SEEDED_SECRET_NAME)).to eq(OSS_SEEDED_SECRET_VALUE) + expect(secret_client.secret_exists(OSS_SEEDED_SECRET_NAME)).to be true + expect(secret_client.list_all_secret_names).to include(OSS_SEEDED_SECRET_NAME) + + begin + secret_client.put_secret(secret_key, secret_value) + # A future OSS release might ship a writable backend; if so, clean up. + secret_client.delete_secret(secret_key) + rescue Conductor::ApiError => e + raise unless e.status == 501 + end + else + # Create + secret_client.put_secret(secret_key, secret_value) - # Verify it exists - exists = secret_client.secret_exists(secret_key) - expect(exists).to be true + # Verify it exists + exists = secret_client.secret_exists(secret_key) + expect(exists).to be true - # List secrets should include our key - secrets = secret_client.list_all_secret_names - expect(secrets).to include(secret_key) + # List secrets should include our key + secrets = secret_client.list_all_secret_names + expect(secrets).to include(secret_key) - # Get secret (note: Orkes may return masked value or the actual value depending on permissions) - retrieved = secret_client.get_secret(secret_key) - expect(retrieved).not_to be_nil + # Get secret (note: Orkes may return masked value or the actual value depending on permissions) + retrieved = secret_client.get_secret(secret_key) + expect(retrieved).not_to be_nil - # Delete - secret_client.delete_secret(secret_key) + # Delete + secret_client.delete_secret(secret_key) - # Verify deleted - exists_after = secret_client.secret_exists(secret_key) - expect(exists_after).to be false + # Verify deleted + exists_after = secret_client.secret_exists(secret_key) + expect(exists_after).to be false + end rescue Conductor::ApiError => e skip_if_limit_reached(e) end it 'handles secret tags' do + if oss? + skip 'Secret tags require a writable secrets backend; OSS only ships read-only ' \ + 'SecretsDAO implementations (env/no-op)' + end + # Create secret first secret_client.put_secret(secret_key, secret_value) @@ -121,6 +157,13 @@ def skip_if_limit_reached(error) describe 'SchemaClient' do let(:schema_client) { clients.get_schema_client } + before do + if oss? + skip 'Schema registry API not implemented in OSS Conductor (SchemaDef is only an inline ' \ + 'WorkflowDef/TaskDef field; there is no standalone SchemaResource/DAO)' + end + end + it 'lists all schemas' do # This should work even on free tier all_schemas = schema_client.get_all_schemas @@ -178,6 +221,14 @@ def skip_if_limit_reached(error) describe 'AuthorizationClient' do let(:auth_client) { clients.get_authorization_client } + before do + if oss? + skip 'Authorization/RBAC API not implemented in OSS Conductor (no users/roles/groups/' \ + 'applications/permissions resource at all; OSS explicitly ships with ' \ + 'ACCESS_MANAGEMENT/RBAC disabled)' + end + end + describe 'token operations' do it 'gets user info from current token' do user_info = auth_client.get_user_info_from_token @@ -353,15 +404,10 @@ def skip_if_limit_reached(error) it 'creates and registers a workflow using the DSL' do # Build workflow using DSL - workflow = Conductor::Workflow::ConductorWorkflow.new(executor: workflow_executor) - workflow.name = workflow_name - workflow.version = 1 - workflow.description = 'Ruby SDK DSL test on Orkes' - - # Add a simple set variable task - set_var = Conductor::Workflow::SetVariableTask.new('set_greeting') - set_var.input('greeting', '${workflow.input.name}') - workflow.add(set_var) + workflow = Conductor.workflow(workflow_name, version: 1, description: 'Ruby SDK DSL test on Orkes', + executor: workflow_executor) do + set greeting: wf[:name] + end # Register the workflow workflow.register(overwrite: true) @@ -379,6 +425,14 @@ def skip_if_limit_reached(error) describe 'IntegrationClient' do let(:integration_client) { clients.get_integration_client } + before do + if oss? + skip 'Integration Hub API not implemented in OSS Conductor (no IntegrationResource/DAO; ' \ + "the OSS agentspan module's ProviderController only exposes /api/providers/status, " \ + 'a fixed-list LLM-provider health check, not an integration-def registry)' + end + end + it 'lists available integrations' do # This just verifies the API call works (may return empty array) integrations = integration_client.get_integrations @@ -399,6 +453,14 @@ def skip_if_limit_reached(error) describe 'PromptClient' do let(:prompt_client) { clients.get_prompt_client } + before do + if oss? + skip 'Prompt template management API not implemented in OSS Conductor (PromptTemplateRef ' \ + 'is only an inert, unresolved reference field on AGENT tasks; there is no ' \ + 'PromptResource/DAO backing a named template store)' + end + end + it 'lists available prompts' do # This just verifies the API call works (may return empty array) prompts = prompt_client.get_prompts diff --git a/spec/integration/prompt_spec.rb b/spec/integration/prompt_spec.rb index bc4025f..a605f14 100644 --- a/spec/integration/prompt_spec.rb +++ b/spec/integration/prompt_spec.rb @@ -42,6 +42,15 @@ def skip_if_limit_reached(error) skip "Orkes free tier limit reached: #{error.message}" end + # Prompt template management is not implemented in OSS Conductor: there is no + # PromptResource/DAO. The `agentspan` module's `PromptTemplateRef` is only an + # inert, unresolved reference field on AGENT tasks -- nothing ever persists or + # looks up a named template by content. See orkes_spec.rb's PromptClient tests + # for the equivalent OSS-aware gating. + before do + skip 'Prompt template management API not implemented in OSS Conductor' if ENV['CONDUCTOR_SERVER_TYPE'] == 'oss' + end + describe 'Prompt CRUD Operations' do let(:prompt_name) { "#{test_id}_test_prompt" } let(:prompt_template) do diff --git a/spec/integration/scheduler_spec.rb b/spec/integration/scheduler_spec.rb index 6ab3e59..e787edc 100644 --- a/spec/integration/scheduler_spec.rb +++ b/spec/integration/scheduler_spec.rb @@ -56,6 +56,10 @@ def skip_if_limit_reached(error) skip "Orkes free tier limit reached: #{error.message}" end + def oss? + ENV['CONDUCTOR_SERVER_TYPE'] == 'oss' + end + # Helper to get attribute from schedule object or hash def get_schedule_attr(schedule, attr_name) value = if schedule.is_a?(Hash) @@ -641,6 +645,11 @@ def get_schedule_attr(schedule, attr_name) let(:schedule_name) { "#{test_id}_tag_test" } before do + if oss? + skip 'Schedule tagging API not implemented in OSS Conductor (no /scheduler/schedules/{name}/tags ' \ + 'route; scheduler tags are an Orkes-only addition)' + end + # Ensure the test workflow exists begin workflow_def = Conductor::Http::Models::WorkflowDef.new( diff --git a/spec/integration/task_ops_spec.rb b/spec/integration/task_ops_spec.rb index c45df6d..b8526c5 100644 --- a/spec/integration/task_ops_spec.rb +++ b/spec/integration/task_ops_spec.rb @@ -445,10 +445,14 @@ def skip_if_limit_reached(error) end it 'search - with query filter' do + # Use the portable `field = "value"` query syntax (not Lucene `field:value` + # colon syntax) so this also works against OSS Conductor's default + # Postgres-backed indexing, which only understands `=`/`>`/`<`/`IN` + # conditions, not full Lucene grammar. results = task_api.search( start: 0, size: 5, - query: "taskType:#{test_id}_simple_task" + query: "taskType = \"#{test_id}_simple_task\"" ) expect(results).not_to be_nil diff --git a/spec/integration/worker_e2e_spec.rb b/spec/integration/worker_e2e_spec.rb index b6796b5..02fc15f 100644 --- a/spec/integration/worker_e2e_spec.rb +++ b/spec/integration/worker_e2e_spec.rb @@ -215,23 +215,19 @@ it 'builds, registers, and executes a workflow using the DSL' do # Build workflow using DSL - workflow = Conductor::Workflow::ConductorWorkflow.new( - executor: Conductor::Workflow::WorkflowExecutor.new(IntegrationHelper.configuration) - ) - dsl_wf_name = IntegrationHelper.test_name('dsl_workflow') + dsl_task_name = task_name # capture the `let` into a local: the block below is + # instance_eval'd against the WorkflowBuilder, so bare method calls (like the + # `task_name` helper) wouldn't resolve there, but closed-over locals still do. + executor = Conductor::Workflow::WorkflowExecutor.new(configuration) - workflow.name = dsl_wf_name - workflow.version = 1 - workflow.description = 'DSL integration test' - workflow.timeout_seconds = 300 - workflow.owner_email = 'test@example.com' + workflow = Conductor.workflow(dsl_wf_name, version: 1, description: 'DSL integration test', + executor: executor) do + timeout 300 + owner_email 'test@example.com' - # Add a simple task - simple = Conductor::Workflow::SimpleTask.new(task_name, "#{task_name}_dsl_ref") - simple.input('value', '${workflow.input.value}') - - workflow >> simple + simple dsl_task_name, value: wf[:value] + end # Register the workflow wf_def = workflow.to_workflow_def diff --git a/spec/integration/workflow_ops_spec.rb b/spec/integration/workflow_ops_spec.rb index b0165e7..90c4f48 100644 --- a/spec/integration/workflow_ops_spec.rb +++ b/spec/integration/workflow_ops_spec.rb @@ -45,6 +45,10 @@ def skip_if_limit_reached(error) skip "Orkes free tier limit reached: #{error.message}" end + def oss? + ENV['CONDUCTOR_SERVER_TYPE'] == 'oss' + end + # Helper to get workflow status def get_status(workflow) workflow.is_a?(Hash) ? workflow['status'] : workflow.status @@ -381,11 +385,14 @@ def get_status(workflow) end it 'search - searches workflows with query' do - # Search for our workflow by name + # Search for our workflow by name. Use the portable `field = "value"` query + # syntax (not Lucene `field:value` colon syntax) so this also works against + # OSS Conductor's default Postgres-backed indexing, which only understands + # `=`/`>`/`<`/`IN` conditions, not full Lucene grammar. results = workflow_api.search( start: 0, size: 10, - query: "workflowType:#{test_id}_simple_workflow" + query: "workflowType = \"#{test_id}_simple_workflow\"" ) expect(results).not_to be_nil @@ -574,6 +581,12 @@ def get_status(workflow) let(:workflow_id) { @workflow_id } before do + if oss? + skip 'update_workflow_state not implemented in OSS Conductor (no POST /workflow/{id}/variables ' \ + 'route; this is an Orkes-only addition, distinct from the SET_VARIABLE task type which ' \ + 'OSS does support)' + end + begin workflow_def = Conductor::Http::Models::WorkflowDef.new( name: "#{test_id}_wait_workflow",