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
59 changes: 45 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -171,18 +171,12 @@ jobs:

cloud-test:
# Secrets are unavailable to workflows from forks.
if: ${{ github.event.pull_request.head.repo.full_name == '' || github.event.pull_request.head.repo.full_name == 'temporalio/sdk-ruby' }}
if: ${{ (github.event.pull_request.head.repo.full_name == '' || github.event.pull_request.head.repo.full_name == 'temporalio/sdk-ruby') && github.actor != 'dependabot[bot]' }}
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: sdk-ruby-cloud-test
cancel-in-progress: false
timeout-minutes: 45
env:
TEMPORAL_TEST_ENV_CONFIG_SERVER: "1"
TEMPORAL_ADDRESS: ca-central-1.aws.api.temporal.io:7233
TEMPORAL_NAMESPACE: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }}
TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }}
TEMPORAL_GRPC_META_TEMPORAL_NAMESPACE: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }}
TEMPORAL_CLIENT_CLOUD_API_VERSION: v0.19.1
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
Expand Down Expand Up @@ -215,12 +209,49 @@ jobs:
working-directory: ./temporalio
run: bundle exec rake compile

- name: Generate Cloud test certificates
run: |
cert_dir="$RUNNER_TEMP/cloud-test-certs"
mkdir "$cert_dir"
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-keyout "$cert_dir/ca.key" -out "$cert_dir/ca.pem" \
-subj '/CN=Temporal Ruby SDK Cloud CI CA'
openssl req -newkey rsa:2048 -nodes \
-keyout "$cert_dir/client.key" -out "$cert_dir/client.csr" \
-subj '/CN=Temporal Ruby SDK Cloud CI'
openssl x509 -req -days 1 -in "$cert_dir/client.csr" \
-CA "$cert_dir/ca.pem" -CAkey "$cert_dir/ca.key" -CAcreateserial \
-out "$cert_dir/client.pem" -extfile <(printf 'extendedKeyUsage=clientAuth')
{
echo "TEMPORAL_CLOUD_CLIENT_CA_PATH=$cert_dir/ca.pem"
echo "TEMPORAL_TLS_CLIENT_CERT_PATH=$cert_dir/client.pem"
echo "TEMPORAL_TLS_CLIENT_KEY_PATH=$cert_dir/client.key"
} >> "$GITHUB_ENV"

- name: Create Cloud namespace
id: create-cloud-namespace
working-directory: ./temporalio
run: bundle exec ruby extra/cloud_namespace.rb create
env:
TEMPORAL_CLIENT_CLOUD_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }}

- name: Test Ruby against Temporal Cloud
if: ${{ env.TEMPORAL_NAMESPACE != '' && env.TEMPORAL_API_KEY != '' }}
working-directory: ./temporalio
timeout-minutes: 20
timeout-minutes: 15
env:
TEMPORAL_ADDRESS: ${{ steps.create-cloud-namespace.outputs.namespace }}.tmprl.cloud:7233
TEMPORAL_NAMESPACE: ${{ steps.create-cloud-namespace.outputs.namespace }}
run: bundle exec rake test TEST=test/worker_workflow_test.rb TESTOPTS="--name=/WorkerWorkflowTest#test_simple/"

- name: Report unavailable Cloud configuration
if: ${{ env.TEMPORAL_NAMESPACE == '' || env.TEMPORAL_API_KEY == '' }}
run: echo "Temporal Cloud test skipped because its namespace or API key is unavailable"
- name: Delete Cloud namespace
id: delete-cloud-namespace
if: ${{ always() && steps.create-cloud-namespace.outputs.namespace != '' }}
continue-on-error: true
working-directory: ./temporalio
run: bundle exec ruby extra/cloud_namespace.rb delete "${{ steps.create-cloud-namespace.outputs.namespace }}"
env:
TEMPORAL_CLIENT_CLOUD_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }}

- name: Report Cloud namespace cleanup failure
if: ${{ always() && steps.delete-cloud-namespace.outcome == 'failure' }}
run: echo "::warning title=Cloud namespace cleanup failed::Failed to delete Cloud namespace ${{ steps.create-cloud-namespace.outputs.namespace }}"
125 changes: 125 additions & 0 deletions temporalio/extra/cloud_namespace.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# frozen_string_literal: true

require 'securerandom'
require 'temporalio/api'
require 'temporalio/client'
require 'timeout'

# Keeps Cloud CI isolated by provisioning and deleting a namespace for each run.
module CloudNamespace
CLOUD_API_TARGET = 'saas-api.tmprl.cloud:443'
CLOUD_REGION = 'aws-ca-central-1'
OPERATION_TIMEOUT_SECONDS = 10 * 60
FAILED_OPERATION_STATES = %i[STATE_FAILED STATE_CANCELLED STATE_REJECTED].freeze

class << self
# Keep command dispatch separate so the lifecycle can run inside the repository's Ruby bundle.
def run(args)
case args
in ['create']
create
in ['delete', namespace]
delete(namespace)
else
raise ArgumentError, 'Usage: cloud_namespace.rb create|delete <namespace>'
end
end

# Emit the namespace before polling so cleanup can run if provisioning later fails.
def create
service = cloud_service
namespace_name = "sdk-ruby-ci-#{required_env('GITHUB_RUN_ID')}-#{required_env('GITHUB_RUN_ATTEMPT')}"
result = service.create_namespace(
Temporalio::Api::Cloud::CloudService::V1::CreateNamespaceRequest.new(
spec: Temporalio::Api::Cloud::Namespace::V1::NamespaceSpec.new(
name: namespace_name,
replicas: [Temporalio::Api::Cloud::Namespace::V1::ReplicaSpec.new(region: CLOUD_REGION)],
retention_days: 1,
mtls_auth: Temporalio::Api::Cloud::Namespace::V1::MtlsAuthSpec.new(
accepted_client_ca: File.binread(required_env('TEMPORAL_CLOUD_CLIENT_CA_PATH')),
enabled: true
)
),
async_operation_id: SecureRandom.uuid
)
)
namespace = result.namespace
raise 'Create namespace response did not include a namespace' if namespace.nil? || namespace.empty?

File.open(required_env('GITHUB_OUTPUT'), 'a') { |output| output.puts("namespace=#{namespace}") }
wait_for_operation(service, result.async_operation)
end

# Read the current resource version because Cloud uses optimistic concurrency for deletion.
def delete(namespace)
service = cloud_service
existing = service.get_namespace(
Temporalio::Api::Cloud::CloudService::V1::GetNamespaceRequest.new(namespace:)
).namespace
resource_version = existing&.resource_version
if resource_version.nil? || resource_version.empty?
raise "Cloud namespace #{namespace} did not include a resource version"
end

result = service.delete_namespace(
Temporalio::Api::Cloud::CloudService::V1::DeleteNamespaceRequest.new(
namespace:,
resource_version:,
async_operation_id: SecureRandom.uuid
)
)
wait_for_operation(service, result.async_operation)
end

# Honor server polling guidance while bounding the overall asynchronous operation.
def wait_for_operation(service, operation)
operation_id = operation&.id
raise 'Cloud operation response did not include an ID' if operation_id.nil? || operation_id.empty?

deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + OPERATION_TIMEOUT_SECONDS
loop do
operation = service.get_async_operation(
Temporalio::Api::Cloud::CloudService::V1::GetAsyncOperationRequest.new(
async_operation_id: operation_id
)
).async_operation
raise "Cloud operation #{operation_id} could not be read" unless operation
return if operation.state == :STATE_FULFILLED

if FAILED_OPERATION_STATES.include?(operation.state)
state = operation.state.to_s.delete_prefix('STATE_').downcase
raise "Cloud operation #{operation_id} #{state}: #{operation.failure_reason}"
end

remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
raise Timeout::Error, "Timed out waiting for Cloud operation #{operation_id}" if remaining <= 0

duration = operation.check_duration
delay = duration ? duration.seconds + (duration.nanos / 1_000_000_000.0) : 10
minimum_delay = [1, remaining].min
Kernel.sleep(delay.clamp(minimum_delay, remaining))
end
end

private

def cloud_service
Temporalio::Client::Connection.new(
target_host: CLOUD_API_TARGET,
api_key: required_env('TEMPORAL_CLIENT_CLOUD_API_KEY'),
rpc_metadata: {
'temporal-cloud-api-version' => required_env('TEMPORAL_CLIENT_CLOUD_API_VERSION')
}
).cloud_service
end

def required_env(name)
value = ENV.fetch(name, '')
return value unless value.empty?

raise "Missing required environment variable #{name}"
end
end
end

CloudNamespace.run(ARGV) if $PROGRAM_NAME == __FILE__
Loading