ood_core 0.14.0 → 0.17.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52ba764b085dedb7eaeb06d95751f1804a50488e1859f980a7836d2d9032b95d
4
- data.tar.gz: c2dc5edf395fe158960f33b80c554f3dc745f15e7ec1337b738683a0e1bbdc7f
3
+ metadata.gz: 266e24cd2e11c2c712b6128bdc3f82bf9781ae9885f8f0eb21439cb80c889c90
4
+ data.tar.gz: 01e682b6313468371076cdfd4ff2df2f3c06c661af9b4d7d7a65b7dcf3e2d836
5
5
  SHA512:
6
- metadata.gz: 59915bae23a008a923c249d222e50548a7bee3438144068a29ae1cafdd489ca1229ee1a14f4f81e3fd065381f46f920bef24344fe633c7c578cb1f6a4f9a2a77
7
- data.tar.gz: 8d2ca42c7f49158c8d321c21b79aff1c636df3c77bb7e71107db70371a34058d79c8a5ec32ca93883e7d3bcc7dc2202375144d23613f167ab089318d6270248c
6
+ metadata.gz: 22721c9d368ec44533d93914f977576ee77786ad0926976fa24067c7f353104edd32baffac34723b730e7f711c5b7581cf4f72f6232d050c719edcfc1b3cb14f
7
+ data.tar.gz: 5d99a3c782aad5e420333653e51073cdb6535b7689053452da7ee7ef234292769e70d4010d751aacd4576daf531700136c2aa0ef7a2efc31edcb29e4c01c8be4
@@ -0,0 +1,30 @@
1
+ name: Unit Tests
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - master
7
+ pull_request:
8
+ branches:
9
+ - master
10
+
11
+ jobs:
12
+ tests:
13
+ runs-on: ubuntu-latest
14
+
15
+ steps:
16
+ - name: checkout
17
+ uses: actions/checkout@v2
18
+
19
+ - name: Setup Ruby using Bundler
20
+ uses: ruby/setup-ruby@v1
21
+ with:
22
+ ruby-version: "2.7.1"
23
+ bundler-cache: true
24
+ bundler: "2.1.4"
25
+
26
+ - name: install gems
27
+ run: bundle install
28
+
29
+ - name: test
30
+ run: bundle exec rake spec
data/CHANGELOG.md CHANGED
@@ -6,6 +6,87 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
6
6
  and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  ## [Unreleased]
9
+
10
+ ## [0.17.0] - 5-26-2021
11
+
12
+ ### Fixed
13
+
14
+ - All Kubernetes resources now have the same labels in [280](https://github.com/OSC/ood_core/pull/280).
15
+ - Kubernetes does not crash when no configmap is defined in [282](https://github.com/OSC/ood_core/pull/282).
16
+ - Kubernetes will not specify init containers if there are none in
17
+ [284](https://github.com/OSC/ood_core/pull/284).
18
+
19
+ ### Added
20
+
21
+ - Kubernetes, Slurm and Torque now support the script option `gpus_per_node` in
22
+ [266](https://github.com/OSC/ood_core/pull/266).
23
+ - Kubernetes will now save the pod.yml into the staged root in
24
+ [277](https://github.com/OSC/ood_core/pull/277).
25
+ - Kubernetes now allows for node selector in [264](https://github.com/OSC/ood_core/pull/264).
26
+ - Kubernetes pods now have access the environment variable POD_NAMESPACE in
27
+ [275](https://github.com/OSC/ood_core/pull/275).
28
+ - Kubernetes pods can now specify the image pull policy in [272](https://github.com/OSC/ood_core/pull/272).
29
+ - Cluster config's batch_connect now support `ssh_allow` to disable sshing to compute
30
+ nodes per cluster in [286](https://github.com/OSC/ood_core/pull/286).
31
+ - Kubernetes will now add the templated script content to a configmap in
32
+ [273](https://github.com/OSC/ood_core/pull/273).
33
+
34
+ ### Changed
35
+
36
+ - Kubernetes username prefix no longer appends a - in [271](https://github.com/OSC/ood_core/pull/271).
37
+
38
+
39
+
40
+ ## [0.16.1] - 2021-04-23
41
+ ### Fixed
42
+ - memorized some allow? variables to have better support around ACLS in
43
+ [267](https://github.com/OSC/ood_core/pull/267)
44
+
45
+ ## [0.16.0] - 2021-04-20
46
+ ### Fixed
47
+ - tmux 2.7+ bug in the linux host adapter in [2.5.8](https://github.com/OSC/ood_core/pull/258)
48
+ and [259](https://github.com/OSC/ood_core/pull/259).
49
+
50
+ ### Changed
51
+
52
+ - Changed how k8s configmaps in are defined in [251](https://github.com/OSC/ood_core/pull/251).
53
+ The data structure now expects a key called files which is an array of objects that hold
54
+ filename, data, mount_path, sub_path and init_mount_path.
55
+ [255](https://github.com/OSC/ood_core/pull/255) also relates to this interface change.
56
+
57
+ ### Added
58
+
59
+ - The k8s adapter can now specify environment variables and creates defaults
60
+ in [252](https://github.com/OSC/ood_core/pull/252).
61
+ - The k8s adapter can now specify image pull secrets in [253](https://github.com/OSC/ood_core/pull/253).
62
+
63
+ ## [0.15.1] - 2021-02-25
64
+ ### Fixed
65
+ - kubernetes adapter uses the full module for helpers in [245](https://github.com/OSC/ood_core/pull/245).
66
+
67
+ ### Changed
68
+ - kubernetes pods spawn with runAsNonRoot set to true in [247](https://github.com/OSC/ood_core/pull/247).
69
+ - kubernetes pods can spawn with supplemental groups along with some other in security defaults in
70
+ [246](https://github.com/OSC/ood_core/pull/246).
71
+
72
+ ## [0.15.0] - 2021-01-26
73
+ ### Fixed
74
+ - ccq adapter now accepts job names with spaces in [210](https://github.com/OSC/ood_core/pull/209)
75
+ - k8s correctly handles having no mount volumes in [239](https://github.com/OSC/ood_core/pull/239)
76
+
77
+ ### Added
78
+ - k8s adapter now applies account metadata to resources in [216](https://github.com/OSC/ood_core/pull/216) and
79
+ [231](https://github.com/OSC/ood_core/pull/231)
80
+ - k8s adapter can now prefix namespaces in [218](https://github.com/OSC/ood_core/pull/218)
81
+ - k8s adapter now applies time limits to pods in [224](https://github.com/OSC/ood_core/pull/224)
82
+
83
+ ### Changed
84
+ - testing automation is now done in github actions in [221](https://github.com/OSC/ood_core/pull/218)
85
+ - update bunlder to 2.1.4 and ruby to 2.7 in [235](https://github.com/OSC/ood_core/pull/218) updated bundler and ruby
86
+ - k8s adapter more appropriately labels unschedulable pods as queued in [230](https://github.com/OSC/ood_core/pull/230)
87
+ - k8s adapter now uses the script#ood_connection_info API instead of script#native in
88
+ [222](https://github.com/OSC/ood_core/pull/222)
89
+
9
90
  ## [0.14.0] - 2020-10-01
10
91
  ### Added
11
92
  - Kubernetes adapter in PR [156](https://github.com/OSC/ood_core/pull/156)
@@ -255,7 +336,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
255
336
  ### Added
256
337
  - Initial release!
257
338
 
258
- [Unreleased]: https://github.com/OSC/ood_core/compare/v0.14.0...HEAD
339
+ [Unreleased]: https://github.com/OSC/ood_core/compare/v0.16.0...HEAD
340
+ [0.17.0]: https://github.com/OSC/ood_core/compare/v0.16.1...v0.17.0
341
+ [0.16.1]: https://github.com/OSC/ood_core/compare/v0.16.0...v0.16.1
342
+ [0.16.0]: https://github.com/OSC/ood_core/compare/v0.15.1...v0.16.0
343
+ [0.15.1]: https://github.com/OSC/ood_core/compare/v0.15.0...v0.15.1
344
+ [0.15.0]: https://github.com/OSC/ood_core/compare/v0.14.0...v0.15.0
259
345
  [0.14.0]: https://github.com/OSC/ood_core/compare/v0.13.0...v0.14.0
260
346
  [0.13.0]: https://github.com/OSC/ood_core/compare/v0.12.0...v0.13.0
261
347
  [0.12.0]: https://github.com/OSC/ood_core/compare/v0.11.4...v0.12.0
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # OodCore
2
2
 
3
- [![Build Status](https://travis-ci.org/OSC/ood_core.svg?branch=master)](https://travis-ci.org/OSC/ood_core)
3
+ [![Build Status](https://github.com/osc/ood_core/workflows/Unit%20Tests/badge.svg)](https://github.com/OSC/ood_core/actions?query=workflow%3A%22Unit+Tests%22)
4
4
  ![GitHub Release](https://img.shields.io/github/release/osc/ood_core.svg)
5
5
  ![GitHub License](https://img.shields.io/github/license/osc/ood_core.svg)
6
6
 
@@ -78,7 +78,9 @@ module OodCore
78
78
  # Whether the login feature is allowed
79
79
  # @return [Boolean] is login allowed
80
80
  def login_allow?
81
- allow? && !login_config.empty?
81
+ return @login_allow if defined?(@login_allow)
82
+
83
+ @login_allow = (allow? && !login_config.empty?)
82
84
  end
83
85
 
84
86
  # Build a job adapter from the job configuration
@@ -90,9 +92,11 @@ module OodCore
90
92
  # Whether the job feature is allowed based on the ACLs
91
93
  # @return [Boolean] is the job feature allowed
92
94
  def job_allow?
93
- allow? &&
94
- !job_config.empty? &&
95
- build_acls(job_config.fetch(:acls, []).map(&:to_h)).all?(&:allow?)
95
+ return @job_allow if defined?(@job_allow)
96
+
97
+ @job_allow = (allow? && ! job_config.empty? && build_acls(
98
+ job_config.fetch(:acls, []).map(&:to_h)
99
+ ).all?(&:allow?))
96
100
  end
97
101
 
98
102
  # The batch connect template configuration used for this cluster
@@ -138,7 +142,18 @@ module OodCore
138
142
  # Whether this cluster is allowed to be used
139
143
  # @return [Boolean] whether cluster is allowed
140
144
  def allow?
141
- acls.all?(&:allow?)
145
+ return @allow if defined?(@allow)
146
+
147
+ @allow = acls.all?(&:allow?)
148
+ end
149
+
150
+ # Whether this cluster supports SSH to batch connect nodes
151
+ # @return [Boolean] whether cluster supports SSH to batch connect node
152
+ def batch_connect_ssh_allow?
153
+ return @batch_connect_ssh_allow if defined?(@batch_connect_ssh_allow)
154
+ return @batch_connect_ssh_allow = true if batch_connect_config.nil?
155
+
156
+ @batch_connect_ssh_allow = batch_connect_config.fetch(:ssh_allow, true)
142
157
  end
143
158
 
144
159
  # The comparison operator
@@ -203,6 +203,10 @@ module OodCore
203
203
  'ccq_ood_script_'
204
204
  end
205
205
 
206
+ def ccqstat_regex
207
+ /^(?<id>\S+)\s+(?<name>.+)\s+(?<username>\S+)\s+(?<scheduler>\S+)\s+(?<status>\S+)\s*$/
208
+ end
209
+
206
210
  def parse_job_id_from_ccqsub(output)
207
211
  match_data = /#{jobid_regex}/.match(output)
208
212
  # match_data could be nil, OR re-configured jobid_regex could be looking for a different named group
@@ -236,28 +240,31 @@ module OodCore
236
240
  def info_from_ccqstat(data)
237
241
  infos = []
238
242
 
239
- data.to_s.each_line do |line|
240
- words = line.split(/\s/).reject(&:empty?)
241
- next if !words.empty? && words[0] == "Id" # just skip the header
242
-
243
- infos << Info.new(line_to_hash(words)) if words.size == 5
243
+ data.to_s.lines.drop(1).each do |line|
244
+ match_data = ccqstat_regex.match(line)
245
+ infos << Info.new(ccqstat_match_to_hash(match_data)) if valid_ccqstat_match?(match_data)
244
246
  end
245
247
 
246
248
  infos
247
249
  end
248
250
 
249
- def line_to_hash(words)
250
- return unless words.size == 5
251
-
251
+ def ccqstat_match_to_hash(match)
252
252
  data_hash = {}
253
- data_hash[:id] = words[0]
254
- data_hash[:job_name] = words[1]
255
- data_hash[:job_owner] = words[2]
256
- data_hash[:status] = get_state(words[4])
253
+ data_hash[:id] = match.named_captures.fetch('id', nil)
254
+ data_hash[:job_owner] = match.named_captures.fetch('username', nil)
255
+ data_hash[:status] = get_state(match.named_captures.fetch('status', nil))
256
+
257
+ # The regex leaves trailing empty spaces. There's no way to tell if they're _actually_
258
+ # a part of the job name or not, so we assume they're not and add the rstrip.
259
+ data_hash[:job_name] = match.named_captures.fetch('name', nil).to_s.rstrip
257
260
 
258
261
  data_hash
259
262
  end
260
263
 
264
+ def valid_ccqstat_match?(match)
265
+ !match.nil? && !match.named_captures.fetch('id', nil).nil?
266
+ end
267
+
261
268
  def get_state(state)
262
269
  STATE_MAP.fetch(state, :undetermined)
263
270
  end
@@ -7,7 +7,7 @@ module OodCore
7
7
  using Refinements::HashExtensions
8
8
 
9
9
  def self.build_kubernetes(config)
10
- batch = Adapters::Kubernetes::Batch.new(config.to_h.symbolize_keys, Adapters::Kubernetes::Helper.new)
10
+ batch = Adapters::Kubernetes::Batch.new(config.to_h.symbolize_keys)
11
11
  Adapters::Kubernetes.new(batch)
12
12
  end
13
13
  end
@@ -3,31 +3,31 @@ require "json"
3
3
 
4
4
  class OodCore::Job::Adapters::Kubernetes::Batch
5
5
 
6
- require "ood_core/job/adapters/kubernetes/helper"
7
-
8
- Helper = OodCore::Job::Adapters::Kubernetes::Helper
9
- Resources = OodCore::Job::Adapters::Kubernetes::Resources
6
+ require_relative "helper"
7
+ require_relative "k8s_job_info"
10
8
 
11
9
  using OodCore::Refinements::HashExtensions
12
10
 
13
11
  class Error < StandardError; end
12
+ class NotFoundError < StandardError; end
14
13
 
15
- attr_reader :config_file, :bin, :cluster_name, :mounts
14
+ attr_reader :config_file, :bin, :cluster, :mounts
16
15
  attr_reader :all_namespaces, :using_context, :helper
17
- attr_reader :username_prefix
16
+ attr_reader :username_prefix, :namespace_prefix
18
17
 
19
- def initialize(options = {}, helper = Helper.new)
18
+ def initialize(options = {})
20
19
  options = options.to_h.symbolize_keys
21
20
 
22
21
  @config_file = options.fetch(:config_file, default_config_file)
23
22
  @bin = options.fetch(:bin, '/usr/bin/kubectl')
24
- @cluster_name = options.fetch(:cluster_name, 'open-ondemand')
23
+ @cluster = options.fetch(:cluster, 'open-ondemand')
25
24
  @mounts = options.fetch(:mounts, []).map { |m| m.to_h.symbolize_keys }
26
25
  @all_namespaces = options.fetch(:all_namespaces, false)
27
- @username_prefix = options.fetch(:username_prefix, nil)
26
+ @username_prefix = options.fetch(:username_prefix, '')
27
+ @namespace_prefix = options.fetch(:namespace_prefix, '')
28
28
 
29
29
  @using_context = false
30
- @helper = helper
30
+ @helper = OodCore::Job::Adapters::Kubernetes::Helper.new
31
31
 
32
32
  begin
33
33
  make_kubectl_config(options)
@@ -44,7 +44,10 @@ class OodCore::Job::Adapters::Kubernetes::Batch
44
44
  def submit(script, after: [], afterok: [], afternotok: [], afterany: [])
45
45
  raise ArgumentError, 'Must specify the script' if script.nil?
46
46
 
47
- resource_yml, id = generate_id_yml(script.native)
47
+ resource_yml, id = generate_id_yml(script)
48
+ if !script.workdir.nil? && Dir.exist?(script.workdir)
49
+ File.open(File.join(script.workdir, 'pod.yml'), 'w') { |f| f.write resource_yml }
50
+ end
48
51
  call("#{formatted_ns_cmd} create -f -", stdin: resource_yml)
49
52
 
50
53
  id
@@ -92,16 +95,11 @@ class OodCore::Job::Adapters::Kubernetes::Batch
92
95
  end
93
96
 
94
97
  def info(id)
95
- pod_json = call_json_output('get', 'pod', id)
98
+ pod_json = safe_call('get', 'pod', id)
99
+ return OodCore::Job::Info.new({ id: id, status: 'completed' }) if pod_json.empty?
96
100
 
97
- begin
98
- service_json = call_json_output('get', 'service', service_name(id))
99
- secret_json = call_json_output('get', 'secret', secret_name(id))
100
- rescue
101
- # it's ok if these don't exist
102
- service_json ||= nil
103
- secret_json ||= nil
104
- end
101
+ service_json = safe_call('get', 'service', service_name(id))
102
+ secret_json = safe_call('get', 'secret', secret_name(id))
105
103
 
106
104
  helper.info_from_json(pod_json: pod_json, service_json: service_json, secret_json: secret_json)
107
105
  end
@@ -111,24 +109,27 @@ class OodCore::Job::Adapters::Kubernetes::Batch
111
109
  end
112
110
 
113
111
  def delete(id)
114
- call("#{namespaced_cmd} delete pod #{id}")
112
+ safe_call("delete", "pod", id)
113
+ safe_call("delete", "service", service_name(id))
114
+ safe_call("delete", "secret", secret_name(id))
115
+ safe_call("delete", "configmap", configmap_name(id))
116
+ end
115
117
 
118
+ private
119
+
120
+ def safe_call(verb, resource, id)
116
121
  begin
117
- call("#{namespaced_cmd} delete service #{service_name(id)}")
118
- call("#{namespaced_cmd} delete secret #{secret_name(id)}")
119
- call("#{namespaced_cmd} delete configmap #{configmap_name(id)}")
120
- rescue
121
- # FIXME: retries? delete if exists?
122
- # just eat the results of deleting services and secrets
122
+ case verb.to_s
123
+ when "get"
124
+ call_json_output('get', resource, id)
125
+ when "delete"
126
+ call("#{namespaced_cmd} delete #{resource} #{id}")
127
+ end
128
+ rescue NotFoundError
129
+ {}
123
130
  end
124
131
  end
125
132
 
126
- def configmap_mount_path
127
- '/ood'
128
- end
129
-
130
- private
131
-
132
133
  # helper to help format multi-line yaml data from the submit.yml into
133
134
  # mutli-line yaml in the pod.yml.erb
134
135
  def config_data_lines(data)
@@ -148,32 +149,57 @@ class OodCore::Job::Adapters::Kubernetes::Batch
148
149
  end
149
150
 
150
151
  def k8s_username
151
- username_prefix.nil? ? username : "#{username_prefix}-#{username}"
152
+ "#{username_prefix}#{username}"
153
+ end
154
+
155
+ def user
156
+ @user ||= Etc.getpwnam(username)
157
+ end
158
+
159
+ def home_dir
160
+ user.dir
152
161
  end
153
162
 
154
163
  def run_as_user
155
- Etc.getpwnam(username).uid
164
+ user.uid
156
165
  end
157
166
 
158
167
  def run_as_group
159
- Etc.getpwnam(username).gid
168
+ user.gid
160
169
  end
161
170
 
162
171
  def fs_group
163
172
  run_as_group
164
173
  end
165
174
 
175
+ def group
176
+ Etc.getgrgid(run_as_group).name
177
+ end
178
+
179
+ def default_env
180
+ {
181
+ USER: username,
182
+ UID: run_as_user,
183
+ HOME: home_dir,
184
+ GROUP: group,
185
+ GID: run_as_group,
186
+ }
187
+ end
188
+
166
189
  # helper to template resource yml you're going to submit and
167
190
  # create an id.
168
- def generate_id_yml(native_data)
169
- container = helper.container_from_native(native_data[:container])
191
+ def generate_id_yml(script)
192
+ native_data = script.native
193
+ container = helper.container_from_native(native_data[:container], default_env)
170
194
  id = generate_id(container.name)
171
- configmap = helper.configmap_from_native(native_data, id)
172
- init_containers = helper.init_ctrs_from_native(native_data[:init_containers])
173
- spec = Resources::PodSpec.new(container, init_containers: init_containers)
195
+ configmap = helper.configmap_from_native(native_data, id, script.content)
196
+ init_containers = helper.init_ctrs_from_native(native_data[:init_containers], container.env)
197
+ spec = OodCore::Job::Adapters::Kubernetes::Resources::PodSpec.new(container, init_containers: init_containers)
174
198
  all_mounts = native_data[:mounts].nil? ? mounts : mounts + native_data[:mounts]
199
+ node_selector = native_data[:node_selector].nil? ? {} : native_data[:node_selector]
200
+ gpu_type = native_data[:gpu_type].nil? ? "nvidia.com/gpu" : native_data[:gpu_type]
175
201
 
176
- template = ERB.new(File.read(resource_file))
202
+ template = ERB.new(File.read(resource_file), nil, '-')
177
203
 
178
204
  [template.result(binding), id]
179
205
  end
@@ -204,15 +230,11 @@ class OodCore::Job::Adapters::Kubernetes::Batch
204
230
  end
205
231
 
206
232
  def namespace
207
- default_namespace
208
- end
209
-
210
- def default_namespace
211
- username
233
+ "#{namespace_prefix}#{username}"
212
234
  end
213
235
 
214
236
  def context
215
- cluster_name
237
+ cluster
216
238
  end
217
239
 
218
240
  def default_config_file
@@ -264,7 +286,7 @@ class OodCore::Job::Adapters::Kubernetes::Batch
264
286
 
265
287
  def pod_info_from_json(pod)
266
288
  hash = helper.pod_info_from_json(pod)
267
- OodCore::Job::Info.new(hash)
289
+ K8sJobInfo.new(hash)
268
290
  rescue Helper::K8sDataError
269
291
  # FIXME: silently eating error, could probably use a logger
270
292
  nil
@@ -318,14 +340,14 @@ class OodCore::Job::Adapters::Kubernetes::Batch
318
340
  locale = "--region=#{region}" unless region.nil?
319
341
 
320
342
  # gke cluster name can probably can differ from what ood calls the cluster
321
- cmd = "gcloud container clusters get-credentials #{locale} #{cluster_name}"
343
+ cmd = "gcloud container clusters get-credentials #{locale} #{cluster}"
322
344
  env = { 'KUBECONFIG' => config_file }
323
345
  call(cmd, env)
324
346
  end
325
347
 
326
348
  def set_context
327
- cmd = "#{base_cmd} config set-context #{cluster_name}"
328
- cmd << " --cluster=#{cluster_name} --namespace=#{namespace}"
349
+ cmd = "#{base_cmd} config set-context #{cluster}"
350
+ cmd << " --cluster=#{cluster} --namespace=#{namespace}"
329
351
  cmd << " --user=#{k8s_username}"
330
352
 
331
353
  call(cmd)
@@ -336,7 +358,7 @@ class OodCore::Job::Adapters::Kubernetes::Batch
336
358
  server = config.fetch(:endpoint)
337
359
  cert = config.fetch(:cert_authority_file, nil)
338
360
 
339
- cmd = "#{base_cmd} config set-cluster #{cluster_name}"
361
+ cmd = "#{base_cmd} config set-cluster #{cluster}"
340
362
  cmd << " --server=#{server}"
341
363
  cmd << " --certificate-authority=#{cert}" unless cert.nil?
342
364
 
@@ -344,7 +366,12 @@ class OodCore::Job::Adapters::Kubernetes::Batch
344
366
  end
345
367
 
346
368
  def call(cmd = '', env: {}, stdin: nil)
347
- o, error, s = Open3.capture3(env, cmd, stdin_data: stdin.to_s)
348
- s.success? ? o : raise(Error, error)
369
+ o, e, s = Open3.capture3(env, cmd, stdin_data: stdin.to_s)
370
+ s.success? ? o : interpret_and_raise(e)
371
+ end
372
+
373
+ def interpret_and_raise(stderr)
374
+ raise NotFoundError, stderr if /^Error from server \(NotFound\):/.match(stderr)
375
+ raise(Error, stderr)
349
376
  end
350
377
  end