Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 106 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,24 @@ 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 the E2E_TEST_OSS_CONDUCTOR_VERSION org var, then to the default in scripts/docker-compose-oss.yaml on fork PRs)'
required: false
type: string

# Supersede an in-flight run when a new commit lands on the same ref, matching
# python-sdk and go-sdk. The integration jobs make this more than a runner-time
# saving: the cloud job mutates shared state on the sdkdev tenant (scheduler_spec
# calls pause_all_schedules/resume_all_schedules, which are not scoped to a
# test_id), so two runs of the same branch overlapping will fight each other.
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true

jobs:
test:
Expand Down Expand Up @@ -86,7 +101,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
Expand All @@ -105,9 +120,95 @@ 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'
steps:
# OSS_CONDUCTOR_VERSION is resolved here rather than in the job `env` so
# that the two ways it can come back empty get different treatment:
#
# - Fork PR: GitHub withholds org/repo variables from pull_request runs
# on forks exactly as it withholds secrets, so vars.* is always "" for
# an outside contributor (observed in csharp-sdk#178). This job needs
# no secrets, only a tag, so leave the var unset and let the default
# baked into the `image:` line of scripts/docker-compose-oss.yaml
# apply. That is the same tag a plain local run of
# scripts/run-integration-oss.sh gets, and the one place it is
# written -- no second copy to drift out of sync here.
# - Anything else: the org variable is genuinely missing or its
# repository access policy no longer covers this repo. Fail loudly
# rather than silently drifting onto the default.
- name: Resolve OSS Conductor version
env:
REQUESTED_VERSION: ${{ inputs.oss_conductor_version || vars.E2E_TEST_OSS_CONDUCTOR_VERSION }}
IS_FORK_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }}
run: |
if [ -n "$REQUESTED_VERSION" ]; then
echo "OSS_CONDUCTOR_VERSION=${REQUESTED_VERSION}" >> "$GITHUB_ENV"
elif [ "$IS_FORK_PR" = "true" ]; then
echo "::notice::Fork PR: org variables are withheld, falling back to the default tag in scripts/docker-compose-oss.yaml"
else
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

- 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

# `docker compose up` only pulls an image when it is missing locally. On a
# GitHub-hosted runner the VM is ephemeral and starts with no cached copy
# of this image, so `up` would pull anyway and this step is redundant
# today. It is here deliberately: it costs no extra network pull (`up`
# then finds the image locally), it separates "couldn't pull the image"
# from "the stack didn't come up" into two distinct red steps, and it is
# what keeps a mutable tag from going stale if this job ever moves to a
# self-hosted runner with a warm Docker daemon -- the same reason
# scripts/run-integration-oss.sh pulls. It also prints the tag actually in
# use, which for a fork PR comes from the compose file's default.
- name: Pull Conductor OSS image
run: |
echo "Using $(docker compose -f scripts/docker-compose-oss.yaml config --images | grep -m1 '^conductoross/conductor:')"
docker compose -f scripts/docker-compose-oss.yaml pull conductor-server

- name: Start Conductor OSS stack
run: docker compose -f scripts/docker-compose-oss.yaml up -d

- name: Wait for Conductor to be healthy
# Matches HEALTH_TIMEOUT in scripts/run-integration-oss.sh, and stays under the compose
# healthcheck's own ~200s budget (10s x 20 retries). `curl -sf` alone is the check:
# /health answers non-2xx while the server is still coming up.
run: timeout 180 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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Task classes: `SimpleTask`, `SwitchTask`, `ForkTask`, `JoinTask`, `DoWhileTask`, `HttpTask`, `SubWorkflowTask`, `WaitTask`, `TerminateTask`, `SetVariableTask`, `DynamicForkTask`, `JavascriptTask`, `JsonJqTask`, `EventTask`, `HttpPollTask`, `DynamicTask`, `HumanTask`, `StartWorkflowTask`, `KafkaPublishTask`, `WaitForWebhookTask`
- LLM task classes: `LlmChatCompleteTask`, `LlmTextCompleteTask`, `LlmGenerateEmbeddingsTask`, `LlmIndexTextTask`, `LlmIndexDocumentTask`, `LlmSearchIndexTask`, `LlmQueryEmbeddingsTask`, `LlmStoreEmbeddingsTask`, `LlmSearchEmbeddingsTask`, `GenerateImageTask`, `GenerateAudioTask`, `GetDocumentTask`, `ListMcpToolsTask`, `CallMcpToolTask`

### Fixed

- `SchedulerResourceApi#pause_schedule` / `#resume_schedule` now work against both Conductor server families. The client sends `PUT` first and falls back to `GET` on a `405` -- and only on a `405`. OSS Conductor maps these two per-schedule routes `@PutMapping`-only, so the previous `GET`-only calls failed there outright; Orkes Conductor accepts both verbs as of the dual `@RequestMapping(method = {GET, PUT})` added in 2026-07, and is `GET`-only in deployments older than that. `pause_all_schedules` / `resume_all_schedules` remain `GET`, which is how both families map those admin endpoints. Matches the python-sdk, go-sdk, javascript-sdk, csharp-sdk and rust-sdk clients; `spec/conductor/http/api/scheduler_resource_api_spec.rb` pins the whole contract
- `Conductor::AuthenticationSettings` is no longer referenced as `Conductor::Configuration::AuthenticationSettings`, which raised `NameError: uninitialized constant`. The class has always been defined directly under `Conductor`. Fixed in `RactorTaskRunner`'s in-Ractor configuration rebuild (where it was a live failure) and in the `Conductor` / `OrkesClients` doc comments (where it told users to write the broken form)

### Migration Guide

**Before (old DSL):**
Expand Down
2 changes: 1 addition & 1 deletion lib/conductor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ def config
# @example
# Conductor.configure do |config|
# config.server_url = 'http://localhost:7001/api'
# config.authentication_settings = Conductor::Configuration::AuthenticationSettings.new(
# config.authentication_settings = Conductor::AuthenticationSettings.new(
# key_id: 'my_key',
# key_secret: 'my_secret'
# )
Expand Down
43 changes: 31 additions & 12 deletions lib/conductor/http/api/scheduler_resource_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions lib/conductor/http/rest_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,20 @@ def build_connection
end

# Retry middleware (3 retries with exponential backoff)
#
# `exceptions` has to be spelled out: faraday-retry's
# DEFAULT_EXCEPTIONS covers timeouts but not Faraday::ConnectionFailed,
# which is what the net_http_persistent adapter raises for
# Errno::ECONNRESET/EPIPE -- a write to a pooled socket the peer closed
# first. net-http-persistent retries stale sockets itself, but only for
# idempotent requests, so POSTs surfaced these as hard ApiErrors while
# every GET was silently protected.
conn.request :retry,
max: 3,
interval: 0.5,
backoff_factor: 2,
retry_statuses: [408, 429, 500, 502, 503, 504],
exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [Faraday::ConnectionFailed],
methods: %i[get post put patch delete]

# Connection settings
Expand Down
2 changes: 1 addition & 1 deletion lib/conductor/orkes/orkes_clients.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ module Orkes
# Usage:
# config = Conductor::Configuration.new
# config.server_url = 'https://developer.orkescloud.com/api'
# config.authentication_settings = Conductor::Configuration::AuthenticationSettings.new(
# config.authentication_settings = Conductor::AuthenticationSettings.new(
# key_id: 'your_key', key_secret: 'your_secret'
# )
# clients = Conductor::Orkes::OrkesClients.new(config)
Expand Down
2 changes: 1 addition & 1 deletion lib/conductor/worker/ractor_task_runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ def setup_ractor_resources
server_api_url: @configuration_hash[:server_api_url]
)
if @configuration_hash[:authentication_settings]
config.authentication_settings = Configuration::AuthenticationSettings.new(
config.authentication_settings = AuthenticationSettings.new(
key_id: @configuration_hash[:authentication_settings][:key_id],
key_secret: @configuration_hash[:authentication_settings][:key_secret]
)
Expand Down
49 changes: 49 additions & 0 deletions scripts/docker-compose-oss.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Conductor OSS stack used to run the SDK integration specs against open-source
# Conductor. Shared by scripts/run-integration-oss.sh and the
# integration-tests-oss job in .github/workflows/ci.yml.
#
# The `image:` default below is the SINGLE place the Conductor OSS image tag is
# written. Everything that does not override OSS_CONDUCTOR_VERSION lands on it:
# a plain `scripts/run-integration-oss.sh` run, and the integration-tests-oss
# job on a fork PR (where GitHub withholds org variables). Overrides are the
# script's --version flag and, in CI, the E2E_TEST_OSS_CONDUCTOR_VERSION org
# variable or a workflow_dispatch input. Bump the tag here and both follow.
#
# Note that the org variable is currently set to `latest`, so a normal CI run
# still tracks whatever `latest` resolves to at run time; this default is what
# actually pins fork PRs and local runs until someone sets it to a real tag.
services:
conductor-server:
image: conductoross/conductor:${OSS_CONDUCTOR_VERSION:-3.32.3}
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
Loading
Loading