Skip to content

Commit ef62a52

Browse files
authored
Cloud test and support for updating API keys and RPC metadata (#197)
1 parent 350951a commit ef62a52

File tree

6 files changed

+163
-5
lines changed

6 files changed

+163
-5
lines changed

.github/workflows/ci.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,22 @@ jobs:
7575
working-directory: ./temporalio
7676
# Timeout just in case there's a hanging part in rake
7777
timeout-minutes: 20
78+
# Set env vars for cloud tests. If secrets aren't present, tests will be skipped.
79+
env:
80+
# For mTLS tests
81+
TEMPORAL_CLOUD_MTLS_TEST_TARGET_HOST: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }}.tmprl.cloud:7233
82+
TEMPORAL_CLOUD_MTLS_TEST_NAMESPACE: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }}
83+
TEMPORAL_CLOUD_MTLS_TEST_CLIENT_CERT: ${{ secrets.TEMPORAL_CLIENT_CERT }}
84+
TEMPORAL_CLOUD_MTLS_TEST_CLIENT_KEY: ${{ secrets.TEMPORAL_CLIENT_KEY }}
85+
86+
# For API key tests
87+
TEMPORAL_CLOUD_API_KEY_TEST_TARGET_HOST: us-west-2.aws.api.temporal.io:7233
88+
TEMPORAL_CLOUD_API_KEY_TEST_NAMESPACE: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }}
89+
TEMPORAL_CLOUD_API_KEY_TEST_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }}
90+
91+
# For cloud ops tests
92+
TEMPORAL_CLOUD_OPS_TEST_TARGET_HOST: saas-api.tmprl.cloud:443
93+
TEMPORAL_CLOUD_OPS_TEST_NAMESPACE: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }}
94+
TEMPORAL_CLOUD_OPS_TEST_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }}
95+
TEMPORAL_CLOUD_OPS_TEST_API_VERSION: 2024-05-13-00
7896
run: bundle exec rake TESTOPTS="--verbose"

temporalio/ext/src/client.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ pub fn init(ruby: &Ruby) -> Result<(), Error> {
3838
class.const_set("SERVICE_HEALTH", SERVICE_HEALTH)?;
3939
class.define_singleton_method("async_new", function!(Client::async_new, 3))?;
4040
class.define_method("async_invoke_rpc", method!(Client::async_invoke_rpc, -1))?;
41+
class.define_method("update_metadata", method!(Client::update_metadata, 1))?;
42+
class.define_method("update_api_key", method!(Client::update_api_key, 1))?;
4143

4244
let inner_class = class.define_error("RPCFailure", ruby.get_inner(&ROOT_ERR))?;
4345
inner_class.define_method("code", method!(RpcFailure::code, 0))?;
@@ -83,10 +85,16 @@ impl Client {
8385
pub fn async_new(runtime: &Runtime, options: Struct, queue: Value) -> Result<(), Error> {
8486
// Build options
8587
let mut opts_build = ClientOptionsBuilder::default();
88+
let tls = options.child(id!("tls"))?;
8689
opts_build
8790
.target_url(
8891
Url::parse(
89-
format!("http://{}", options.member::<String>(id!("target_host"))?).as_str(),
92+
format!(
93+
"{}://{}",
94+
if tls.is_some() { "https" } else { "http" },
95+
options.member::<String>(id!("target_host"))?
96+
)
97+
.as_str(),
9098
)
9199
.map_err(|err| error!("Failed parsing host: {}", err))?,
92100
)
@@ -95,7 +103,7 @@ impl Client {
95103
.headers(Some(options.member(id!("rpc_metadata"))?))
96104
.api_key(options.member(id!("api_key"))?)
97105
.identity(options.member(id!("identity"))?);
98-
if let Some(tls) = options.child(id!("tls"))? {
106+
if let Some(tls) = tls {
99107
opts_build.tls_cfg(TlsConfig {
100108
client_tls_config: match (
101109
tls.member::<Option<RString>>(id!("client_cert"))?,
@@ -223,6 +231,14 @@ impl Client {
223231
let callback = AsyncCallback::from_queue(queue);
224232
self.invoke_rpc(service, callback, call)
225233
}
234+
235+
pub fn update_metadata(&self, headers: HashMap<String, String>) {
236+
self.core.get_client().set_headers(headers);
237+
}
238+
239+
pub fn update_api_key(&self, api_key: Option<String>) {
240+
self.core.get_client().set_api_key(api_key);
241+
}
226242
}
227243

228244
#[derive(DataTypeFunctions, TypedData)]

temporalio/lib/temporalio/client/connection.rb

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,16 @@ class Options; end # rubocop:disable Lint/EmptyClass
4949
# @!attribute domain
5050
# @return [String, nil] SNI override. This is only needed for self-hosted servers with certificates that do not
5151
# match the hostname being connected to.
52-
class TLSOptions; end # rubocop:disable Lint/EmptyClass
52+
class TLSOptions
53+
def initialize(
54+
client_cert: nil,
55+
client_private_key: nil,
56+
server_root_ca_cert: nil,
57+
domain: nil
58+
)
59+
super
60+
end
61+
end
5362

5463
RPCRetryOptions = Data.define(
5564
:initial_interval,
@@ -122,7 +131,9 @@ def initialize(interval: 30.0, timeout: 15.0)
122131
# @return [String, nil] Pass for HTTP basic auth for the proxy, must be combined with {basic_auth_user}.
123132
class HTTPConnectProxyOptions; end # rubocop:disable Lint/EmptyClass
124133

125-
# @return [Options] Frozen options for this client which has the same attributes as {initialize}.
134+
# @return [Options] Frozen options for this client which has the same attributes as {initialize}. Note that if
135+
# {api_key=} or {rpc_metadata=} are updated, the options object is replaced with those changes (it is not
136+
# mutated in place).
126137
attr_reader :options
127138

128139
# @return [WorkflowService] Raw gRPC workflow service.
@@ -183,6 +194,7 @@ def initialize(
183194
lazy_connect:
184195
).freeze
185196
# Create core client now if not lazy
197+
@core_client_mutex = Mutex.new
186198
_core_client unless lazy_connect
187199
# Create service instances
188200
@workflow_service = WorkflowService.new(self)
@@ -206,11 +218,43 @@ def connected?
206218
!@core_client.nil?
207219
end
208220

221+
# @return [String, nil] API key. This is a shortcut for `options.api_key`.
222+
def api_key
223+
@options.api_key
224+
end
225+
226+
# Set the API key for all future calls. This also makes a new object for {options} with the changes.
227+
#
228+
# @param new_key [String, nil] New API key.
229+
def api_key=(new_key)
230+
# Mutate the client if connected then mutate options
231+
@core_client_mutex.synchronize do
232+
@core_client&.update_api_key(new_key)
233+
@options = @options.with(api_key: new_key)
234+
end
235+
end
236+
237+
# @return [Hash<String, String>] RPC metadata (aka HTTP headers). This is a shortcut for `options.rpc_metadata`.
238+
def rpc_metadata
239+
@options.rpc_metadata
240+
end
241+
242+
# Set the RPC metadata (aka HTTP headers) for all future calls. This also makes a new object for {options} with
243+
# the changes.
244+
#
245+
# @param rpc_metadata [Hash<String, String>] New API key.
246+
def rpc_metadata=(rpc_metadata)
247+
# Mutate the client if connected then mutate options
248+
@core_client_mutex.synchronize do
249+
@core_client&.update_metadata(rpc_metadata)
250+
@options = @options.with(rpc_metadata: rpc_metadata)
251+
end
252+
end
253+
209254
# @!visibility private
210255
def _core_client
211256
# If lazy, this needs to be done under mutex
212257
if @options.lazy_connect
213-
@core_client_mutex ||= Mutex.new
214258
@core_client_mutex.synchronize do
215259
@core_client ||= new_core_client
216260
end

temporalio/sig/temporalio/client/connection.rbs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ module Temporalio
111111
def target_host: -> String
112112
def identity: -> String
113113
def connected?: -> bool
114+
def api_key: -> String?
115+
def api_key=: (String? new_key) -> void
116+
def rpc_metadata: -> Hash[String, String]
117+
def rpc_metadata=: (Hash[String, String] rpc_metadata) -> void
114118
def _core_client: -> Internal::Bridge::Client
115119
private def new_core_client: -> Internal::Bridge::Client
116120
end

temporalio/sig/temporalio/internal/bridge/client.rbs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ module Temporalio
105105
queue: Queue
106106
) -> void
107107

108+
def update_metadata: (Hash[String, String]) -> void
109+
def update_api_key: (String?) -> void
110+
108111
class RPCFailure < Error
109112
def code: -> Temporalio::Error::RPCError::Code::enum
110113
def message: -> String

temporalio/test/client_cloud_test.rb

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# frozen_string_literal: true
2+
3+
require 'securerandom'
4+
require 'temporalio/api'
5+
require 'temporalio/client'
6+
require 'test'
7+
8+
class ClientCloudTest < Test
9+
class SimpleWorkflow < Temporalio::Workflow::Definition
10+
def execute(name)
11+
"Hello, #{name}!"
12+
end
13+
end
14+
15+
def test_mtls
16+
client_private_key = ENV.fetch('TEMPORAL_CLOUD_MTLS_TEST_CLIENT_KEY', '')
17+
skip('No cloud mTLS key') if client_private_key.empty?
18+
19+
client = Temporalio::Client.connect(
20+
ENV.fetch('TEMPORAL_CLOUD_MTLS_TEST_TARGET_HOST'),
21+
ENV.fetch('TEMPORAL_CLOUD_MTLS_TEST_NAMESPACE'),
22+
tls: Temporalio::Client::Connection::TLSOptions.new(
23+
client_cert: ENV.fetch('TEMPORAL_CLOUD_MTLS_TEST_CLIENT_CERT'),
24+
client_private_key:
25+
)
26+
)
27+
assert_equal 'Hello, Temporal!', execute_workflow(SimpleWorkflow, 'Temporal', client:)
28+
end
29+
30+
def test_api_key
31+
api_key = ENV.fetch('TEMPORAL_CLOUD_API_KEY_TEST_API_KEY', '')
32+
skip('No cloud API key') if api_key.empty?
33+
34+
client = Temporalio::Client.connect(
35+
ENV.fetch('TEMPORAL_CLOUD_API_KEY_TEST_TARGET_HOST'),
36+
ENV.fetch('TEMPORAL_CLOUD_API_KEY_TEST_NAMESPACE'),
37+
api_key:,
38+
tls: true,
39+
rpc_metadata: { 'temporal-namespace' => ENV.fetch('TEMPORAL_CLOUD_API_KEY_TEST_NAMESPACE') }
40+
)
41+
# Run workflow
42+
id = "wf-#{SecureRandom.uuid}"
43+
assert_equal 'Hello, Temporal!', execute_workflow(SimpleWorkflow, 'Temporal', id:, client:)
44+
handle = client.workflow_handle(id)
45+
46+
# Confirm it can be described
47+
assert_equal 'SimpleWorkflow', handle.describe.workflow_type
48+
49+
# Change API and confirm failure
50+
client.connection.api_key = 'wrong'
51+
assert_raises(Temporalio::Error::RPCError) { handle.describe.workflow_type }
52+
end
53+
54+
def test_cloud_ops
55+
api_key = ENV.fetch('TEMPORAL_CLOUD_OPS_TEST_API_KEY', '')
56+
skip('No cloud API key') if api_key.empty?
57+
58+
# Create connection
59+
conn = Temporalio::Client::Connection.new(
60+
target_host: ENV.fetch('TEMPORAL_CLOUD_OPS_TEST_TARGET_HOST'),
61+
api_key:,
62+
tls: true,
63+
rpc_metadata: { 'temporal-cloud-api-version' => ENV.fetch('TEMPORAL_CLOUD_OPS_TEST_API_VERSION') }
64+
)
65+
66+
# Simple call
67+
namespace = ENV.fetch('TEMPORAL_CLOUD_OPS_TEST_NAMESPACE')
68+
res = conn.cloud_service.get_namespace(
69+
Temporalio::Api::Cloud::CloudService::V1::GetNamespaceRequest.new(namespace:)
70+
)
71+
assert_equal namespace, res.namespace.namespace
72+
end
73+
end

0 commit comments

Comments
 (0)