swarmkit-client 0.1.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/LICENSE +201 -0
- data/NOTICE +485 -0
- data/README.md +257 -0
- data/bin/swarmkit-client +6 -0
- data/docs/api.md +3607 -0
- data/lib/swarmkit-client/api.rb +51 -0
- data/lib/swarmkit-client/cli.rb +159 -0
- data/lib/swarmkit-client/schema.pb +0 -0
- data/lib/swarmkit-client.rb +50 -0
- data/lib/version.rb +5 -0
- data/schema/sources.json +58 -0
- metadata +80 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'grpc'
|
|
4
|
+
require 'google/protobuf/descriptor_pb'
|
|
5
|
+
|
|
6
|
+
module Swarmkit
|
|
7
|
+
module API
|
|
8
|
+
REVISION = '17b8d222e7dd'
|
|
9
|
+
PACKAGE = 'docker.swarmkit.v1'
|
|
10
|
+
POOL = Google::Protobuf::DescriptorPool.new
|
|
11
|
+
SCHEMA = Google::Protobuf::FileDescriptorSet.decode(File.binread(File.join(__dir__, 'schema.pb')))
|
|
12
|
+
|
|
13
|
+
SCHEMA.file.each { |file| POOL.add_serialized_file(file.to_proto) }
|
|
14
|
+
|
|
15
|
+
# reason: the pinned descriptors define every type and RPC without duplicated Ruby bindings.
|
|
16
|
+
def self.define_types(parent, prefix, messages, enums)
|
|
17
|
+
enums.each do |type|
|
|
18
|
+
parent.const_set(type.name, POOL.lookup("#{prefix}.#{type.name}").enummodule)
|
|
19
|
+
end
|
|
20
|
+
messages.each do |type|
|
|
21
|
+
next if type.options&.map_entry
|
|
22
|
+
|
|
23
|
+
name = "#{prefix}.#{type.name}"
|
|
24
|
+
klass = parent.const_set(type.name, POOL.lookup(name).msgclass)
|
|
25
|
+
define_types(klass, name, type.nested_type, type.enum_type)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
private_class_method :define_types
|
|
29
|
+
|
|
30
|
+
SCHEMA.file.select { |file| file.package == PACKAGE }.each do |file|
|
|
31
|
+
define_types(self, PACKAGE, file.message_type, file.enum_type)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
SERVICES = SCHEMA.file.flat_map(&:service).to_h do |definition|
|
|
35
|
+
service = const_set(definition.name, Class.new { include GRPC::GenericService })
|
|
36
|
+
service.service_name = "#{PACKAGE}.#{definition.name}"
|
|
37
|
+
service.marshal_class_method = :encode
|
|
38
|
+
service.unmarshal_class_method = :decode
|
|
39
|
+
POOL.lookup(service.service_name).each do |method|
|
|
40
|
+
input = method.input_type.msgclass
|
|
41
|
+
output = method.output_type.msgclass
|
|
42
|
+
input = service.stream(input) if method.client_streaming
|
|
43
|
+
output = service.stream(output) if method.server_streaming
|
|
44
|
+
service.rpc(method.name.to_sym, input, output)
|
|
45
|
+
end
|
|
46
|
+
service.const_set(:Stub, service.rpc_stub_class)
|
|
47
|
+
[GRPC::GenericService.underscore(definition.name).to_sym, service]
|
|
48
|
+
end.freeze
|
|
49
|
+
private_constant :SCHEMA
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'optparse'
|
|
5
|
+
require_relative '../swarmkit-client'
|
|
6
|
+
|
|
7
|
+
module Swarmkit
|
|
8
|
+
class CLI
|
|
9
|
+
class UsageError < StandardError; end
|
|
10
|
+
|
|
11
|
+
SENSITIVE = /auth|password|credential|token|(?:^|_)keys?$|
|
|
12
|
+
\A(?:data|payload|env|command|args|labels|options|headers|test|value|message|err)\z/ix
|
|
13
|
+
|
|
14
|
+
def initialize(input: $stdin, output: $stdout, error: $stderr)
|
|
15
|
+
@input = input
|
|
16
|
+
@output = output
|
|
17
|
+
@error = error
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run(argv)
|
|
21
|
+
options = {}
|
|
22
|
+
parser = OptionParser.new do |opts|
|
|
23
|
+
opts.banner = <<~HELP
|
|
24
|
+
Usage: swarmkit-client [options] SERVICE METHOD [JSON|@FILE|-]
|
|
25
|
+
swarmkit-client methods [SERVICE]
|
|
26
|
+
swarmkit-client describe SERVICE METHOD
|
|
27
|
+
JSON defaults to {}. Client streams take a JSON array; responses use JSON lines.
|
|
28
|
+
Sensitive fields are redacted. Explicit RPC calls can change cluster state.
|
|
29
|
+
HELP
|
|
30
|
+
%i[target ca cert key server_name].each do |name|
|
|
31
|
+
opts.on("--#{name.to_s.tr('_', '-')} VALUE") { |value| options[name] = value }
|
|
32
|
+
end
|
|
33
|
+
opts.on('--timeout SECONDS', Float, 'Deadline duration (default: 10)') { |value| options[:timeout] = value }
|
|
34
|
+
opts.on('-v', '--version') do
|
|
35
|
+
@output.puts VERSION
|
|
36
|
+
return 0
|
|
37
|
+
end
|
|
38
|
+
opts.on('-h', '--help') do
|
|
39
|
+
@output.puts opts
|
|
40
|
+
return 0
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
args = parser.parse(argv)
|
|
44
|
+
case args.first
|
|
45
|
+
when nil then @output.puts parser
|
|
46
|
+
when 'methods' then list_methods(args.drop(1))
|
|
47
|
+
when 'describe'
|
|
48
|
+
raise UsageError, 'describe requires SERVICE METHOD' unless args.size == 3
|
|
49
|
+
|
|
50
|
+
_, _, method = resolve(*args.drop(1))
|
|
51
|
+
describe(method)
|
|
52
|
+
else
|
|
53
|
+
raise UsageError, 'expected SERVICE METHOD [JSON|@FILE|-]' unless (2..3).cover?(args.size)
|
|
54
|
+
|
|
55
|
+
service, name, method = resolve(*args.first(2))
|
|
56
|
+
request = request(method, args[2] || '{}')
|
|
57
|
+
call(service, name, method, request, options)
|
|
58
|
+
end
|
|
59
|
+
0
|
|
60
|
+
rescue UsageError, OptionParser::ParseError => e
|
|
61
|
+
@error.puts "error: #{e.message}"
|
|
62
|
+
2
|
|
63
|
+
rescue JSON::ParserError, Google::Protobuf::ParseError, ArgumentError, TypeError
|
|
64
|
+
@error.puts 'error: invalid JSON request or connection options; use --help and describe SERVICE METHOD'
|
|
65
|
+
2
|
|
66
|
+
rescue GRPC::BadStatus => e
|
|
67
|
+
@error.puts "error: RPC failed (gRPC status #{e.code})"
|
|
68
|
+
1
|
|
69
|
+
rescue Errno::EPIPE
|
|
70
|
+
0
|
|
71
|
+
rescue SystemCallError, IOError => e
|
|
72
|
+
@error.puts "error: I/O failed (#{e.class})"
|
|
73
|
+
1
|
|
74
|
+
rescue Interrupt
|
|
75
|
+
130
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def list_methods(args)
|
|
81
|
+
raise UsageError, 'methods accepts at most one SERVICE' if args.size > 1
|
|
82
|
+
|
|
83
|
+
services = args.empty? ? API::SERVICES : {args.first.to_sym => service(args.first)}
|
|
84
|
+
services.each do |name, definition|
|
|
85
|
+
definition.rpc_descs.each_key { |rpc| @output.puts "#{name} #{GRPC::GenericService.underscore(rpc.to_s)}" }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def service(name)
|
|
90
|
+
API::SERVICES.fetch(name.to_sym) { raise UsageError, "unknown service; use 'methods'" }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def resolve(service_name, method_name)
|
|
94
|
+
method = API::POOL.lookup(service(service_name).service_name).find do |rpc|
|
|
95
|
+
GRPC::GenericService.underscore(rpc.name) == method_name
|
|
96
|
+
end
|
|
97
|
+
raise UsageError, "unknown RPC; use 'methods SERVICE'" unless method
|
|
98
|
+
|
|
99
|
+
[service_name, method_name, method]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def describe(method)
|
|
103
|
+
types = {}
|
|
104
|
+
queue = [method.input_type, method.output_type]
|
|
105
|
+
until queue.empty?
|
|
106
|
+
type = queue.shift
|
|
107
|
+
next if types.key?(type.name)
|
|
108
|
+
|
|
109
|
+
types[type.name] = type.to_proto.to_h
|
|
110
|
+
queue.concat(type.filter_map(&:subtype)) if type.is_a?(Google::Protobuf::Descriptor)
|
|
111
|
+
end
|
|
112
|
+
@output.puts JSON.pretty_generate(method: method.to_proto.to_h, types: types)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def request(method, source)
|
|
116
|
+
json = if source == '-'
|
|
117
|
+
@input.read
|
|
118
|
+
elsif source.start_with?('@')
|
|
119
|
+
File.read(source.delete_prefix('@'))
|
|
120
|
+
else
|
|
121
|
+
source
|
|
122
|
+
end
|
|
123
|
+
type = method.input_type.msgclass
|
|
124
|
+
return type.decode_json(json) unless method.client_streaming
|
|
125
|
+
|
|
126
|
+
items = JSON.parse(json)
|
|
127
|
+
raise UsageError, 'client-streaming RPCs require a JSON array' unless items.is_a?(Array)
|
|
128
|
+
|
|
129
|
+
items.map { |item| type.decode_json(JSON.generate(item)) }.each
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def call(service, name, method, request, options)
|
|
133
|
+
Client.open(**options) do |client|
|
|
134
|
+
operation = client.public_send(service).public_send(name, request, return_op: true)
|
|
135
|
+
begin
|
|
136
|
+
response = operation.execute
|
|
137
|
+
method.server_streaming ? response.each { |message| emit(message) } : emit(response)
|
|
138
|
+
ensure
|
|
139
|
+
operation.cancel
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def emit(message)
|
|
145
|
+
data = JSON.parse(message.class.encode_json(message, preserve_proto_fieldnames: true))
|
|
146
|
+
@output.puts JSON.generate(redact(data))
|
|
147
|
+
@output.flush
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def redact(value)
|
|
151
|
+
case value
|
|
152
|
+
when Hash
|
|
153
|
+
value.to_h { |key, item| [key, key.match?(SENSITIVE) ? '[REDACTED]' : redact(item)] }
|
|
154
|
+
when Array then value.map { |item| redact(item) }
|
|
155
|
+
else value
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
Binary file
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'version'
|
|
4
|
+
require_relative 'swarmkit-client/api'
|
|
5
|
+
|
|
6
|
+
module Swarmkit
|
|
7
|
+
class Client
|
|
8
|
+
SOCKET = '/var/run/docker/swarm/control.sock'
|
|
9
|
+
CA = '/var/lib/docker/swarm/certificates/swarm-root-ca.crt'
|
|
10
|
+
|
|
11
|
+
def self.open(**)
|
|
12
|
+
client = new(**)
|
|
13
|
+
yield client
|
|
14
|
+
ensure
|
|
15
|
+
client&.close
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(target: "unix:#{SOCKET}", ca: CA, cert: nil, key: nil,
|
|
19
|
+
server_name: 'swarm-manager', timeout: 10, channel_args: {})
|
|
20
|
+
raise ArgumentError, 'cert and key must be supplied together' if cert.nil? != key.nil?
|
|
21
|
+
|
|
22
|
+
unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
|
|
23
|
+
raise ArgumentError,
|
|
24
|
+
'timeout must be finite and positive'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
credentials = GRPC::Core::ChannelCredentials.new(
|
|
28
|
+
File.read(ca), key && File.read(key), cert && File.read(cert)
|
|
29
|
+
)
|
|
30
|
+
args = {'grpc.max_receive_message_length' => 16 * 1024 * 1024}.merge(channel_args)
|
|
31
|
+
if server_name
|
|
32
|
+
args['grpc.ssl_target_name_override'] = server_name
|
|
33
|
+
args['grpc.default_authority'] = server_name
|
|
34
|
+
end
|
|
35
|
+
@channel = GRPC::Core::Channel.new(target, args, credentials)
|
|
36
|
+
@stubs = API::SERVICES.transform_values do |service|
|
|
37
|
+
service::Stub.new(target, credentials, channel_override: @channel, timeout: timeout)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# reason: all 11 services share one channel and the same accessor behavior.
|
|
42
|
+
API::SERVICES.each_key do |name|
|
|
43
|
+
define_method(name) { @stubs.fetch(name) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def close
|
|
47
|
+
@channel.close
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/version.rb
ADDED
data/schema/sources.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"gogoproto/gogo.proto": {
|
|
3
|
+
"url": "https://raw.githubusercontent.com/gogo/protobuf/v1.3.2/gogoproto/gogo.proto",
|
|
4
|
+
"sha256": "f2f77edf7de807ded7884813d851656f4ccb18262db717a8f31061995f3e7324"
|
|
5
|
+
},
|
|
6
|
+
"github.com/docker/swarmkit/api/types.proto": {
|
|
7
|
+
"url": "https://raw.githubusercontent.com/moby/swarmkit/17b8d222e7dd/api/types.proto",
|
|
8
|
+
"sha256": "f0a9f0dad6a87380feb7ba6715887250d4df64fc74a9ca5b9d7c14775dc81240"
|
|
9
|
+
},
|
|
10
|
+
"github.com/docker/swarmkit/api/specs.proto": {
|
|
11
|
+
"url": "https://raw.githubusercontent.com/moby/swarmkit/17b8d222e7dd/api/specs.proto",
|
|
12
|
+
"sha256": "f25c844176ba6874af81b3b182721b96a107129319825ed796ed812208be082d"
|
|
13
|
+
},
|
|
14
|
+
"github.com/docker/swarmkit/protobuf/plugin/plugin.proto": {
|
|
15
|
+
"url": "https://raw.githubusercontent.com/moby/swarmkit/17b8d222e7dd/protobuf/plugin/plugin.proto",
|
|
16
|
+
"sha256": "e1e65b489db311503b9fdc5e1f984b77660d1b7a3018ae92c0bbb124a86f4021"
|
|
17
|
+
},
|
|
18
|
+
"github.com/docker/swarmkit/api/objects.proto": {
|
|
19
|
+
"url": "https://raw.githubusercontent.com/moby/swarmkit/17b8d222e7dd/api/objects.proto",
|
|
20
|
+
"sha256": "dd76a9c58c6af0a4d81dcababd569b344b3926b069fc67a7225e2f85474110e9"
|
|
21
|
+
},
|
|
22
|
+
"github.com/docker/swarmkit/api/control.proto": {
|
|
23
|
+
"url": "https://raw.githubusercontent.com/moby/swarmkit/17b8d222e7dd/api/control.proto",
|
|
24
|
+
"sha256": "cd4ba9a8902eb1e468a0a6e718a952844e712d7db5a7f81dcedeaa889586778a"
|
|
25
|
+
},
|
|
26
|
+
"github.com/docker/swarmkit/api/watch.proto": {
|
|
27
|
+
"url": "https://raw.githubusercontent.com/moby/swarmkit/17b8d222e7dd/api/watch.proto",
|
|
28
|
+
"sha256": "83eb87a44ca3a9d93977430c32c719422f92ce3a1bc7dd458175c567543752f7"
|
|
29
|
+
},
|
|
30
|
+
"github.com/docker/swarmkit/api/logbroker.proto": {
|
|
31
|
+
"url": "https://raw.githubusercontent.com/moby/swarmkit/17b8d222e7dd/api/logbroker.proto",
|
|
32
|
+
"sha256": "8894f2e4f0c97bb082454dfccbf01e0cf26951a1f770aee163d28f64f2da5cd9"
|
|
33
|
+
},
|
|
34
|
+
"github.com/docker/swarmkit/api/dispatcher.proto": {
|
|
35
|
+
"url": "https://raw.githubusercontent.com/moby/swarmkit/17b8d222e7dd/api/dispatcher.proto",
|
|
36
|
+
"sha256": "c93c7f3ace11a3c946bd2ddcbe34966932c05e5160b3b91051a8caeb268bc968"
|
|
37
|
+
},
|
|
38
|
+
"github.com/docker/swarmkit/api/ca.proto": {
|
|
39
|
+
"url": "https://raw.githubusercontent.com/moby/swarmkit/17b8d222e7dd/api/ca.proto",
|
|
40
|
+
"sha256": "2ea6cf7bcb89d792fe5b4a98c8b1c7364de13cb149aefbb56c820043c2bc3c9d"
|
|
41
|
+
},
|
|
42
|
+
"go.etcd.io/raft/v3/raftpb/raft.proto": {
|
|
43
|
+
"url": "https://raw.githubusercontent.com/etcd-io/raft/v3.6.0/raftpb/raft.proto",
|
|
44
|
+
"sha256": "312ce6c6ff1dd797e17edfa8dbc7133f965d5fc5423a76e6aaff7b09ba8be19f"
|
|
45
|
+
},
|
|
46
|
+
"github.com/docker/swarmkit/api/raft.proto": {
|
|
47
|
+
"url": "https://raw.githubusercontent.com/moby/swarmkit/17b8d222e7dd/api/raft.proto",
|
|
48
|
+
"sha256": "ad575667ea693066de0557cdff76e5bde4daaec35e8041ff4e3c5c5da066e6e4"
|
|
49
|
+
},
|
|
50
|
+
"github.com/docker/swarmkit/api/resource.proto": {
|
|
51
|
+
"url": "https://raw.githubusercontent.com/moby/swarmkit/17b8d222e7dd/api/resource.proto",
|
|
52
|
+
"sha256": "c309a506da325a7c5801993d7e6a28a7597a2f4667eec4ee41aea87efd036229"
|
|
53
|
+
},
|
|
54
|
+
"github.com/docker/swarmkit/api/health.proto": {
|
|
55
|
+
"url": "https://raw.githubusercontent.com/moby/swarmkit/17b8d222e7dd/api/health.proto",
|
|
56
|
+
"sha256": "920a69b4b3296ceb46081982b98cb7b4bd0d79f3c6ccba4536f5320dbae59df8"
|
|
57
|
+
}
|
|
58
|
+
}
|
metadata
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: swarmkit-client
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Artyom B
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: google-protobuf
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '4.36'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '4.36'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: grpc
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.85'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.85'
|
|
40
|
+
description: Typed requests, responses and streams for all SwarmKit services over
|
|
41
|
+
verified TLS.
|
|
42
|
+
executables:
|
|
43
|
+
- swarmkit-client
|
|
44
|
+
extensions: []
|
|
45
|
+
extra_rdoc_files: []
|
|
46
|
+
files:
|
|
47
|
+
- LICENSE
|
|
48
|
+
- NOTICE
|
|
49
|
+
- README.md
|
|
50
|
+
- bin/swarmkit-client
|
|
51
|
+
- docs/api.md
|
|
52
|
+
- lib/swarmkit-client.rb
|
|
53
|
+
- lib/swarmkit-client/api.rb
|
|
54
|
+
- lib/swarmkit-client/cli.rb
|
|
55
|
+
- lib/swarmkit-client/schema.pb
|
|
56
|
+
- lib/version.rb
|
|
57
|
+
- schema/sources.json
|
|
58
|
+
homepage: https://github.com/artyomb/swarmkit-client
|
|
59
|
+
licenses:
|
|
60
|
+
- Apache-2.0
|
|
61
|
+
metadata:
|
|
62
|
+
source_code_uri: https://github.com/artyomb/swarmkit-client
|
|
63
|
+
rdoc_options: []
|
|
64
|
+
require_paths:
|
|
65
|
+
- lib
|
|
66
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
67
|
+
requirements:
|
|
68
|
+
- - ">="
|
|
69
|
+
- !ruby/object:Gem::Version
|
|
70
|
+
version: '3.2'
|
|
71
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '0'
|
|
76
|
+
requirements: []
|
|
77
|
+
rubygems_version: 3.6.7
|
|
78
|
+
specification_version: 4
|
|
79
|
+
summary: Compact Ruby client for the complete SwarmKit gRPC API
|
|
80
|
+
test_files: []
|