vcap_common 1.0.10
Sign up to get free protection for your applications and to get access to all the features.
- data/lib/json_message.rb +139 -0
- data/lib/json_schema.rb +84 -0
- data/lib/services/api/async_requests.rb +43 -0
- data/lib/services/api/clients/service_gateway_client.rb +107 -0
- data/lib/services/api/const.rb +9 -0
- data/lib/services/api/messages.rb +153 -0
- data/lib/services/api/util.rb +17 -0
- data/lib/services/api.rb +6 -0
- data/lib/vcap/common.rb +236 -0
- data/lib/vcap/component.rb +172 -0
- data/lib/vcap/config.rb +32 -0
- data/lib/vcap/fiber_tracing.rb +45 -0
- data/lib/vcap/json_schema.rb +202 -0
- data/lib/vcap/priority_queue.rb +164 -0
- data/lib/vcap/process_utils.rb +43 -0
- data/lib/vcap/quota.rb +152 -0
- data/lib/vcap/rolling_metric.rb +74 -0
- data/lib/vcap/spec/em.rb +32 -0
- data/lib/vcap/spec/forked_component/base.rb +87 -0
- data/lib/vcap/spec/forked_component/nats_server.rb +28 -0
- data/lib/vcap/spec/forked_component.rb +2 -0
- data/lib/vcap/subprocess.rb +211 -0
- data/lib/vcap/user_pools/user_ops.rb +47 -0
- data/lib/vcap/user_pools/user_pool.rb +45 -0
- data/lib/vcap/user_pools/user_pool_util.rb +107 -0
- metadata +166 -0
data/lib/json_message.rb
ADDED
@@ -0,0 +1,139 @@
|
|
1
|
+
# Copyright (c) 2009-2011 VMware, Inc.
|
2
|
+
require 'rubygems'
|
3
|
+
|
4
|
+
require 'yajl'
|
5
|
+
|
6
|
+
require 'json_schema'
|
7
|
+
|
8
|
+
class JsonMessage
|
9
|
+
# Base error class that all other JsonMessage related errors should inherit from
|
10
|
+
class Error < StandardError
|
11
|
+
end
|
12
|
+
|
13
|
+
# Failed to parse json during +decode+
|
14
|
+
class ParseError < Error
|
15
|
+
end
|
16
|
+
|
17
|
+
# One or more field's values didn't match their schema
|
18
|
+
class ValidationError < Error
|
19
|
+
def initialize(field_errs)
|
20
|
+
@field_errs = field_errs
|
21
|
+
end
|
22
|
+
|
23
|
+
def to_s
|
24
|
+
err_strs = @field_errs.map{|f, e| "Field: #{f}, Error: #{e}"}
|
25
|
+
err_strs.join(', ')
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
class Field
|
30
|
+
attr_reader :name, :schema, :required
|
31
|
+
|
32
|
+
def initialize(name, schema, required=true)
|
33
|
+
@name = name
|
34
|
+
@schema = schema.is_a?(JsonSchema) ? schema : JsonSchema.new(schema)
|
35
|
+
@required = required
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
class << self
|
40
|
+
attr_reader :fields
|
41
|
+
|
42
|
+
def schema(&blk)
|
43
|
+
instance_eval &blk
|
44
|
+
end
|
45
|
+
|
46
|
+
def decode(json)
|
47
|
+
begin
|
48
|
+
dec_json = Yajl::Parser.parse(json)
|
49
|
+
rescue => e
|
50
|
+
raise ParseError, e.to_s
|
51
|
+
end
|
52
|
+
|
53
|
+
from_decoded_json(dec_json)
|
54
|
+
end
|
55
|
+
|
56
|
+
def from_decoded_json(dec_json)
|
57
|
+
raise ParseError, "Decoded JSON cannot be nil" unless dec_json
|
58
|
+
|
59
|
+
errs = {}
|
60
|
+
|
61
|
+
# Treat null values as if the keys aren't present. This isn't as strict as one would like,
|
62
|
+
# but conforms to typical use cases.
|
63
|
+
dec_json.delete_if {|k, v| v == nil}
|
64
|
+
|
65
|
+
# Collect errors by field
|
66
|
+
@fields.each do |name, field|
|
67
|
+
err = nil
|
68
|
+
name_s = name.to_s
|
69
|
+
if dec_json.has_key?(name_s)
|
70
|
+
err = field.schema.validate(dec_json[name_s])
|
71
|
+
elsif field.required
|
72
|
+
err = "Missing field #{name}"
|
73
|
+
end
|
74
|
+
errs[name] = err if err
|
75
|
+
end
|
76
|
+
|
77
|
+
raise ValidationError.new(errs) unless errs.empty?
|
78
|
+
|
79
|
+
new(dec_json)
|
80
|
+
end
|
81
|
+
|
82
|
+
def required(field_name, schema=JsonSchema::WILDCARD)
|
83
|
+
define_field(field_name, schema, true)
|
84
|
+
end
|
85
|
+
|
86
|
+
def optional(field_name, schema=JsonSchema::WILDCARD)
|
87
|
+
define_field(field_name, schema, false)
|
88
|
+
end
|
89
|
+
|
90
|
+
protected
|
91
|
+
|
92
|
+
def define_field(name, schema, required)
|
93
|
+
name = name.to_sym
|
94
|
+
|
95
|
+
@fields ||= {}
|
96
|
+
@fields[name] = Field.new(name, schema, required)
|
97
|
+
|
98
|
+
define_method name.to_sym do
|
99
|
+
@msg[name]
|
100
|
+
end
|
101
|
+
|
102
|
+
define_method "#{name}=".to_sym do |value|
|
103
|
+
set_field(name, value)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def initialize(fields={})
|
109
|
+
@msg = {}
|
110
|
+
fields.each {|k, v| set_field(k, v)}
|
111
|
+
end
|
112
|
+
|
113
|
+
def encode
|
114
|
+
if self.class.fields
|
115
|
+
missing_fields = {}
|
116
|
+
self.class.fields.each do |name, field|
|
117
|
+
missing_fields[name] = "Missing field #{name}" unless (!field.required || @msg.has_key?(name))
|
118
|
+
end
|
119
|
+
raise ValidationError.new(missing_fields) unless missing_fields.empty?
|
120
|
+
end
|
121
|
+
|
122
|
+
Yajl::Encoder.encode(@msg)
|
123
|
+
end
|
124
|
+
|
125
|
+
def extract
|
126
|
+
@msg.dup.freeze
|
127
|
+
end
|
128
|
+
|
129
|
+
protected
|
130
|
+
|
131
|
+
def set_field(field, value)
|
132
|
+
field = field.to_sym
|
133
|
+
raise ValidationError.new({field => "Unknown field #{field}"}) unless self.class.fields.has_key?(field)
|
134
|
+
|
135
|
+
errs = self.class.fields[field].schema.validate(value)
|
136
|
+
raise ValidationError.new({field => errs}) if errs
|
137
|
+
@msg[field] = value
|
138
|
+
end
|
139
|
+
end
|
data/lib/json_schema.rb
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
# Copyright (c) 2009-2011 VMware, Inc.
|
2
|
+
# This class provides dead simple declarative validation for decoded json using
|
3
|
+
# a fairly intuitive DSL like syntax.
|
4
|
+
#
|
5
|
+
# For example, the following is a sample schema that exercises all functionality
|
6
|
+
#
|
7
|
+
# {'foo' => [String], # 'foo' must be a list of strings
|
8
|
+
# 'bar' => {'baz' => Fixnum, # 'bar' must be a hash where
|
9
|
+
# 'jaz' => /foo/, # 'baz' is a Fixnum, and
|
10
|
+
# } # 'jaz' matches the regex /foo/
|
11
|
+
# }
|
12
|
+
#
|
13
|
+
class JsonSchema
|
14
|
+
WILDCARD = Object
|
15
|
+
|
16
|
+
# TODO(mjp): validate that schema is syntatically correct
|
17
|
+
|
18
|
+
def initialize(schema)
|
19
|
+
@schema = schema
|
20
|
+
end
|
21
|
+
|
22
|
+
def validate(json)
|
23
|
+
_validate(json, @schema)
|
24
|
+
end
|
25
|
+
|
26
|
+
protected
|
27
|
+
|
28
|
+
def _validate(json, schema)
|
29
|
+
case schema
|
30
|
+
when Class
|
31
|
+
# Terminal case, type check
|
32
|
+
klass = json.class
|
33
|
+
if json.is_a? schema
|
34
|
+
nil
|
35
|
+
else
|
36
|
+
"Type mismatch (expected #{schema}, got #{klass})"
|
37
|
+
end
|
38
|
+
|
39
|
+
when Hash
|
40
|
+
# Recursive case, check for required params, recursively check them against the supplied schema
|
41
|
+
missing_keys = schema.keys.select {|k| !json.has_key?(k)} if json.is_a? Hash
|
42
|
+
|
43
|
+
if !(json.is_a? Hash)
|
44
|
+
"Type mismatch (expected hash, got #{json.class})"
|
45
|
+
elsif missing_keys.length > 0
|
46
|
+
"Missing params: '#{missing_keys.join(', ')}'"
|
47
|
+
else
|
48
|
+
errs = nil
|
49
|
+
schema.each_key do |k|
|
50
|
+
sub_errs = _validate(json[k], schema[k])
|
51
|
+
if sub_errs
|
52
|
+
errs ||= {}
|
53
|
+
errs[k] = sub_errs
|
54
|
+
end
|
55
|
+
end
|
56
|
+
errs
|
57
|
+
end
|
58
|
+
|
59
|
+
when Array
|
60
|
+
# Recursive case, check that array isn't empty, recursively check array against supplied schema
|
61
|
+
if !(json.is_a? Array)
|
62
|
+
"Type mismatch (expected array, got #{json.class}"
|
63
|
+
else
|
64
|
+
errs = nil
|
65
|
+
json.each do |v|
|
66
|
+
errs = _validate(v, schema[0])
|
67
|
+
break if errs
|
68
|
+
end
|
69
|
+
errs
|
70
|
+
end
|
71
|
+
|
72
|
+
when Regexp
|
73
|
+
if schema.match(json)
|
74
|
+
nil
|
75
|
+
else
|
76
|
+
"Invalid value (doesn't match '#{schema.source})"
|
77
|
+
end
|
78
|
+
|
79
|
+
else
|
80
|
+
# Terminal case, value check
|
81
|
+
"Value mismatch (expected '#{schema}', got #{json})" unless json == schema
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# Copyright (c) 2009-2011 VMware, Inc.
|
2
|
+
require 'eventmachine'
|
3
|
+
require 'em-http-request'
|
4
|
+
require 'fiber'
|
5
|
+
|
6
|
+
require 'services/api/const'
|
7
|
+
|
8
|
+
module VCAP
|
9
|
+
module Services
|
10
|
+
module Api
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
module VCAP::Services::Api
|
16
|
+
class AsyncHttpRequest
|
17
|
+
class << self
|
18
|
+
def new(url, token, verb, timeout, msg=VCAP::Services::Api::EMPTY_REQUEST)
|
19
|
+
|
20
|
+
req = {
|
21
|
+
:head => {
|
22
|
+
VCAP::Services::Api::GATEWAY_TOKEN_HEADER => token,
|
23
|
+
'Content-Type' => 'application/json',
|
24
|
+
},
|
25
|
+
:body => msg.encode,
|
26
|
+
}
|
27
|
+
if timeout
|
28
|
+
EM::HttpRequest.new(url, :inactivity_timeout => timeout).send(verb.to_sym, req)
|
29
|
+
else
|
30
|
+
EM::HttpRequest.new(url).send(verb.to_sym, req)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def fibered(url, token, verb, timeout, msg=VCAP::Services::Api::EMPTY_REQUEST)
|
35
|
+
req = new(url, token, verb, timeout, msg)
|
36
|
+
f = Fiber.current
|
37
|
+
req.callback { f.resume(req) }
|
38
|
+
req.errback { f.resume(req) }
|
39
|
+
Fiber.yield
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
# Copyright (c) 2009-2011 VMware, Inc.
|
2
|
+
require 'net/http'
|
3
|
+
|
4
|
+
require 'services/api/const'
|
5
|
+
require 'services/api/messages'
|
6
|
+
|
7
|
+
module VCAP
|
8
|
+
module Services
|
9
|
+
module Api
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
class VCAP::Services::Api::ServiceGatewayClient
|
15
|
+
|
16
|
+
class UnexpectedResponse < StandardError
|
17
|
+
attr_reader :response
|
18
|
+
|
19
|
+
def initialize(resp)
|
20
|
+
@response = resp
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
attr_reader :host, :port, :token
|
25
|
+
|
26
|
+
def initialize(host, token, port=80)
|
27
|
+
@host = host
|
28
|
+
@port = port
|
29
|
+
@token = token
|
30
|
+
@hdrs = {
|
31
|
+
'Content-Type' => 'application/json',
|
32
|
+
VCAP::Services::Api::GATEWAY_TOKEN_HEADER => @token
|
33
|
+
}
|
34
|
+
end
|
35
|
+
|
36
|
+
def provision(args)
|
37
|
+
msg = VCAP::Services::Api::GatewayProvisionRequest.new(args)
|
38
|
+
resp = perform_request(Net::HTTP::Post, '/gateway/v1/configurations', msg)
|
39
|
+
VCAP::Services::Api::GatewayProvisionResponse.decode(resp.body)
|
40
|
+
end
|
41
|
+
|
42
|
+
def unprovision(args)
|
43
|
+
perform_request(Net::HTTP::Delete, "/gateway/v1/configurations/#{args[:service_id]}")
|
44
|
+
end
|
45
|
+
|
46
|
+
def create_snapshot(args)
|
47
|
+
resp = perform_request(Net::HTTP::Post, "/gateway/v1/configurations/#{args[:service_id]}/snapshots")
|
48
|
+
VCAP::Services::Api::Job.decode(resp.body)
|
49
|
+
end
|
50
|
+
|
51
|
+
def enum_snapshots(args)
|
52
|
+
resp = perform_request(Net::HTTP::Get, "/gateway/v1/configurations/#{args[:service_id]}/snapshots")
|
53
|
+
VCAP::Services::Api::SnapshotList.decode(resp.body)
|
54
|
+
end
|
55
|
+
|
56
|
+
def snapshot_details(args)
|
57
|
+
resp = perform_request(Net::HTTP::Get, "/gateway/v1/configurations/#{args[:service_id]}/snapshots/#{args[:snapshot_id]}")
|
58
|
+
VCAP::Services::Api::Snapshot.decode(resp.body)
|
59
|
+
end
|
60
|
+
|
61
|
+
def rollback_snapshot(args)
|
62
|
+
resp = perform_request(Net::HTTP::Put, "/gateway/v1/configurations/#{args[:service_id]}/snapshots/#{args[:snapshot_id]}")
|
63
|
+
VCAP::Services::Api::Job.decode(resp.body)
|
64
|
+
end
|
65
|
+
|
66
|
+
def serialized_url(args)
|
67
|
+
resp = perform_request(Net::HTTP::Get, "/gateway/v1/configurations/#{args[:service_id]}/serialized/url")
|
68
|
+
VCAP::Services::Api::Job.decode(resp.body)
|
69
|
+
end
|
70
|
+
|
71
|
+
def import_from_url(args)
|
72
|
+
resp = perform_request(Net::HTTP::Put, "/gateway/v1/configurations/#{args[:service_id]}/serialized/url", args[:msg])
|
73
|
+
VCAP::Services::Api::Job.decode(resp.body)
|
74
|
+
end
|
75
|
+
|
76
|
+
def import_from_data(args)
|
77
|
+
resp = perform_request(Net::HTTP::Put, "/gateway/v1/configurations/#{args[:service_id]}/serialized/data", args[:msg])
|
78
|
+
VCAP::Services::Api::Job.decode(resp.body)
|
79
|
+
end
|
80
|
+
|
81
|
+
def job_info(args)
|
82
|
+
resp = perform_request(Net::HTTP::Get, "/gateway/v1/configurations/#{args[:service_id]}/jobs/#{args[:job_id]}")
|
83
|
+
VCAP::Services::Api::Job.decode(resp.body)
|
84
|
+
end
|
85
|
+
|
86
|
+
def bind(args)
|
87
|
+
msg = VCAP::Services::Api::GatewayBindRequest.new(args)
|
88
|
+
resp = perform_request(Net::HTTP::Post, "/gateway/v1/configurations/#{msg.service_id}/handles", msg)
|
89
|
+
VCAP::Services::Api::GatewayBindResponse.decode(resp.body)
|
90
|
+
end
|
91
|
+
|
92
|
+
def unbind(args)
|
93
|
+
msg = VCAP::Services::Api::GatewayUnbindRequest.new(args)
|
94
|
+
perform_request(Net::HTTP::Delete, "/gateway/v1/configurations/#{msg.service_id}/handles/#{msg.handle_id}", msg)
|
95
|
+
end
|
96
|
+
|
97
|
+
protected
|
98
|
+
|
99
|
+
def perform_request(klass, path, msg=VCAP::Services::Api::EMPTY_REQUEST)
|
100
|
+
req = klass.new(path, initheader=@hdrs)
|
101
|
+
req.body = msg.encode
|
102
|
+
resp = Net::HTTP.new(@host, @port).start {|http| http.request(req)}
|
103
|
+
raise UnexpectedResponse, resp unless resp.is_a? Net::HTTPOK
|
104
|
+
resp
|
105
|
+
end
|
106
|
+
|
107
|
+
end
|
@@ -0,0 +1,153 @@
|
|
1
|
+
# Copyright (c) 2009-2011 VMware, Inc.
|
2
|
+
require 'uri'
|
3
|
+
|
4
|
+
require 'services/api/const'
|
5
|
+
require 'json_message'
|
6
|
+
|
7
|
+
module VCAP
|
8
|
+
module Services
|
9
|
+
module Api
|
10
|
+
|
11
|
+
class EmptyRequest < JsonMessage
|
12
|
+
end
|
13
|
+
EMPTY_REQUEST = EmptyRequest.new.freeze
|
14
|
+
|
15
|
+
#
|
16
|
+
# Tell the CloudController about a service
|
17
|
+
# NB: Deleting an offering takes all args in the url
|
18
|
+
#
|
19
|
+
class ServiceOfferingRequest < JsonMessage
|
20
|
+
required :label, SERVICE_LABEL_REGEX
|
21
|
+
required :url, URI::regexp(%w(http https))
|
22
|
+
|
23
|
+
optional :description, String
|
24
|
+
optional :info_url, URI::regexp(%w(http https))
|
25
|
+
optional :tags, [String]
|
26
|
+
optional :plans, [String]
|
27
|
+
optional :plan_options
|
28
|
+
optional :binding_options
|
29
|
+
optional :acls
|
30
|
+
optional :active
|
31
|
+
optional :timeout, Integer
|
32
|
+
end
|
33
|
+
|
34
|
+
class BrokeredServiceOfferingRequest < JsonMessage
|
35
|
+
required :label, SERVICE_LABEL_REGEX
|
36
|
+
required :options, [{"name" => String, "credentials" => Hash}]
|
37
|
+
optional :description, String
|
38
|
+
end
|
39
|
+
|
40
|
+
class HandleUpdateRequest < JsonMessage
|
41
|
+
required :service_id, String
|
42
|
+
required :configuration
|
43
|
+
required :credentials
|
44
|
+
end
|
45
|
+
|
46
|
+
class ListHandlesResponse < JsonMessage
|
47
|
+
required :handles, [::JsonSchema::WILDCARD]
|
48
|
+
end
|
49
|
+
|
50
|
+
class ListBrokeredServicesResponse < JsonMessage
|
51
|
+
required :brokered_services, [{"label" => String, "description" => String, "acls" => {"users" => [String], "wildcards" => [String]}}]
|
52
|
+
end
|
53
|
+
|
54
|
+
#
|
55
|
+
# Provision a service instance
|
56
|
+
# NB: Unprovision takes all args in the url
|
57
|
+
#
|
58
|
+
class CloudControllerProvisionRequest < JsonMessage
|
59
|
+
required :label, SERVICE_LABEL_REGEX
|
60
|
+
required :name, String
|
61
|
+
required :plan, String
|
62
|
+
|
63
|
+
optional :plan_option
|
64
|
+
end
|
65
|
+
|
66
|
+
class GatewayProvisionRequest < JsonMessage
|
67
|
+
required :label, SERVICE_LABEL_REGEX
|
68
|
+
required :name, String
|
69
|
+
required :plan, String
|
70
|
+
required :email, String
|
71
|
+
|
72
|
+
optional :plan_option
|
73
|
+
end
|
74
|
+
|
75
|
+
class GatewayProvisionResponse < JsonMessage
|
76
|
+
required :service_id, String
|
77
|
+
required :data
|
78
|
+
required :credentials
|
79
|
+
end
|
80
|
+
|
81
|
+
#
|
82
|
+
# Bind a previously provisioned service to an app
|
83
|
+
#
|
84
|
+
class CloudControllerBindRequest < JsonMessage
|
85
|
+
required :service_id, String
|
86
|
+
required :app_id, Integer
|
87
|
+
required :binding_options
|
88
|
+
end
|
89
|
+
|
90
|
+
class GatewayBindRequest < JsonMessage
|
91
|
+
required :service_id, String
|
92
|
+
required :label, String
|
93
|
+
required :email, String
|
94
|
+
required :binding_options
|
95
|
+
end
|
96
|
+
|
97
|
+
class GatewayUnbindRequest < JsonMessage
|
98
|
+
required :service_id, String
|
99
|
+
required :handle_id, String
|
100
|
+
required :binding_options
|
101
|
+
end
|
102
|
+
|
103
|
+
class CloudControllerBindResponse < JsonMessage
|
104
|
+
required :label, SERVICE_LABEL_REGEX
|
105
|
+
required :binding_token, String
|
106
|
+
end
|
107
|
+
|
108
|
+
class GatewayBindResponse < JsonMessage
|
109
|
+
required :service_id, String
|
110
|
+
required :configuration
|
111
|
+
required :credentials
|
112
|
+
end
|
113
|
+
|
114
|
+
# Bind app_name using binding_token
|
115
|
+
class BindExternalRequest < JsonMessage
|
116
|
+
required :binding_token, String
|
117
|
+
required :app_id, Integer
|
118
|
+
end
|
119
|
+
|
120
|
+
class BindingTokenRequest < JsonMessage
|
121
|
+
required :service_id, String
|
122
|
+
required :binding_options
|
123
|
+
end
|
124
|
+
|
125
|
+
class Snapshot < JsonMessage
|
126
|
+
required :snapshot_id, String
|
127
|
+
required :date, String
|
128
|
+
required :size, Integer
|
129
|
+
end
|
130
|
+
|
131
|
+
class SnapshotList < JsonMessage
|
132
|
+
required :snapshots, [::JsonSchema::WILDCARD]
|
133
|
+
end
|
134
|
+
|
135
|
+
class Job < JsonMessage
|
136
|
+
required :job_id, String
|
137
|
+
required :status, String
|
138
|
+
required :start_time, String
|
139
|
+
optional :description, String
|
140
|
+
optional :complete_time, String
|
141
|
+
optional :result, ::JsonSchema::WILDCARD
|
142
|
+
end
|
143
|
+
|
144
|
+
class SerializedURL < JsonMessage
|
145
|
+
required :url, URI::regexp(%w(http https))
|
146
|
+
end
|
147
|
+
|
148
|
+
class SerializedData < JsonMessage
|
149
|
+
required :data, String
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# Copyright (c) 2009-2011 VMware, Inc.
|
2
|
+
module VCAP
|
3
|
+
module Services
|
4
|
+
module Api
|
5
|
+
end
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
class VCAP::Services::Api::Util
|
10
|
+
class << self
|
11
|
+
def parse_label(label)
|
12
|
+
raise ArgumentError, "Invalid label" unless label.match(/-/)
|
13
|
+
name, _, version = label.rpartition(/-/)
|
14
|
+
[name, version]
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|