modal-rb 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/lib/modal/api_client.rb +111 -0
- data/lib/modal/app.rb +65 -0
- data/lib/modal/cls.rb +131 -0
- data/lib/modal/config.rb +69 -0
- data/lib/modal/errors.rb +11 -0
- data/lib/modal/function.rb +122 -0
- data/lib/modal/function_call.rb +30 -0
- data/lib/modal/image.rb +75 -0
- data/lib/modal/invocation.rb +134 -0
- data/lib/modal/pickle.rb +50 -0
- data/lib/modal/sandbox.rb +340 -0
- data/lib/modal/sandbox_filesystem.rb +229 -0
- data/lib/modal/streams.rb +73 -0
- data/lib/modal/version.rb +3 -0
- data/lib/modal.rb +15 -0
- data/lib/modal_proto/api_pb.rb +480 -0
- data/lib/modal_proto/api_services_pb.rb +218 -0
- data/lib/modal_proto/options_pb.rb +18 -0
- metadata +58 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 3d89ed493f174adebf2f5a015923904757963cae4a650fd949eb66cab0267dff
|
4
|
+
data.tar.gz: ff2a215607081d69ba5c164460e970a857b7f91bd7781fa44a035e380feb51a9
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 2d319697b1697a8092ef7aa6644785fbf61e6ee3594740322f8736687eac163e859ad0796845bc05c3705d386e3a15a366dc5689ffb7aa938f15f15f6d980086
|
7
|
+
data.tar.gz: eee6a3d7c4d60075b67e92e7ec4b0150f0def437313175cd79004368831ca397233dd6af879c38800b57ec77d133270d42ab5710a6af0be5e821c678dfa1c61a
|
@@ -0,0 +1,111 @@
|
|
1
|
+
require 'grpc'
|
2
|
+
require 'securerandom'
|
3
|
+
|
4
|
+
module Modal
|
5
|
+
class ApiClient
|
6
|
+
RETRYABLE_GRPC_STATUS_CODES = Set.new([
|
7
|
+
GRPC::Core::StatusCodes::DEADLINE_EXCEEDED,
|
8
|
+
GRPC::Core::StatusCodes::UNAVAILABLE,
|
9
|
+
GRPC::Core::StatusCodes::CANCELLED,
|
10
|
+
GRPC::Core::StatusCodes::INTERNAL,
|
11
|
+
GRPC::Core::StatusCodes::UNKNOWN,
|
12
|
+
])
|
13
|
+
|
14
|
+
def initialize
|
15
|
+
@profile = Config.profile
|
16
|
+
target, credentials = parse_server_url(@profile[:server_url])
|
17
|
+
|
18
|
+
@stub = Modal::Client::ModalClient::Stub.new(
|
19
|
+
target,
|
20
|
+
credentials,
|
21
|
+
channel_args: {
|
22
|
+
'grpc.max_receive_message_length' => 100 * 1024 * 1024, # 100 MiB
|
23
|
+
'grpc.max_send_message_length' => 100 * 1024 * 1024, # 100 MiB
|
24
|
+
}
|
25
|
+
)
|
26
|
+
end
|
27
|
+
|
28
|
+
def call(method_name, request_pb, options = {})
|
29
|
+
retries = options[:retries] || 3
|
30
|
+
base_delay = options[:base_delay] || 0.1 # seconds
|
31
|
+
max_delay = options[:max_delay] || 1.0 # seconds
|
32
|
+
delay_factor = options[:delay_factor] || 2
|
33
|
+
timeout = options[:timeout] # milliseconds
|
34
|
+
|
35
|
+
idempotency_key = SecureRandom.uuid
|
36
|
+
attempt = 0
|
37
|
+
|
38
|
+
loop do
|
39
|
+
metadata = {
|
40
|
+
'x-modal-client-type' => Modal::Client::ClientType::CLIENT_TYPE_LIBMODAL.to_s, # TODO: libmodal_rb!!!
|
41
|
+
'x-modal-client-version' => '1.0.0',
|
42
|
+
'x-modal-token-id' => @profile[:token_id],
|
43
|
+
'x-modal-token-secret' => @profile[:token_secret],
|
44
|
+
'x-idempotency-key' => idempotency_key,
|
45
|
+
'x-retry-attempt' => attempt.to_s,
|
46
|
+
}
|
47
|
+
metadata['x-retry-delay'] = base_delay.to_s if attempt > 0
|
48
|
+
|
49
|
+
call_options = { metadata: metadata }
|
50
|
+
call_options[:deadline] = Time.now + timeout / 1000.0 if timeout
|
51
|
+
|
52
|
+
begin
|
53
|
+
response = @stub.send(method_name, request_pb, call_options)
|
54
|
+
return response
|
55
|
+
rescue GRPC::BadStatus => e
|
56
|
+
if RETRYABLE_GRPC_STATUS_CODES.include?(e.code) && attempt < retries
|
57
|
+
puts "Retrying #{method_name} due to #{e.code} (attempt #{attempt + 1}/#{retries})"
|
58
|
+
sleep(base_delay)
|
59
|
+
base_delay = [base_delay * delay_factor, max_delay].min
|
60
|
+
attempt += 1
|
61
|
+
else
|
62
|
+
raise convert_grpc_error(e)
|
63
|
+
end
|
64
|
+
rescue StandardError => e
|
65
|
+
raise e
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
private
|
71
|
+
|
72
|
+
def parse_server_url(server_url)
|
73
|
+
if server_url.start_with?('https://')
|
74
|
+
target = server_url.sub('https://', '')
|
75
|
+
credentials = GRPC::Core::ChannelCredentials.new
|
76
|
+
elsif server_url.start_with?('http://')
|
77
|
+
target = server_url.sub('http://', '')
|
78
|
+
credentials = :this_channel_is_insecure
|
79
|
+
else
|
80
|
+
target = server_url
|
81
|
+
credentials = GRPC::Core::ChannelCredentials.new
|
82
|
+
end
|
83
|
+
|
84
|
+
[target, credentials]
|
85
|
+
end
|
86
|
+
|
87
|
+
def convert_grpc_error(grpc_error)
|
88
|
+
case grpc_error.code
|
89
|
+
when GRPC::Core::StatusCodes::NOT_FOUND
|
90
|
+
NotFoundError.new(grpc_error.details)
|
91
|
+
when GRPC::Core::StatusCodes::FAILED_PRECONDITION
|
92
|
+
if grpc_error.details.include?("Secret is missing key")
|
93
|
+
NotFoundError.new(grpc_error.details)
|
94
|
+
else
|
95
|
+
InvalidError.new(grpc_error.details)
|
96
|
+
end
|
97
|
+
when GRPC::Core::StatusCodes::DEADLINE_EXCEEDED
|
98
|
+
FunctionTimeoutError.new(grpc_error.details)
|
99
|
+
when GRPC::Core::StatusCodes::INTERNAL, GRPC::Core::StatusCodes::UNKNOWN
|
100
|
+
InternalFailure.new(grpc_error.details)
|
101
|
+
else
|
102
|
+
RemoteError.new(grpc_error.details)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
@client = ApiClient.new
|
108
|
+
def self.client
|
109
|
+
@client
|
110
|
+
end
|
111
|
+
end
|
data/lib/modal/app.rb
ADDED
@@ -0,0 +1,65 @@
|
|
1
|
+
module Modal
|
2
|
+
class App
|
3
|
+
attr_reader :app_id
|
4
|
+
|
5
|
+
def initialize(app_id)
|
6
|
+
@app_id = app_id
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.lookup(name, options = {})
|
10
|
+
create_if_missing = options[:create_if_missing] || false
|
11
|
+
environment = options[:environment]
|
12
|
+
|
13
|
+
request = Modal::Client::AppGetOrCreateRequest.new(
|
14
|
+
app_name: name,
|
15
|
+
environment_name: Config.environment_name(environment),
|
16
|
+
object_creation_type: create_if_missing ? Modal::Client::ObjectCreationType::OBJECT_CREATION_TYPE_CREATE_IF_MISSING : Modal::Client::ObjectCreationType::OBJECT_CREATION_TYPE_UNSPECIFIED
|
17
|
+
)
|
18
|
+
|
19
|
+
resp = Modal.client.call(:app_get_or_create, request)
|
20
|
+
new(resp.app_id)
|
21
|
+
end
|
22
|
+
|
23
|
+
def create_sandbox(image, options = {})
|
24
|
+
timeout_secs = options[:timeout] ? options[:timeout] / 1000 : 600
|
25
|
+
cpu_milli = (options[:cpu] || 0.125) * 1000
|
26
|
+
memory_mb = options[:memory] || 128
|
27
|
+
command = options[:command] || ["sleep", "48h"]
|
28
|
+
|
29
|
+
request = Modal::Client::SandboxCreateRequest.new(
|
30
|
+
app_id: @app_id,
|
31
|
+
definition: Modal::Client::Sandbox.new(
|
32
|
+
entrypoint_args: command,
|
33
|
+
image_id: image.image_id,
|
34
|
+
timeout_secs: timeout_secs,
|
35
|
+
network_access: Modal::Client::NetworkAccess.new(
|
36
|
+
network_access_type: Modal::Client::NetworkAccess::NetworkAccessType::OPEN
|
37
|
+
),
|
38
|
+
resources: Modal::Client::Resources.new(
|
39
|
+
milli_cpu: cpu_milli.round,
|
40
|
+
memory_mb: memory_mb.round
|
41
|
+
)
|
42
|
+
)
|
43
|
+
)
|
44
|
+
|
45
|
+
create_resp = Modal.client.call(:sandbox_create, request)
|
46
|
+
Sandbox.new(create_resp.sandbox_id)
|
47
|
+
end
|
48
|
+
|
49
|
+
def image_from_registry(tag)
|
50
|
+
Image.from_registry_internal(@app_id, tag)
|
51
|
+
end
|
52
|
+
|
53
|
+
def image_from_aws_ecr(tag, secret)
|
54
|
+
unless secret.is_a?(Secret)
|
55
|
+
raise TypeError, "secret must be a reference to an existing Secret, e.g. `Secret.from_name('my_secret')`"
|
56
|
+
end
|
57
|
+
|
58
|
+
image_registry_config = Modal::Client::ImageRegistryConfig.new(
|
59
|
+
registry_auth_type: Modal::Client::RegistryAuthType::REGISTRY_AUTH_TYPE_AWS,
|
60
|
+
secret_id: secret.secret_id
|
61
|
+
)
|
62
|
+
Image.from_registry_internal(@app_id, tag, image_registry_config)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
data/lib/modal/cls.rb
ADDED
@@ -0,0 +1,131 @@
|
|
1
|
+
module Modal
|
2
|
+
class Cls
|
3
|
+
attr_reader :service_function_id, :schema, :method_names
|
4
|
+
|
5
|
+
def initialize(service_function_id, schema, method_names)
|
6
|
+
@service_function_id = service_function_id
|
7
|
+
@schema = schema
|
8
|
+
@method_names = method_names
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.lookup(app_name, name, options = {})
|
12
|
+
service_function_name = "#{name}.*"
|
13
|
+
request = Modal::Client::FunctionGetRequest.new(
|
14
|
+
app_name: app_name,
|
15
|
+
object_tag: service_function_name,
|
16
|
+
environment_name: Config.environment_name(options[:environment])
|
17
|
+
)
|
18
|
+
|
19
|
+
service_function = Modal.client.call(:function_get, request)
|
20
|
+
parameter_info = service_function.handle_metadata&.class_parameter_info
|
21
|
+
schema = parameter_info&.schema || []
|
22
|
+
|
23
|
+
if schema.any? && parameter_info.format != Modal::Client::ClassParameterInfo_ParameterSerializationFormat::PARAM_SERIALIZATION_FORMAT_PROTO
|
24
|
+
raise "Unsupported parameter format: #{parameter_info.format}"
|
25
|
+
end
|
26
|
+
|
27
|
+
method_names = if service_function.handle_metadata&.method_handle_metadata
|
28
|
+
service_function.handle_metadata.method_handle_metadata.keys
|
29
|
+
else
|
30
|
+
raise "Cls requires Modal deployments using client v0.67 or later."
|
31
|
+
end
|
32
|
+
|
33
|
+
new(service_function.function_id, schema, method_names)
|
34
|
+
rescue NotFoundError => e
|
35
|
+
raise NotFoundError.new("Class '#{app_name}/#{name}' not found")
|
36
|
+
end
|
37
|
+
|
38
|
+
def instance(params = {})
|
39
|
+
function_id = if @schema.empty?
|
40
|
+
@service_function_id
|
41
|
+
else
|
42
|
+
bind_parameters(params)
|
43
|
+
end
|
44
|
+
|
45
|
+
methods = {}
|
46
|
+
@method_names.each do |name|
|
47
|
+
methods[name] = Function_.new(function_id, name)
|
48
|
+
end
|
49
|
+
ClsInstance.new(methods)
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def bind_parameters(params)
|
55
|
+
serialized_params = encode_parameter_set(@schema, params)
|
56
|
+
request = Modal::Client::FunctionBindParamsRequest.new(
|
57
|
+
function_id: @service_function_id,
|
58
|
+
serialized_params: serialized_params
|
59
|
+
)
|
60
|
+
bind_resp = Modal.client.call(:function_bind_params, request)
|
61
|
+
bind_resp.bound_function_id
|
62
|
+
end
|
63
|
+
|
64
|
+
def encode_parameter_set(schema, params)
|
65
|
+
encoded_params = schema.map do |param_spec|
|
66
|
+
encode_parameter(param_spec, params[param_spec.name.to_sym])
|
67
|
+
end
|
68
|
+
|
69
|
+
encoded_params.sort_by!(&:name)
|
70
|
+
Modal::Client::ClassParameterSet.encode(parameters: encoded_params).to_proto
|
71
|
+
end
|
72
|
+
|
73
|
+
def encode_parameter(param_spec, value)
|
74
|
+
name = param_spec.name
|
75
|
+
param_type = param_spec.type
|
76
|
+
param_value = Modal::Client::ClassParameterValue.new(name: name, type: param_type)
|
77
|
+
|
78
|
+
case param_type
|
79
|
+
when Modal::Client::ParameterType::PARAM_TYPE_STRING
|
80
|
+
if value.nil? && param_spec.has_default
|
81
|
+
value = param_spec.string_default || ""
|
82
|
+
end
|
83
|
+
unless value.is_a?(String)
|
84
|
+
raise "Parameter '#{name}' must be a string"
|
85
|
+
end
|
86
|
+
param_value.string_value = value
|
87
|
+
when Modal::Client::ParameterType::PARAM_TYPE_INT
|
88
|
+
if value.nil? && param_spec.has_default
|
89
|
+
value = param_spec.int_default || 0
|
90
|
+
end
|
91
|
+
unless value.is_a?(Integer)
|
92
|
+
raise "Parameter '#{name}' must be an integer"
|
93
|
+
end
|
94
|
+
param_value.int_value = value
|
95
|
+
when Modal::Client::ParameterType::PARAM_TYPE_BOOL
|
96
|
+
if value.nil? && param_spec.has_default
|
97
|
+
value = param_spec.bool_default || false
|
98
|
+
end
|
99
|
+
unless [true, false].include?(value)
|
100
|
+
raise "Parameter '#{name}' must be a boolean"
|
101
|
+
end
|
102
|
+
param_value.bool_value = value
|
103
|
+
when Modal::Client::ParameterType::PARAM_TYPE_BYTES
|
104
|
+
if value.nil? && param_spec.has_default
|
105
|
+
value = param_spec.bytes_default || ""
|
106
|
+
end
|
107
|
+
unless value.is_a?(String)
|
108
|
+
raise "Parameter '#{name}' must be a byte array (String in Ruby)"
|
109
|
+
end
|
110
|
+
param_value.bytes_value = value.bytes.pack('C*')
|
111
|
+
else
|
112
|
+
raise "Unsupported parameter type: #{param_type}"
|
113
|
+
end
|
114
|
+
param_value
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
class ClsInstance
|
119
|
+
def initialize(methods)
|
120
|
+
@methods = methods
|
121
|
+
end
|
122
|
+
|
123
|
+
def method(name)
|
124
|
+
func = @methods[name.to_s]
|
125
|
+
unless func
|
126
|
+
raise NotFoundError.new("Method '#{name}' not found on class")
|
127
|
+
end
|
128
|
+
func
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
data/lib/modal/config.rb
ADDED
@@ -0,0 +1,69 @@
|
|
1
|
+
require 'toml-rb'
|
2
|
+
require 'fileutils'
|
3
|
+
|
4
|
+
module Modal
|
5
|
+
module Config
|
6
|
+
CONFIG_FILE = File.join(Dir.home, '.modal.toml')
|
7
|
+
|
8
|
+
def self.read_config_file
|
9
|
+
if File.exist?(CONFIG_FILE)
|
10
|
+
TomlRB.parse(File.read(CONFIG_FILE))
|
11
|
+
else
|
12
|
+
{}
|
13
|
+
end
|
14
|
+
rescue StandardError => e
|
15
|
+
raise "Failed to read or parse .modal.toml: #{e.message}"
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.get_profile(profile_name = nil)
|
19
|
+
config = read_config_file
|
20
|
+
|
21
|
+
profile_name ||= ENV['MODAL_PROFILE']
|
22
|
+
unless profile_name
|
23
|
+
config.each do |name, data|
|
24
|
+
if data['active']
|
25
|
+
profile_name = name
|
26
|
+
break
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
if profile_name && !config.key?(profile_name)
|
32
|
+
raise "Profile \"#{profile_name}\" not found in .modal.toml. Please set the MODAL_PROFILE environment variable or specify a valid profile."
|
33
|
+
end
|
34
|
+
|
35
|
+
profile_data = profile_name ? config[profile_name] : {}
|
36
|
+
|
37
|
+
server_url = ENV['MODAL_SERVER_URL'] || profile_data['server_url'] || 'https://api.modal.com'
|
38
|
+
token_id = ENV['MODAL_TOKEN_ID'] || profile_data['token_id']
|
39
|
+
token_secret = ENV['MODAL_TOKEN_SECRET'] || profile_data['token_secret']
|
40
|
+
environment = ENV['MODAL_ENVIRONMENT'] || profile_data['environment']
|
41
|
+
image_builder_version = ENV['MODAL_IMAGE_BUILDER_VERSION'] || profile_data['image_builder_version'] || '2024.10'
|
42
|
+
|
43
|
+
unless token_id && token_secret
|
44
|
+
raise "Profile \"#{profile_name}\" is missing token_id or token_secret. Please set them in .modal.toml or as environment variables."
|
45
|
+
end
|
46
|
+
|
47
|
+
{
|
48
|
+
server_url: server_url,
|
49
|
+
token_id: token_id,
|
50
|
+
token_secret: token_secret,
|
51
|
+
environment: environment,
|
52
|
+
image_builder_version: image_builder_version
|
53
|
+
}
|
54
|
+
end
|
55
|
+
|
56
|
+
def self.environment_name(environment = nil)
|
57
|
+
environment || profile[:environment] || ""
|
58
|
+
end
|
59
|
+
|
60
|
+
def self.image_builder_version(version = nil)
|
61
|
+
version || profile[:image_builder_version] || "2024.10"
|
62
|
+
end
|
63
|
+
|
64
|
+
@profile = get_profile
|
65
|
+
def self.profile
|
66
|
+
@profile
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
data/lib/modal/errors.rb
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
module Modal
|
2
|
+
class ModalError < StandardError; end
|
3
|
+
class FunctionTimeoutError < ModalError; end
|
4
|
+
class RemoteError < ModalError; end
|
5
|
+
class InternalFailure < ModalError; end
|
6
|
+
class NotFoundError < ModalError; end
|
7
|
+
class InvalidError < ModalError; end
|
8
|
+
class QueueEmptyError < ModalError; end
|
9
|
+
class QueueFullError < ModalError; end
|
10
|
+
class SandboxFilesystemError < ModalError; end
|
11
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
require 'digest'
|
2
|
+
require_relative 'pickle'
|
3
|
+
|
4
|
+
module Modal
|
5
|
+
class Function_
|
6
|
+
attr_reader :function_id, :method_name
|
7
|
+
|
8
|
+
MAX_OBJECT_SIZE_BYTES = 2 * 1024 * 1024 # 2 MiB
|
9
|
+
MAX_SYSTEM_RETRIES = 8
|
10
|
+
|
11
|
+
def initialize(function_id, method_name = nil)
|
12
|
+
@function_id = function_id
|
13
|
+
@method_name = method_name
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.lookup(app_name, name, options = {})
|
17
|
+
environment = options[:environment] || Modal::Config.environment_name
|
18
|
+
request = Modal::Client::FunctionGetRequest.new(
|
19
|
+
app_name: app_name,
|
20
|
+
object_tag: name,
|
21
|
+
environment_name: Config.environment_name(environment)
|
22
|
+
)
|
23
|
+
resp = Modal.client.call(:function_get, request)
|
24
|
+
new(resp.function_id)
|
25
|
+
rescue NotFoundError
|
26
|
+
raise NotFoundError.new("Function '#{app_name}/#{name}' not found")
|
27
|
+
end
|
28
|
+
|
29
|
+
def remote(args = [], kwargs = {})
|
30
|
+
input = create_input(args, kwargs)
|
31
|
+
invocation = ControlPlaneInvocation.create(@function_id, input, Modal::Client::FunctionCallInvocationType::FUNCTION_CALL_INVOCATION_TYPE_SYNC)
|
32
|
+
|
33
|
+
retry_count = 0
|
34
|
+
loop do
|
35
|
+
begin
|
36
|
+
return invocation.await_output
|
37
|
+
rescue InternalFailure => e
|
38
|
+
if retry_count <= MAX_SYSTEM_RETRIES
|
39
|
+
invocation.retry(retry_count)
|
40
|
+
retry_count += 1
|
41
|
+
else
|
42
|
+
raise e
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def spawn(args = [], kwargs = {})
|
49
|
+
input = create_input(args, kwargs)
|
50
|
+
invocation = ControlPlaneInvocation.create(@function_id, input, Modal::Client::FunctionCallInvocationType::FUNCTION_CALL_INVOCATION_TYPE_ASYNC)
|
51
|
+
FunctionCall.new(invocation.function_call_id)
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
def create_input(args, kwargs)
|
57
|
+
# Create a proper Python tuple structure
|
58
|
+
# The Python function expects to receive (*args, **kwargs)
|
59
|
+
# So we need to create a tuple where:
|
60
|
+
# - First element is a tuple of positional args
|
61
|
+
# - Second element is a dict of keyword args
|
62
|
+
|
63
|
+
# Convert Ruby array to Python tuple, Ruby hash to Python dict
|
64
|
+
python_args = args.is_a?(Array) ? args : [args]
|
65
|
+
python_kwargs = kwargs.is_a?(Hash) ? kwargs : {}
|
66
|
+
|
67
|
+
# Create the payload as a tuple of (args_tuple, kwargs_dict)
|
68
|
+
payload = Pickle.dumps([python_args, python_kwargs])
|
69
|
+
args_blob_id = nil
|
70
|
+
|
71
|
+
if payload.bytesize > MAX_OBJECT_SIZE_BYTES
|
72
|
+
args_blob_id = blob_upload(payload)
|
73
|
+
end
|
74
|
+
|
75
|
+
Modal::Client::FunctionInput.new(
|
76
|
+
args: args_blob_id ? nil : payload,
|
77
|
+
args_blob_id: args_blob_id,
|
78
|
+
data_format: Modal::Client::DataFormat::DATA_FORMAT_PICKLE,
|
79
|
+
method_name: @method_name,
|
80
|
+
final_input: false
|
81
|
+
)
|
82
|
+
end
|
83
|
+
|
84
|
+
def blob_upload(data)
|
85
|
+
content_md5 = Digest::MD5.base64digest(data)
|
86
|
+
content_sha256 = Digest::SHA256.base64digest(data)
|
87
|
+
content_length = data.bytesize
|
88
|
+
|
89
|
+
request = Modal::Client::BlobCreateRequest.new(
|
90
|
+
content_md5: content_md5,
|
91
|
+
content_sha256_base64: content_sha256,
|
92
|
+
content_length: content_length
|
93
|
+
)
|
94
|
+
resp = Modal.client.call(:blob_create, request)
|
95
|
+
|
96
|
+
if resp.multipart
|
97
|
+
raise "Function input size exceeds multipart upload threshold, unsupported by this SDK version"
|
98
|
+
elsif resp.upload_url
|
99
|
+
require 'net/http'
|
100
|
+
require 'uri'
|
101
|
+
|
102
|
+
uri = URI.parse(resp.upload_url)
|
103
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
104
|
+
http.use_ssl = uri.scheme == 'https'
|
105
|
+
|
106
|
+
req = Net::HTTP::Put.new(uri.request_uri)
|
107
|
+
req['Content-Type'] = 'application/octet-stream'
|
108
|
+
req['Content-MD5'] = content_md5
|
109
|
+
req.body = data
|
110
|
+
|
111
|
+
upload_resp = http.request(req)
|
112
|
+
|
113
|
+
unless upload_resp.code.to_i >= 200 && upload_resp.code.to_i < 300
|
114
|
+
raise "Failed blob upload: #{upload_resp.message}"
|
115
|
+
end
|
116
|
+
resp.blob_id
|
117
|
+
else
|
118
|
+
raise "Missing upload URL in BlobCreate response"
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require_relative 'invocation'
|
2
|
+
|
3
|
+
module Modal
|
4
|
+
class FunctionCall
|
5
|
+
attr_reader :function_call_id
|
6
|
+
|
7
|
+
def initialize(function_call_id)
|
8
|
+
@function_call_id = function_call_id
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.from_id(function_call_id)
|
12
|
+
new(function_call_id)
|
13
|
+
end
|
14
|
+
|
15
|
+
def get(options = {})
|
16
|
+
timeout = options[:timeout]
|
17
|
+
invocation = ControlPlaneInvocation.from_function_call_id(@function_call_id)
|
18
|
+
invocation.await_output(timeout)
|
19
|
+
end
|
20
|
+
|
21
|
+
def cancel(options = {})
|
22
|
+
terminate_containers = options[:terminate_containers] || false
|
23
|
+
request = Modal::Client::FunctionCallCancelRequest.new(
|
24
|
+
function_call_id: @function_call_id,
|
25
|
+
terminate_containers: terminate_containers
|
26
|
+
)
|
27
|
+
Modal.client.call(:function_call_cancel, request)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
data/lib/modal/image.rb
ADDED
@@ -0,0 +1,75 @@
|
|
1
|
+
module Modal
|
2
|
+
class Image
|
3
|
+
attr_reader :image_id
|
4
|
+
|
5
|
+
def initialize(image_id)
|
6
|
+
@image_id = image_id
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.from_registry_internal(app_id, tag, image_registry_config = nil)
|
10
|
+
request = Modal::Client::ImageGetOrCreateRequest.new(
|
11
|
+
app_id: app_id,
|
12
|
+
image: Modal::Client::Image.new(
|
13
|
+
dockerfile_commands: ["FROM #{tag}"],
|
14
|
+
image_registry_config: image_registry_config
|
15
|
+
),
|
16
|
+
namespace: Modal::Client::DeploymentNamespace::DEPLOYMENT_NAMESPACE_WORKSPACE,
|
17
|
+
builder_version: Config.image_builder_version
|
18
|
+
)
|
19
|
+
|
20
|
+
resp = Modal.client.call(:image_get_or_create, request)
|
21
|
+
result = resp.result
|
22
|
+
metadata = resp.metadata
|
23
|
+
|
24
|
+
if result && result.status != :GENERIC_STATUS_UNSPECIFIED
|
25
|
+
metadata = resp.metadata
|
26
|
+
else
|
27
|
+
last_entry_id = ""
|
28
|
+
|
29
|
+
loop do
|
30
|
+
streaming_request = Modal::Client::ImageJoinStreamingRequest.new(
|
31
|
+
image_id: resp.image_id,
|
32
|
+
timeout: 55,
|
33
|
+
last_entry_id: last_entry_id
|
34
|
+
)
|
35
|
+
|
36
|
+
puts "Waiting for image build for #{resp.image_id}..."
|
37
|
+
|
38
|
+
begin
|
39
|
+
stream_resp = Modal.client.call(:image_join_streaming, streaming_request)
|
40
|
+
|
41
|
+
if stream_resp.result && stream_resp.result.status != Modal::Client::GenericResult::GenericStatus::GENERIC_STATUS_UNSPECIFIED
|
42
|
+
result = stream_resp.result
|
43
|
+
metadata = stream_resp.metadata
|
44
|
+
break
|
45
|
+
end
|
46
|
+
|
47
|
+
if stream_resp.entry_id && !stream_resp.entry_id.empty?
|
48
|
+
last_entry_id = stream_resp.entry_id
|
49
|
+
end
|
50
|
+
|
51
|
+
rescue => e
|
52
|
+
puts "Error checking build status: #{e.message}"
|
53
|
+
sleep(5)
|
54
|
+
next
|
55
|
+
end
|
56
|
+
|
57
|
+
sleep(2) # Wait before next check
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
case result.status
|
62
|
+
when :GENERIC_STATUS_FAILURE
|
63
|
+
raise "Image build for #{resp.image_id} failed with the exception:\n#{result.exception}"
|
64
|
+
when :GENERIC_STATUS_TERMINATED
|
65
|
+
raise "Image build for #{resp.image_id} terminated due to external shut-down. Please try again."
|
66
|
+
when :GENERIC_STATUS_TIMEOUT
|
67
|
+
raise "Image build for #{resp.image_id} timed out. Please try again with a larger timeout parameter."
|
68
|
+
when :GENERIC_STATUS_SUCCESS
|
69
|
+
new(resp.image_id)
|
70
|
+
else
|
71
|
+
raise "Image build for #{resp.image_id} failed with unknown status: #{result.status}"
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|