xapixctl 1.1.0 → 1.2.2
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 +4 -4
- data/.github/workflows/cd.yaml +17 -0
- data/.rubocop.yml +5 -1
- data/.ruby-version +1 -1
- data/Gemfile.lock +57 -40
- data/Rakefile +1 -1
- data/lib/xapixctl.rb +0 -3
- data/lib/xapixctl/base_cli.rb +75 -0
- data/lib/xapixctl/cli.rb +59 -159
- data/lib/xapixctl/connector_cli.rb +49 -0
- data/lib/xapixctl/phoenix_client.rb +38 -247
- data/lib/xapixctl/phoenix_client/connection.rb +50 -0
- data/lib/xapixctl/phoenix_client/organization_connection.rb +61 -0
- data/lib/xapixctl/phoenix_client/project_connection.rb +184 -0
- data/lib/xapixctl/phoenix_client/result_handler.rb +35 -0
- data/lib/xapixctl/preview_cli.rb +54 -0
- data/lib/xapixctl/sync_cli.rb +166 -0
- data/lib/xapixctl/titan_cli.rb +281 -0
- data/lib/xapixctl/util.rb +42 -0
- data/lib/xapixctl/version.rb +3 -1
- data/xapixctl.gemspec +13 -6
- metadata +67 -14
@@ -0,0 +1,184 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Xapixctl
|
4
|
+
module PhoenixClient
|
5
|
+
class ProjectConnection < OrganizationConnection
|
6
|
+
attr_reader :project
|
7
|
+
|
8
|
+
def initialize(connection, org, project)
|
9
|
+
super(connection, org)
|
10
|
+
@project = project
|
11
|
+
end
|
12
|
+
|
13
|
+
def project_resource(format: :hash, &block)
|
14
|
+
organization.resource('Project', @project, format: format, &block)
|
15
|
+
end
|
16
|
+
|
17
|
+
def organization
|
18
|
+
OrganizationConnection.new(@connection, @org)
|
19
|
+
end
|
20
|
+
|
21
|
+
def resource_types_for_export
|
22
|
+
@resource_types_for_export ||=
|
23
|
+
@connection.available_resource_types do |res|
|
24
|
+
res.on_success do |available_types|
|
25
|
+
prj_types = available_types.select { |desc| desc['context'] == 'Project' }
|
26
|
+
SUPPORTED_RESOURCE_TYPES & prj_types.map { |desc| desc['type'] }
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Notes on parameters:
|
32
|
+
# - Query parameters should be part of the URL
|
33
|
+
# - Path parameters should be marked with `{name}` in the URL, and values should be given in path_params hash
|
34
|
+
# - Headers should be given in headers hash
|
35
|
+
# - Cookies should be given in cookies hash
|
36
|
+
# - The body has to be given as a string
|
37
|
+
# - The required authentication schemes should be listed, referring to previously created schemes
|
38
|
+
#
|
39
|
+
# This returns a hash like the following:
|
40
|
+
# "data_source" => { "id" => id, "resource_description" => resource_description }
|
41
|
+
#
|
42
|
+
# To successfully onboard a DB using the API, the following steps are needed:
|
43
|
+
# 1. setup the data source using add_rest_data_source.
|
44
|
+
# 2. retrieve a preview using preview_data_source using the id returned by previous step
|
45
|
+
# 3. confirm preview
|
46
|
+
# 4. call accept_data_source_preview to complete onboarding
|
47
|
+
#
|
48
|
+
def add_rest_data_source(http_method:, url:, path_params: {}, headers: {}, cookies: {}, body: nil, auth_schemes: [], &block)
|
49
|
+
data_source_details = {
|
50
|
+
data_source: {
|
51
|
+
http_method: http_method, url: url,
|
52
|
+
parameters: { path: path_params.to_query, header: headers.to_query, cookies: cookies.to_query, body: body },
|
53
|
+
auth_schemes: auth_schemes
|
54
|
+
}
|
55
|
+
}
|
56
|
+
result_handler(block).
|
57
|
+
run { @client[rest_data_source_path].post(data_source_details.to_json, content_type: :json) }
|
58
|
+
end
|
59
|
+
|
60
|
+
# Notes on parameters:
|
61
|
+
# - To call a data source which requires authentication, provide a hash with each required auth scheme as key and
|
62
|
+
# as the value a reference to a previously created credential.
|
63
|
+
# Example: { scheme_ref1 => credential_ref1, scheme_ref2 => credential_ref2 }
|
64
|
+
#
|
65
|
+
# This returns a hashified preview like the following:
|
66
|
+
# { "preview" => {
|
67
|
+
# "sample" => { "status" => integer, "body" => { ... }, "headers" => { ... }, "cookies" => { ... } },
|
68
|
+
# "fetched_at" => Timestamp },
|
69
|
+
# "data_source" => { "id" => id, "resource_description" => resource_description } }
|
70
|
+
#
|
71
|
+
def data_source_preview(data_source_id, authentications: {}, &block)
|
72
|
+
preview_data = {
|
73
|
+
authentications: authentications.map { |scheme, cred| { auth_scheme_id: scheme, auth_credential_id: cred } }
|
74
|
+
}
|
75
|
+
result_handler(block).
|
76
|
+
run { @client[data_source_preview_path(data_source_id)].post(preview_data.to_json, content_type: :json) }
|
77
|
+
end
|
78
|
+
|
79
|
+
def add_schema_import(spec_filename, &block)
|
80
|
+
spec_data = { schema_import: { file: File.new(spec_filename, 'r') } }
|
81
|
+
result_handler(block).
|
82
|
+
run { @client[schema_imports_path].post(spec_data) }
|
83
|
+
end
|
84
|
+
|
85
|
+
def update_schema_import(schema_import, spec_filename, &block)
|
86
|
+
spec_data = { schema_import: { file: File.new(spec_filename, 'r') } }
|
87
|
+
result_handler(block).
|
88
|
+
run { @client[schema_import_path(schema_import)].patch(spec_data) }
|
89
|
+
end
|
90
|
+
|
91
|
+
def pipeline_preview(pipeline_id, format: :hash, &block)
|
92
|
+
result_handler(block).
|
93
|
+
prepare_data(->(data) { data['pipeline_preview'] }).
|
94
|
+
formatter(PREVIEW_FORMATTERS[format]).
|
95
|
+
run { @client[pipeline_preview_path(pipeline_id)].get }
|
96
|
+
end
|
97
|
+
|
98
|
+
def endpoint_preview(endpoint_id, format: :hash, &block)
|
99
|
+
result_handler(block).
|
100
|
+
prepare_data(->(data) { data['endpoint_preview'] }).
|
101
|
+
formatter(PREVIEW_FORMATTERS[format]).
|
102
|
+
run { @client[endpoint_preview_path(endpoint_id)].get }
|
103
|
+
end
|
104
|
+
|
105
|
+
def stream_processor_preview(stream_processor_id, format: :hash, &block)
|
106
|
+
result_handler(block).
|
107
|
+
prepare_data(->(data) { data['stream_processor_preview'] }).
|
108
|
+
formatter(PREVIEW_FORMATTERS[format]).
|
109
|
+
run { @client[stream_processor_preview_path(stream_processor_id)].get }
|
110
|
+
end
|
111
|
+
|
112
|
+
def publish(&block)
|
113
|
+
result_handler(block).
|
114
|
+
run { @client[project_publications_path].post('') }
|
115
|
+
end
|
116
|
+
|
117
|
+
def logs(correlation_id, &block)
|
118
|
+
result_handler(block).
|
119
|
+
run { @client[project_logss_path(correlation_id)].get }
|
120
|
+
end
|
121
|
+
|
122
|
+
# This returns a hashified preview like the following:
|
123
|
+
|
124
|
+
def accept_data_source_preview(data_source_id, &block)
|
125
|
+
result_handler(block).
|
126
|
+
run { @client[data_source_preview_path(data_source_id)].patch('') }
|
127
|
+
end
|
128
|
+
|
129
|
+
def public_project_url
|
130
|
+
File.join(@connection.xapix_url, @org, @project)
|
131
|
+
end
|
132
|
+
|
133
|
+
private
|
134
|
+
|
135
|
+
def rest_data_source_path
|
136
|
+
"/projects/#{@org}/#{@project}/onboarding/data_sources/rest"
|
137
|
+
end
|
138
|
+
|
139
|
+
def data_source_preview_path(id)
|
140
|
+
"/projects/#{@org}/#{@project}/onboarding/data_sources/#{id}/preview"
|
141
|
+
end
|
142
|
+
|
143
|
+
def schema_imports_path
|
144
|
+
"/projects/#{@org}/#{@project}/onboarding/schema_imports"
|
145
|
+
end
|
146
|
+
|
147
|
+
def schema_import_path(schema_import)
|
148
|
+
"/projects/#{@org}/#{@project}/onboarding/schema_imports/#{schema_import}"
|
149
|
+
end
|
150
|
+
|
151
|
+
def resource_path(type, id)
|
152
|
+
"/projects/#{@org}/#{@project}/#{translate_type(type)}/#{id}"
|
153
|
+
end
|
154
|
+
|
155
|
+
def resources_path(type)
|
156
|
+
"/projects/#{@org}/#{@project}/#{translate_type(type)}"
|
157
|
+
end
|
158
|
+
|
159
|
+
def generic_resource_path
|
160
|
+
"projects/#{@org}/#{@project}/resource"
|
161
|
+
end
|
162
|
+
|
163
|
+
def pipeline_preview_path(pipeline)
|
164
|
+
"/projects/#{@org}/#{@project}/pipelines/#{pipeline}/preview"
|
165
|
+
end
|
166
|
+
|
167
|
+
def endpoint_preview_path(endpoint)
|
168
|
+
"/projects/#{@org}/#{@project}/endpoints/#{endpoint}/preview"
|
169
|
+
end
|
170
|
+
|
171
|
+
def stream_processor_preview_path(stream_processor)
|
172
|
+
"/projects/#{@org}/#{@project}/stream_processors/#{stream_processor}/preview"
|
173
|
+
end
|
174
|
+
|
175
|
+
def project_publications_path
|
176
|
+
"/projects/#{@org}/#{@project}/publications"
|
177
|
+
end
|
178
|
+
|
179
|
+
def project_logss_path(correlation_id)
|
180
|
+
"/projects/#{@org}/#{@project}/logs/#{correlation_id}"
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Xapixctl
|
4
|
+
module PhoenixClient
|
5
|
+
class ResultHandler
|
6
|
+
def initialize(default_success_handler:, default_error_handler:)
|
7
|
+
@success_handler = default_success_handler
|
8
|
+
@error_handler = default_error_handler
|
9
|
+
@result_handler = nil
|
10
|
+
yield self if block_given?
|
11
|
+
end
|
12
|
+
|
13
|
+
def on_success(&block); @success_handler = block; self; end
|
14
|
+
|
15
|
+
def on_error(&block); @error_handler = block; self; end
|
16
|
+
|
17
|
+
def prepare_data(proc); @result_handler = proc; self; end
|
18
|
+
|
19
|
+
def formatter(proc); @formatter = proc; self; end
|
20
|
+
|
21
|
+
def run
|
22
|
+
res = yield
|
23
|
+
res = res.present? ? JSON.parse(res) : res
|
24
|
+
res = @result_handler ? @result_handler.call(res) : res
|
25
|
+
res = @formatter ? @formatter.call(res) : res
|
26
|
+
@success_handler.call(res)
|
27
|
+
rescue RestClient::Exception => err
|
28
|
+
response = JSON.parse(err.response) rescue {}
|
29
|
+
@error_handler.call(err, response)
|
30
|
+
rescue SocketError, Errno::ECONNREFUSED => err
|
31
|
+
@error_handler.call(err, nil)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'xapixctl/base_cli'
|
4
|
+
|
5
|
+
module Xapixctl
|
6
|
+
class PreviewCli < BaseCli
|
7
|
+
option :format, aliases: "-f", default: 'text', enum: ['text', 'yaml', 'json'], desc: "Output format"
|
8
|
+
desc "pipeline ID", "Preview a pipeline"
|
9
|
+
long_desc <<-LONGDESC
|
10
|
+
`xapixctl preview pipeline` will return a preview of the given pipeline.
|
11
|
+
|
12
|
+
The preview function will not call any external data sources but calculate a preview based on the provided sample data.
|
13
|
+
|
14
|
+
To preview a pipeline attached to an endpoint, please use `xapixctl preview endpoint` to see the correct preview.
|
15
|
+
|
16
|
+
Examples:
|
17
|
+
\x5> $ xapixctl preview pipeline -o xapix -p some-project pipeline
|
18
|
+
\x5> $ xapixctl preview pipeline -p xapix/some-project pipeline
|
19
|
+
LONGDESC
|
20
|
+
def pipeline(pipeline)
|
21
|
+
puts prj_connection.pipeline_preview(pipeline, format: options[:format].to_sym)
|
22
|
+
end
|
23
|
+
|
24
|
+
option :format, aliases: "-f", default: 'text', enum: ['text', 'yaml', 'json'], desc: "Output format"
|
25
|
+
desc "endpoint ID", "Preview an endpoint"
|
26
|
+
long_desc <<-LONGDESC
|
27
|
+
`xapixctl preview endpoint` will return a preview of the given endpoint.
|
28
|
+
|
29
|
+
The preview function will not call any external data sources but calculate a preview based on the provided sample data.
|
30
|
+
|
31
|
+
Examples:
|
32
|
+
\x5> $ xapixctl preview endpoint -o xapix -p some-project endpoint
|
33
|
+
\x5> $ xapixctl preview endpoint -p xapix/some-project endpoint
|
34
|
+
LONGDESC
|
35
|
+
def endpoint(endpoint)
|
36
|
+
puts prj_connection.endpoint_preview(endpoint, format: options[:format].to_sym)
|
37
|
+
end
|
38
|
+
|
39
|
+
option :format, aliases: "-f", default: 'text', enum: ['text', 'yaml', 'json'], desc: "Output format"
|
40
|
+
desc "stream-processor ID", "Preview a stream processor"
|
41
|
+
long_desc <<-LONGDESC
|
42
|
+
`xapixctl preview stream-processor` will return a preview of the given stream processor.
|
43
|
+
|
44
|
+
The preview function will not call any external data sources but calculate a preview based on the provided sample data.
|
45
|
+
|
46
|
+
Examples:
|
47
|
+
\x5> $ xapixctl preview stream-processor -o xapix -p some-project processor
|
48
|
+
\x5> $ xapixctl preview stream-processor -p xapix/some-project processor
|
49
|
+
LONGDESC
|
50
|
+
def stream_processor(stream_processor)
|
51
|
+
puts prj_connection.stream_processor_preview(stream_processor, format: options[:format].to_sym)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,166 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'xapixctl/base_cli'
|
4
|
+
require 'pathname'
|
5
|
+
|
6
|
+
module Xapixctl
|
7
|
+
class SyncCli < BaseCli
|
8
|
+
class_option :credentials, desc: "Whether to include Credential resources in sync", type: :boolean, default: true
|
9
|
+
class_option :exclude_types, desc: "Resource types to exclude from sync", type: :array
|
10
|
+
|
11
|
+
desc "to-dir DIRECTORY", "Syncs resources in project to directory"
|
12
|
+
long_desc <<-LONGDESC
|
13
|
+
`xapixctl sync to-dir DIRECTORY` will export all resources of a given project and remove any additional resources from the directory.
|
14
|
+
|
15
|
+
With --no-credentials you can exclude all credentials from getting exported.
|
16
|
+
|
17
|
+
With --exclude-types you can specify any resource types besides Project you'd like to exclude.
|
18
|
+
|
19
|
+
When excluding types, the excluded types will be recorded in the sync directory in a file called .excluded_types, so that any future syncs will exclude those types.
|
20
|
+
|
21
|
+
Examples:
|
22
|
+
\x5> $ xapixctl sync to-dir ./project_dir -p xapix/some-project
|
23
|
+
\x5> $ xapixctl sync to-dir ./project_dir -p xapix/some-project --no-credentials
|
24
|
+
\x5> $ xapixctl sync to-dir ./project_dir -p xapix/some-project --exclude-types=ApiPublishing ApiPublishingRole Credential
|
25
|
+
LONGDESC
|
26
|
+
def to_dir(dir)
|
27
|
+
sync_path = SyncPath.new(dir, prj_connection.resource_types_for_export, excluded_types)
|
28
|
+
|
29
|
+
res_details = prj_connection.project_resource
|
30
|
+
sync_path.write_file(generate_readme(res_details), 'README.md')
|
31
|
+
sync_path.write_resource_yaml(res_details, 'project')
|
32
|
+
|
33
|
+
sync_path.types_to_sync.each do |type|
|
34
|
+
res_path = sync_path.resource_path(type)
|
35
|
+
prj_connection.resource_ids(type).each do |res_id|
|
36
|
+
res_details = prj_connection.resource(type, res_id)
|
37
|
+
res_path.write_resource_yaml(res_details, res_id)
|
38
|
+
end
|
39
|
+
res_path.remove_outdated_resources
|
40
|
+
end
|
41
|
+
sync_path.update_excluded_types_file
|
42
|
+
end
|
43
|
+
|
44
|
+
desc "from-dir DIRECTORY", "Syncs resources in project from directory"
|
45
|
+
long_desc <<-LONGDESC
|
46
|
+
`xapixctl sync from-dir project dir` will import all resources into the given project from the directory and remove any additional resources which are not present in the directory.
|
47
|
+
|
48
|
+
With --no-credentials you can exclude all credentials from getting exported.
|
49
|
+
|
50
|
+
With --exclude-types you can specify any resource types besides Project you'd like to exclude.
|
51
|
+
|
52
|
+
Examples:
|
53
|
+
\x5> $ xapixctl sync from-dir ./project_dir -p xapix/some-project
|
54
|
+
\x5> $ xapixctl sync from-dir ./project_dir -p xapix/some-project --no-credentials
|
55
|
+
\x5> $ xapixctl sync from-dir ./project_dir -p xapix/some-project --exclude-types=ApiPublishing ApiPublishingRole Credential
|
56
|
+
LONGDESC
|
57
|
+
def from_dir(dir)
|
58
|
+
sync_path = SyncPath.new(dir, prj_connection.resource_types_for_export, excluded_types)
|
59
|
+
|
60
|
+
sync_path.load_resource('project') do |desc|
|
61
|
+
puts "applying #{desc['kind']} #{desc.dig('metadata', 'id')} to #{prj_connection.project}"
|
62
|
+
desc['metadata']['id'] = prj_connection.project
|
63
|
+
prj_connection.organization.apply(desc)
|
64
|
+
end
|
65
|
+
|
66
|
+
outdated_resources = {}
|
67
|
+
sync_path.types_to_sync.each do |type|
|
68
|
+
res_path = sync_path.resource_path(type)
|
69
|
+
updated_resource_ids = []
|
70
|
+
res_path.load_resources do |desc|
|
71
|
+
puts "applying #{desc['kind']} #{desc.dig('metadata', 'id')}"
|
72
|
+
updated_resource_ids += prj_connection.apply(desc)
|
73
|
+
end
|
74
|
+
outdated_resources[type] = prj_connection.resource_ids(type) - updated_resource_ids
|
75
|
+
end
|
76
|
+
|
77
|
+
outdated_resources.each do |type, resource_ids|
|
78
|
+
resource_ids.each do |resource_id|
|
79
|
+
puts "removing #{type} #{resource_id}"
|
80
|
+
prj_connection.delete(type, resource_id)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
private
|
86
|
+
|
87
|
+
class ResourcePath
|
88
|
+
def initialize(path)
|
89
|
+
@path = path
|
90
|
+
@resource_files = []
|
91
|
+
end
|
92
|
+
|
93
|
+
def write_file(content, filename)
|
94
|
+
@path.mkpath
|
95
|
+
unless @path.directory? && @path.writable?
|
96
|
+
warn "Cannot write to #{@path}, please check directory exists and is writable"
|
97
|
+
exit 1
|
98
|
+
end
|
99
|
+
file = @path.join(filename)
|
100
|
+
file.write(content)
|
101
|
+
puts "updated #{file}..."
|
102
|
+
file
|
103
|
+
end
|
104
|
+
|
105
|
+
def write_resource_yaml(res_details, res_name)
|
106
|
+
file = write_file(res_details.to_yaml, "#{res_name}.yaml")
|
107
|
+
@resource_files << file
|
108
|
+
file
|
109
|
+
end
|
110
|
+
|
111
|
+
def load_resources(&block)
|
112
|
+
Util.resources_from_file(@path, ignore_missing: true, &block)
|
113
|
+
end
|
114
|
+
|
115
|
+
def load_resource(res_name, &block)
|
116
|
+
Util.resources_from_file(@path.join("#{res_name}.yaml"), ignore_missing: false, &block)
|
117
|
+
end
|
118
|
+
|
119
|
+
def remove_outdated_resources
|
120
|
+
(@path.glob('*.yaml') - @resource_files).each do |outdated_file|
|
121
|
+
outdated_file.delete
|
122
|
+
puts "removed #{outdated_file}"
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
class SyncPath < ResourcePath
|
128
|
+
attr_reader :types_to_sync
|
129
|
+
|
130
|
+
def initialize(dir, all_types, excluded_types)
|
131
|
+
super(Pathname.new(dir))
|
132
|
+
@all_types = all_types
|
133
|
+
@excluded_types_file = @path.join('.excluded_types')
|
134
|
+
@excluded_types = excluded_types || []
|
135
|
+
@excluded_types += @excluded_types_file.read.split if @excluded_types_file.exist?
|
136
|
+
@excluded_types &= @all_types
|
137
|
+
@excluded_types.sort!
|
138
|
+
@types_to_sync = @all_types - @excluded_types
|
139
|
+
puts "Resource types excluded from sync: #{@excluded_types.join(', ')}" if @excluded_types.any?
|
140
|
+
end
|
141
|
+
|
142
|
+
def resource_path(type)
|
143
|
+
ResourcePath.new(@path.join(type.underscore))
|
144
|
+
end
|
145
|
+
|
146
|
+
def update_excluded_types_file
|
147
|
+
@excluded_types_file.write(@excluded_types.join(" ") + "\n") if @excluded_types.any?
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def excluded_types
|
152
|
+
excluded = options[:exclude_types]
|
153
|
+
excluded += ['Credential'] unless options[:credentials]
|
154
|
+
excluded
|
155
|
+
end
|
156
|
+
|
157
|
+
def generate_readme(res_details)
|
158
|
+
<<~EOREADME
|
159
|
+
# #{res_details.dig('definition', 'name')}
|
160
|
+
#{res_details.dig('definition', 'description')}
|
161
|
+
|
162
|
+
Project exported from #{File.join(prj_connection.public_project_url)} by xapixctl v#{Xapixctl::VERSION}.
|
163
|
+
EOREADME
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
@@ -0,0 +1,281 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'xapixctl/base_cli'
|
4
|
+
|
5
|
+
module Xapixctl
|
6
|
+
class TitanCli < BaseCli
|
7
|
+
DEFAULT_METHODS = {
|
8
|
+
"predict" => :predict,
|
9
|
+
"performance" => :performance,
|
10
|
+
}.freeze
|
11
|
+
|
12
|
+
option :schema_import, desc: "Resource id of an existing Schema import"
|
13
|
+
option :data, desc: "JSON encoded data for predict API", default: "[[1,2,3]]"
|
14
|
+
desc "service URL", "build a service for the deployed model"
|
15
|
+
long_desc <<-LONGDESC
|
16
|
+
`xapixctl titan service URL` will build a ML service around a deployed ML model.
|
17
|
+
|
18
|
+
We expect the following of the deployed ML model:
|
19
|
+
|
20
|
+
- There should be a "POST /predict" endpoint. Use --data="JSON" to specify an example dataset the model expects.
|
21
|
+
|
22
|
+
- If there is a "GET /performance" endpoint, it'll be made available. It's expected to return a JSON object with the properties 'accuracy', 'precision', 'recall', and 'min_acc_threshold'.
|
23
|
+
|
24
|
+
Examples:
|
25
|
+
\x5> $ xapixctl titan service https://services.demo.akoios.com/ai-model-name -p xapix/ml-project
|
26
|
+
LONGDESC
|
27
|
+
def service(akoios_url)
|
28
|
+
url = URI.parse(akoios_url)
|
29
|
+
schema = JSON.parse(RestClient.get(File.join(url.to_s, 'spec'), params: { host: url.hostname }, headers: { accept: :json }))
|
30
|
+
patch_schema(schema)
|
31
|
+
connector_refs = import_swagger(File.basename(url.path), schema)
|
32
|
+
say "\n== Onboarding Connectors", :bold
|
33
|
+
connectors = match_connectors_to_action(connector_refs)
|
34
|
+
say "\n== Building Service", :bold
|
35
|
+
service_doc = build_service(schema.dig('info', 'title'), connectors)
|
36
|
+
res = prj_connection.apply(service_doc)
|
37
|
+
say "\ncreated / updated service #{res.first}"
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def patch_schema(schema)
|
43
|
+
predict_schema = schema.dig('paths', '/predict', 'post')
|
44
|
+
if predict_schema
|
45
|
+
predict_data = JSON.parse(options[:data]) rescue {}
|
46
|
+
predict_schema['operationId'] = 'predict'
|
47
|
+
predict_schema['parameters'].each do |param|
|
48
|
+
if param['name'] == 'json' && param['in'] == 'body'
|
49
|
+
param['schema']['properties'] = { "data" => extract_schema(predict_data) }
|
50
|
+
param['schema']['example'] = { "data" => predict_data }
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
performane_schema = schema.dig('paths', '/performance', 'get')
|
56
|
+
if performane_schema
|
57
|
+
performane_schema['operationId'] = 'performance'
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def import_swagger(filename, schema)
|
62
|
+
Tempfile.create([filename, '.json']) do |f|
|
63
|
+
f.write(schema.to_json)
|
64
|
+
f.rewind
|
65
|
+
|
66
|
+
if options[:schema_import]
|
67
|
+
result = prj_connection.update_schema_import(options[:schema_import], f)
|
68
|
+
say "updated #{result.dig('resource', 'kind')} #{result.dig('resource', 'id')}"
|
69
|
+
else
|
70
|
+
result = prj_connection.add_schema_import(f)
|
71
|
+
say "created #{result.dig('resource', 'kind')} #{result.dig('resource', 'id')}"
|
72
|
+
end
|
73
|
+
result.dig('schema_import', 'updated_resources')
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def match_connectors_to_action(connector_refs)
|
78
|
+
connector_refs.map do |connector_ref|
|
79
|
+
connector = prj_connection.resource(connector_ref['kind'], connector_ref['id'])
|
80
|
+
action = DEFAULT_METHODS[connector.dig('definition', 'name')]
|
81
|
+
say "\n#{connector['kind']} #{connector.dig('definition', 'name')} -> "
|
82
|
+
if action
|
83
|
+
say "#{action} action"
|
84
|
+
[action, update_connector_with_preview(connector)]
|
85
|
+
else
|
86
|
+
say "no action type detected, ignoring"
|
87
|
+
nil
|
88
|
+
end
|
89
|
+
end.compact
|
90
|
+
end
|
91
|
+
|
92
|
+
def update_connector_with_preview(connector)
|
93
|
+
say "fetching preview for #{connector['kind']} #{connector.dig('definition', 'name')}..."
|
94
|
+
preview_details = prj_connection.data_source_preview(connector.dig('metadata', 'id'))
|
95
|
+
preview = preview_details.dig('preview', 'sample')
|
96
|
+
say "got a #{preview['status']} response: #{preview['body']}"
|
97
|
+
if preview['status'] != 200
|
98
|
+
say "unexpected status, please check data or model"
|
99
|
+
elsif yes?("Does this look alright?", :bold)
|
100
|
+
res = prj_connection.accept_data_source_preview(connector.dig('metadata', 'id'))
|
101
|
+
return res.dig('data_source', 'resource_description')
|
102
|
+
end
|
103
|
+
connector
|
104
|
+
end
|
105
|
+
|
106
|
+
def extract_schema(data_sample)
|
107
|
+
case data_sample
|
108
|
+
when Array
|
109
|
+
{ type: 'array', items: extract_schema(data_sample[0]) }
|
110
|
+
when Hash
|
111
|
+
{ type: 'object', properties: data_sample.transform_values { |v| extract_schema(v) } }
|
112
|
+
when Numeric
|
113
|
+
{ type: 'number' }
|
114
|
+
else
|
115
|
+
{}
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
def build_service(title, connectors)
|
120
|
+
{
|
121
|
+
version: 'v1',
|
122
|
+
kind: 'Service',
|
123
|
+
metadata: { id: title.parameterize },
|
124
|
+
definition: {
|
125
|
+
name: title.humanize,
|
126
|
+
actions: connectors.map { |action, connector| build_service_action(action, connector) }
|
127
|
+
}
|
128
|
+
}
|
129
|
+
end
|
130
|
+
|
131
|
+
def build_service_action(action_type, connector)
|
132
|
+
{
|
133
|
+
name: action_type,
|
134
|
+
parameter_schema: parameter_schema(action_type, connector),
|
135
|
+
result_schema: result_schema(action_type),
|
136
|
+
pipeline: { units: pipeline_units(action_type, connector) }
|
137
|
+
}
|
138
|
+
end
|
139
|
+
|
140
|
+
def parameter_schema(action_type, connector)
|
141
|
+
case action_type
|
142
|
+
when :predict
|
143
|
+
{ type: 'object', properties: {
|
144
|
+
data: connector.dig('definition', 'parameter_schema', 'properties', 'body', 'properties', 'data'),
|
145
|
+
} }
|
146
|
+
when :performance
|
147
|
+
{ type: 'object', properties: {} }
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def result_schema(action_type)
|
152
|
+
case action_type
|
153
|
+
when :predict
|
154
|
+
{ type: 'object', properties: {
|
155
|
+
prediction: { type: 'object', properties: { percent: { type: 'number' }, raw: { type: 'number' } } },
|
156
|
+
success: { type: 'boolean' },
|
157
|
+
error: { type: 'string' }
|
158
|
+
} }
|
159
|
+
when :performance
|
160
|
+
{ type: 'object', properties: {
|
161
|
+
performance: {
|
162
|
+
type: 'object', properties: {
|
163
|
+
accuracy: { type: 'number' },
|
164
|
+
precision: { type: 'number' },
|
165
|
+
recall: { type: 'number' },
|
166
|
+
min_acc_threshold: { type: 'number' }
|
167
|
+
}
|
168
|
+
}
|
169
|
+
} }
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
def pipeline_units(action_type, connector)
|
174
|
+
case action_type
|
175
|
+
when :predict
|
176
|
+
[entry_unit, predict_unit(connector), predict_result_unit]
|
177
|
+
when :performance
|
178
|
+
[entry_unit, performance_unit(connector), performance_result_unit]
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
def entry_unit
|
183
|
+
{ version: 'v2', kind: 'Unit/Entry',
|
184
|
+
metadata: { id: 'entry' },
|
185
|
+
definition: { name: 'Entry' } }
|
186
|
+
end
|
187
|
+
|
188
|
+
def predict_unit(connector)
|
189
|
+
leafs = extract_leafs(connector.dig('definition', 'parameter_schema', 'properties', 'body'))
|
190
|
+
{ version: 'v2', kind: 'Unit/DataSource',
|
191
|
+
metadata: { id: 'predict' },
|
192
|
+
definition: {
|
193
|
+
name: 'Predict',
|
194
|
+
inputs: ['entry'],
|
195
|
+
data_source: connector.dig('metadata', 'id'),
|
196
|
+
formulas: leafs.map { |leaf|
|
197
|
+
{ ref: leaf[:node]['$id'], formula: (['', 'entry'] + leaf[:path]).join('.') }
|
198
|
+
}
|
199
|
+
} }
|
200
|
+
end
|
201
|
+
|
202
|
+
def predict_result_unit
|
203
|
+
{ version: 'v2', kind: 'Unit/Result',
|
204
|
+
metadata: { id: 'result' },
|
205
|
+
definition: {
|
206
|
+
name: 'Result',
|
207
|
+
inputs: ['predict'],
|
208
|
+
formulas: [{
|
209
|
+
ref: "#prediction.raw",
|
210
|
+
formula: "IF(.predict.status = 200, ROUND(100*coerce.to-float(.predict.body), 2))"
|
211
|
+
}, {
|
212
|
+
ref: "#prediction.percent",
|
213
|
+
formula: "IF(.predict.status = 200, coerce.to-float(.predict.body))"
|
214
|
+
}, {
|
215
|
+
ref: "#success",
|
216
|
+
formula: ".predict.status = 200"
|
217
|
+
}, {
|
218
|
+
ref: "#error",
|
219
|
+
formula: "IF(.predict.status <> 200, 'Model not trained!')"
|
220
|
+
}],
|
221
|
+
parameter_sample: {
|
222
|
+
"prediction" => { "percent" => 51, "raw" => 0.5112131 },
|
223
|
+
"success" => true,
|
224
|
+
"error" => nil
|
225
|
+
}
|
226
|
+
} }
|
227
|
+
end
|
228
|
+
|
229
|
+
def performance_unit(connector)
|
230
|
+
{ version: 'v2', kind: 'Unit/DataSource',
|
231
|
+
metadata: { id: 'performance' },
|
232
|
+
definition: {
|
233
|
+
name: 'Performance',
|
234
|
+
inputs: ['entry'],
|
235
|
+
data_source: connector.dig('metadata', 'id')
|
236
|
+
} }
|
237
|
+
end
|
238
|
+
|
239
|
+
def performance_result_unit
|
240
|
+
{ version: 'v2', kind: 'Unit/Result',
|
241
|
+
metadata: { id: 'result' },
|
242
|
+
definition: {
|
243
|
+
name: 'Result',
|
244
|
+
inputs: ['performance'],
|
245
|
+
formulas: [{
|
246
|
+
ref: "#performance.recall",
|
247
|
+
formula: "WITH(data, JSON.DECODE(REGEXREPLACE(performance.body, \"'\", \"\\\"\")), .data.recall)"
|
248
|
+
}, {
|
249
|
+
ref: "#performance.accuracy",
|
250
|
+
formula: "WITH(data, JSON.DECODE(REGEXREPLACE(performance.body, \"'\", \"\\\"\")), .data.accuracy)"
|
251
|
+
}, {
|
252
|
+
ref: "#performance.precision",
|
253
|
+
formula: "WITH(data, JSON.DECODE(REGEXREPLACE(performance.body, \"'\", \"\\\"\")), .data.precision)"
|
254
|
+
}, {
|
255
|
+
ref: "#performance.min_acc_threshold",
|
256
|
+
formula: "WITH(data, JSON.DECODE(REGEXREPLACE(performance.body, \"'\", \"\\\"\")), .data.min_acc_threshold)"
|
257
|
+
}],
|
258
|
+
parameter_sample: {
|
259
|
+
"performance" => {
|
260
|
+
"recall" => 0.8184713375796179,
|
261
|
+
"accuracy" => 0.9807847896440129,
|
262
|
+
"precision" => 0.8711864406779661,
|
263
|
+
"min_acc_threshold" => 0.84,
|
264
|
+
}
|
265
|
+
}
|
266
|
+
} }
|
267
|
+
end
|
268
|
+
|
269
|
+
def extract_leafs(schema, current_path = [])
|
270
|
+
return unless schema
|
271
|
+
case schema['type']
|
272
|
+
when 'object'
|
273
|
+
schema['properties'].flat_map { |key, sub_schema| extract_leafs(sub_schema, current_path + [key]) }.compact
|
274
|
+
when 'array'
|
275
|
+
extract_leafs(schema['items'], current_path + [:[]])
|
276
|
+
else
|
277
|
+
{ path: current_path, node: schema }
|
278
|
+
end
|
279
|
+
end
|
280
|
+
end
|
281
|
+
end
|