ood_core 0.13.0 → 0.16.1

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.
@@ -0,0 +1,299 @@
1
+ class OodCore::Job::Adapters::Kubernetes::Helper
2
+
3
+ require_relative 'resources'
4
+ require_relative 'k8s_job_info'
5
+ require 'resolv'
6
+ require 'base64'
7
+ require 'active_support/core_ext/hash'
8
+
9
+ class K8sDataError < StandardError; end
10
+
11
+ # Extract info from json data. The data is expected to be from the kubectl
12
+ # command and conform to kubernetes' datatype structures.
13
+ #
14
+ # Returns K8sJobInfo in the in lieu of writing a connection.yml
15
+ #
16
+ # @param pod_json [#to_h]
17
+ # the pod data returned from 'kubectl get pod abc-123'
18
+ # @param service_json [#to_h]
19
+ # the service data returned from 'kubectl get service abc-123-service'
20
+ # @param secret_json [#to_h]
21
+ # the secret data returned from 'kubectl get secret abc-123-secret'
22
+ # @param ns_prefix [#to_s]
23
+ # the namespace prefix so that namespaces can be converted back to usernames
24
+ # @return [OodCore::Job::Adapters::Kubernetes::K8sJobInfo]
25
+ def info_from_json(pod_json: nil, service_json: nil, secret_json: nil, ns_prefix: nil)
26
+ pod_hash = pod_info_from_json(pod_json, ns_prefix: ns_prefix)
27
+ service_hash = service_info_from_json(service_json)
28
+ secret_hash = secret_info_from_json(secret_json)
29
+
30
+ pod_hash.deep_merge!(service_hash)
31
+ pod_hash.deep_merge!(secret_hash)
32
+ OodCore::Job::Adapters::Kubernetes::K8sJobInfo.new(pod_hash)
33
+ rescue NoMethodError
34
+ raise K8sDataError, "unable to read data correctly from json"
35
+ end
36
+
37
+ # Turn a container hash into a Kubernetes::Resources::Container
38
+ #
39
+ # @param container [#to_h]
40
+ # the input container hash
41
+ # @param default_env [#to_h]
42
+ # Default env to merge with defined env
43
+ # @return [OodCore::Job::Adapters::Kubernetes::Resources::Container]
44
+ def container_from_native(container, default_env)
45
+ env = container.fetch(:env, {}).to_h.symbolize_keys
46
+ OodCore::Job::Adapters::Kubernetes::Resources::Container.new(
47
+ container[:name],
48
+ container[:image],
49
+ command: parse_command(container[:command]),
50
+ port: container[:port],
51
+ env: default_env.merge(env),
52
+ memory: container[:memory],
53
+ cpu: container[:cpu],
54
+ working_dir: container[:working_dir],
55
+ restart_policy: container[:restart_policy],
56
+ image_pull_secret: container[:image_pull_secret]
57
+ )
58
+ end
59
+
60
+ # Parse a command string given from a user and return an array.
61
+ # If given an array, the input is simply returned back.
62
+ #
63
+ # @param cmd [#to_s]
64
+ # the command to parse
65
+ # @return [Array<#to_s>]
66
+ # the command parsed into an array of arguements
67
+ def parse_command(cmd)
68
+ if cmd&.is_a?(Array)
69
+ cmd
70
+ else
71
+ Shellwords.split(cmd.to_s)
72
+ end
73
+ end
74
+
75
+ # Turn a configmap hash into a Kubernetes::Resources::ConfigMap
76
+ # that can be used in templates. Needs an id so that the resulting
77
+ # configmap has a known name.
78
+ #
79
+ # @param native [#to_h]
80
+ # the input configmap hash
81
+ # @param id [#to_s]
82
+ # the id to use for giving the configmap a name
83
+ # @return [OodCore::Job::Adapters::Kubernetes::Resources::ConfigMap]
84
+ def configmap_from_native(native, id)
85
+ configmap = native.fetch(:configmap, nil)
86
+ return nil if configmap.nil?
87
+
88
+ OodCore::Job::Adapters::Kubernetes::Resources::ConfigMap.new(
89
+ configmap_name(id),
90
+ (configmap[:files] || [])
91
+ )
92
+ end
93
+
94
+ # parse initialization containers from native data
95
+ #
96
+ # @param native_data [#to_h]
97
+ # the native data to parse. Expected key init_ctrs and for that
98
+ # key to be an array of hashes.
99
+ # @param default_env [#to_h]
100
+ # Default env to merge with defined env
101
+ # @return [Array<OodCore::Job::Adapters::Kubernetes::Resources::Container>]
102
+ # the array of init containers
103
+ def init_ctrs_from_native(ctrs, default_env)
104
+ init_ctrs = []
105
+
106
+ ctrs&.each do |ctr_raw|
107
+ ctr = container_from_native(ctr_raw, default_env)
108
+ init_ctrs.push(ctr)
109
+ end
110
+
111
+ init_ctrs
112
+ end
113
+
114
+ def service_name(id)
115
+ id + '-service'
116
+ end
117
+
118
+ def secret_name(id)
119
+ id + '-secret'
120
+ end
121
+
122
+ def configmap_name(id)
123
+ id + '-configmap'
124
+ end
125
+
126
+ def seconds_to_duration(s)
127
+ "%02dh%02dm%02ds" % [s / 3600, s / 60 % 60, s % 60]
128
+ end
129
+
130
+ # Extract pod info from json data. The data is expected to be from the kubectl
131
+ # command and conform to kubernetes' datatype structures.
132
+ #
133
+ # @param json_data [#to_h]
134
+ # the pod data returned from 'kubectl get pod abc-123'
135
+ # @param ns_prefix [#to_s]
136
+ # the namespace prefix so that namespaces can be converted back to usernames
137
+ # @return [#to_h]
138
+ # the hash of info expected from adapters
139
+ def pod_info_from_json(json_data, ns_prefix: nil)
140
+ {
141
+ id: json_data.dig(:metadata, :name).to_s,
142
+ job_name: name_from_metadata(json_data.dig(:metadata)),
143
+ status: pod_status_from_json(json_data),
144
+ job_owner: job_owner_from_json(json_data, ns_prefix),
145
+ submission_time: submission_time(json_data),
146
+ dispatch_time: dispatch_time(json_data),
147
+ wallclock_time: wallclock_time(json_data),
148
+ ood_connection_info: { host: get_host(json_data.dig(:status, :hostIP)) },
149
+ procs: procs_from_json(json_data)
150
+ }
151
+ rescue NoMethodError
152
+ # gotta raise an error because Info.new will throw an error if id is undefined
153
+ raise K8sDataError, "unable to read data correctly from json"
154
+ end
155
+
156
+ private
157
+
158
+ def get_host(ip)
159
+ Resolv.getname(ip)
160
+ rescue Resolv::ResolvError
161
+ ip
162
+ end
163
+
164
+ def name_from_metadata(metadata)
165
+ name = metadata.dig(:labels, :'app.kubernetes.io/name')
166
+ name = metadata.dig(:labels, :'k8s-app') if name.nil?
167
+ name = metadata.dig(:name) if name.nil? # pod-id but better than nil?
168
+ name
169
+ end
170
+
171
+ def service_info_from_json(json_data)
172
+ # all we need is the port - .spec.ports[0].nodePort
173
+ ports = json_data.dig(:spec, :ports)
174
+ { ood_connection_info: { port: ports[0].dig(:nodePort) } }
175
+ rescue
176
+ {}
177
+ end
178
+
179
+ def secret_info_from_json(json_data)
180
+ raw = json_data.dig(:data, :password)
181
+ { ood_connection_info: { password: Base64.decode64(raw) } }
182
+ rescue
183
+ {}
184
+ end
185
+
186
+ def dispatch_time(json_data)
187
+ status = pod_status_from_json(json_data)
188
+ container_statuses = json_data.dig(:status, :containerStatuses)
189
+ return nil if container_statuses.nil?
190
+
191
+ state_data = container_statuses[0].dig(:state)
192
+ date_string = nil
193
+
194
+ if status == 'completed'
195
+ date_string = state_data.dig(:terminated, :startedAt)
196
+ elsif status == 'running'
197
+ date_string = state_data.dig(:running, :startedAt)
198
+ end
199
+
200
+ date_string.nil? ? nil : DateTime.parse(date_string).to_time.to_i
201
+ end
202
+
203
+ def wallclock_time(json_data)
204
+ status = pod_status_from_json(json_data)
205
+ container_statuses = json_data.dig(:status, :containerStatuses)
206
+ return nil if container_statuses.nil?
207
+
208
+ state_data = container_statuses[0].dig(:state)
209
+ start_time = dispatch_time(json_data)
210
+ return nil if start_time.nil?
211
+
212
+ et = end_time(status, state_data)
213
+
214
+ et.nil? ? nil : et - start_time
215
+ end
216
+
217
+ def end_time(status, state_data)
218
+ if status == 'completed'
219
+ end_time_string = state_data.dig(:terminated, :finishedAt)
220
+ et = DateTime.parse(end_time_string).to_time.to_i
221
+ elsif status == 'running'
222
+ et = DateTime.now.to_time.to_i
223
+ else
224
+ et = nil
225
+ end
226
+
227
+ et
228
+ end
229
+
230
+ def submission_time(json_data)
231
+ status = json_data.dig(:status)
232
+ start = status.dig(:startTime)
233
+
234
+ if start.nil?
235
+ # the pod is in some pending state limbo
236
+ conditions = status.dig(:conditions)
237
+ # best guess to start time is just the first condition's
238
+ # transition time
239
+ str = conditions[0].dig(:lastTransitionTime)
240
+ else
241
+ str = start
242
+ end
243
+
244
+ DateTime.parse(str).to_time.to_i
245
+ end
246
+
247
+ def pod_status_from_json(json_data)
248
+ phase = json_data.dig(:status, :phase)
249
+ state = case phase
250
+ when "Running"
251
+ "running"
252
+ when "Pending"
253
+ "queued"
254
+ when "Failed"
255
+ "suspended"
256
+ when "Succeeded"
257
+ "completed"
258
+ when "Unknown"
259
+ "undetermined"
260
+ else
261
+ "undetermined"
262
+ end
263
+
264
+ OodCore::Job::Status.new(state: state)
265
+ end
266
+
267
+ def terminated_state(status)
268
+ reason = status.dig(:terminated, :reason)
269
+ if reason == 'Error'
270
+ 'suspended'
271
+ else
272
+ 'completed'
273
+ end
274
+ end
275
+
276
+ def procs_from_json(json_data)
277
+ containers = json_data.dig(:spec, :containers)
278
+ resources = containers[0].dig(:resources)
279
+
280
+ cpu = resources.dig(:limits, :cpu)
281
+ millicores_rex = /(\d+)m/
282
+
283
+ # ok to return string bc nil.to_i == 0 and we'd rather return
284
+ # nil (undefined) than 0 which is confusing.
285
+ if millicores_rex.match?(cpu)
286
+ millicores = millicores_rex.match(cpu)[1].to_i
287
+
288
+ # have to return at least 1 bc 200m could be 0
289
+ ((millicores + 1000) / 1000).to_s
290
+ else
291
+ cpu
292
+ end
293
+ end
294
+
295
+ def job_owner_from_json(json_data = {}, ns_prefix = nil)
296
+ namespace = json_data.dig(:metadata, :namespace).to_s
297
+ namespace.delete_prefix(ns_prefix.to_s)
298
+ end
299
+ end
@@ -0,0 +1,9 @@
1
+ # An object that describes a submitted kubernetes job with extended information
2
+ class OodCore::Job::Adapters::Kubernetes::K8sJobInfo < OodCore::Job::Info
3
+ attr_reader :ood_connection_info
4
+
5
+ def initialize(ood_connection_info: {}, **options)
6
+ super(options)
7
+ @ood_connection_info = ood_connection_info
8
+ end
9
+ end
@@ -0,0 +1,82 @@
1
+ module OodCore::Job::Adapters::Kubernetes::Resources
2
+
3
+ class ConfigMap
4
+ attr_accessor :name, :files
5
+
6
+ def initialize(name, files)
7
+ @name = name
8
+ @files = []
9
+ files.each do |f|
10
+ @files << ConfigMapFile.new(f)
11
+ end
12
+ end
13
+
14
+ def mounts?
15
+ @files.any? { |f| f.mount_path }
16
+ end
17
+
18
+ def init_mounts?
19
+ @files.any? { |f| f.init_mount_path }
20
+ end
21
+ end
22
+
23
+ class ConfigMapFile
24
+ attr_accessor :filename, :data, :mount_path, :sub_path, :init_mount_path, :init_sub_path
25
+
26
+ def initialize(data)
27
+ @filename = data[:filename]
28
+ @data = data[:data]
29
+ @mount_path = data[:mount_path]
30
+ @sub_path = data[:sub_path]
31
+ @init_mount_path = data[:init_mount_path]
32
+ @init_sub_path = data[:init_sub_path]
33
+ end
34
+ end
35
+
36
+ class Container
37
+ attr_accessor :name, :image, :command, :port, :env, :memory, :cpu, :working_dir,
38
+ :restart_policy, :image_pull_secret, :supplemental_groups
39
+
40
+ def initialize(
41
+ name, image, command: [], port: nil, env: {}, memory: "4Gi", cpu: "1",
42
+ working_dir: "", restart_policy: "Never", image_pull_secret: nil, supplemental_groups: []
43
+ )
44
+ raise ArgumentError, "containers need valid names and images" unless name && image
45
+
46
+ @name = name
47
+ @image = image
48
+ @command = command.nil? ? [] : command
49
+ @port = port&.to_i
50
+ @env = env.nil? ? {} : env
51
+ @memory = memory.nil? ? "4Gi" : memory
52
+ @cpu = cpu.nil? ? "1" : cpu
53
+ @working_dir = working_dir.nil? ? "" : working_dir
54
+ @restart_policy = restart_policy.nil? ? "Never" : restart_policy
55
+ @image_pull_secret = image_pull_secret
56
+ @supplemental_groups = supplemental_groups.nil? ? [] : supplemental_groups
57
+ end
58
+
59
+ def ==(other)
60
+ name == other.name &&
61
+ image == other.image &&
62
+ command == other.command &&
63
+ port == other.port &&
64
+ env == other.env &&
65
+ memory == other.memory &&
66
+ cpu == other.cpu &&
67
+ working_dir == other.working_dir &&
68
+ restart_policy == other.restart_policy &&
69
+ image_pull_secret == other.image_pull_secret &&
70
+ supplemental_groups == other.supplemental_groups
71
+ end
72
+ end
73
+
74
+ class PodSpec
75
+ attr_accessor :container, :init_containers
76
+ def initialize(container, init_containers: nil)
77
+ @container = container
78
+ @init_containers = init_containers
79
+ end
80
+ end
81
+
82
+ end
@@ -0,0 +1,188 @@
1
+ apiVersion: v1
2
+ kind: Pod
3
+ metadata:
4
+ namespace: <%= namespace %>
5
+ name: <%= id %>
6
+ labels:
7
+ job: <%= id %>
8
+ app.kubernetes.io/name: <%= container.name %>
9
+ app.kubernetes.io/managed-by: open-ondemand
10
+ <%- if !script.accounting_id.nil? && script.accounting_id != "" -%>
11
+ account: <%= script.accounting_id %>
12
+ <%- end -%>
13
+ annotations:
14
+ <%- unless script.wall_time.nil? -%>
15
+ pod.kubernetes.io/lifetime: <%= helper.seconds_to_duration(script.wall_time) %>
16
+ <%- end -%>
17
+ spec:
18
+ restartPolicy: <%= spec.container.restart_policy %>
19
+ securityContext:
20
+ runAsUser: <%= run_as_user %>
21
+ runAsGroup: <%= run_as_group %>
22
+ runAsNonRoot: true
23
+ <%- if spec.container.supplemental_groups.empty? -%>
24
+ supplementalGroups: []
25
+ <%- else -%>
26
+ supplementalGroups:
27
+ <%- spec.container.supplemental_groups.each do |supplemental_group| -%>
28
+ - "<%= supplemental_group %>"
29
+ <%- end -%>
30
+ <%- end -%>
31
+ fsGroup: <%= fs_group %>
32
+ hostNetwork: false
33
+ hostIPC: false
34
+ hostPID: false
35
+ <%- unless spec.container.image_pull_secret.nil? -%>
36
+ imagePullSecrets:
37
+ - name: <%= spec.container.image_pull_secret %>
38
+ <%- end -%>
39
+ containers:
40
+ - name: "<%= spec.container.name %>"
41
+ image: <%= spec.container.image %>
42
+ imagePullPolicy: IfNotPresent
43
+ <%- unless spec.container.working_dir.empty? -%>
44
+ workingDir: "<%= spec.container.working_dir %>"
45
+ <%- end -%>
46
+ env:
47
+ - name: POD_NAME
48
+ valueFrom:
49
+ fieldRef:
50
+ fieldPath: metadata.name
51
+ <%- spec.container.env.each_pair do |name, value| -%>
52
+ - name: <%= name %>
53
+ value: "<%= value %>"
54
+ <%- end # for each env -%>
55
+ <%- unless spec.container.command.empty? -%>
56
+ command:
57
+ <%- spec.container.command.each do |cmd| -%>
58
+ - "<%= cmd %>"
59
+ <%- end # for each command -%>
60
+ <%- end # unless command is nil -%>
61
+ <%- unless spec.container.port.nil? -%>
62
+ ports:
63
+ - containerPort: <%= spec.container.port %>
64
+ <%- end -%>
65
+ <%- if configmap.mounts? || !all_mounts.empty? -%>
66
+ volumeMounts:
67
+ <%- configmap.files.each do |file| -%>
68
+ <%- next if file.mount_path.nil? -%>
69
+ - name: configmap-volume
70
+ mountPath: <%= file.mount_path %>
71
+ <%- unless file.sub_path.nil? -%>
72
+ subPath: <%= file.sub_path %>
73
+ <%- end # end unless file.sub_path.nil? -%>
74
+ <%- end # end configmap.files.each -%>
75
+ <%- all_mounts.each do |mount| -%>
76
+ - name: <%= mount[:name] %>
77
+ mountPath: <%= mount[:destination_path] %>
78
+ <%- end # for each mount -%>
79
+ <%- end # configmap mounts? and all_mounts not empty -%>
80
+ resources:
81
+ limits:
82
+ memory: "<%= spec.container.memory %>"
83
+ cpu: "<%= spec.container.cpu %>"
84
+ requests:
85
+ memory: "<%= spec.container.memory %>"
86
+ cpu: "<%= spec.container.cpu %>"
87
+ securityContext:
88
+ allowPrivilegeEscalation: false
89
+ capabilities:
90
+ drop:
91
+ - all
92
+ privileged: false
93
+ <%- unless spec.init_containers.nil? -%>
94
+ initContainers:
95
+ <%- spec.init_containers.each do |ctr| -%>
96
+ - name: "<%= ctr.name %>"
97
+ image: "<%= ctr.image %>"
98
+ env:
99
+ - name: POD_NAME
100
+ valueFrom:
101
+ fieldRef:
102
+ fieldPath: metadata.name
103
+ <%- ctr.env.each_pair do |name, value| -%>
104
+ - name: <%= name %>
105
+ value: "<%= value %>"
106
+ <%- end # for each env -%>
107
+ command:
108
+ <%- ctr.command.each do |cmd| -%>
109
+ - "<%= cmd %>"
110
+ <%- end # command loop -%>
111
+ <%- if configmap.init_mounts? || !all_mounts.empty? -%>
112
+ volumeMounts:
113
+ <%- configmap.files.each do |file| -%>
114
+ <%- next if file.init_mount_path.nil? -%>
115
+ - name: configmap-volume
116
+ mountPath: <%= file.init_mount_path %>
117
+ <%- unless file.init_sub_path.nil? -%>
118
+ subPath: <%= file.init_sub_path %>
119
+ <%- end # end unless file.sub_path.nil? -%>
120
+ <%- end # end configmap.files.each -%>
121
+ <%- all_mounts.each do |mount| -%>
122
+ - name: <%= mount[:name] %>
123
+ mountPath: <%= mount[:destination_path] %>
124
+ <%- end # for each mount -%>
125
+ <%- end # if config_map init mounts and all_mounts not empty -%>
126
+ securityContext:
127
+ allowPrivilegeEscalation: false
128
+ capabilities:
129
+ drop:
130
+ - all
131
+ privileged: false
132
+ <%- end # init container loop -%>
133
+ <%- end # if init containers -%>
134
+ <%- unless (configmap.to_s.empty? && all_mounts.empty?) -%>
135
+ volumes:
136
+ <%- unless configmap.to_s.empty? -%>
137
+ - name: configmap-volume
138
+ configMap:
139
+ name: <%= configmap_name(id) %>
140
+ <%- end -%>
141
+ <%- all_mounts.each do |mount| -%>
142
+ <%- if mount[:type] == 'nfs' -%>
143
+ - name: <%= mount[:name] %>
144
+ nfs:
145
+ server: <%= mount[:host] %>
146
+ path: <%= mount[:path] %>
147
+ <%- elsif mount[:type] == 'host' -%>
148
+ - name: <%= mount[:name] %>
149
+ hostPath:
150
+ path: <%= mount[:path] %>
151
+ type: <%= mount[:host_type] %>
152
+ <%- end # if mount is [host,nfs] -%>
153
+ <%- end # for each mount -%>
154
+ <%- end # (configmap.to_s.empty? || all_mounts.empty?) -%>
155
+ ---
156
+ <%- unless spec.container.port.nil? -%>
157
+ apiVersion: v1
158
+ kind: Service
159
+ metadata:
160
+ name: <%= service_name(id) %>
161
+ namespace: <%= namespace %>
162
+ labels:
163
+ job: <%= id %>
164
+ spec:
165
+ selector:
166
+ job: <%= id %>
167
+ ports:
168
+ - protocol: TCP
169
+ port: 80
170
+ targetPort: <%= spec.container.port %>
171
+ type: NodePort
172
+ <%- end # end for service -%>
173
+ ---
174
+ <%- unless configmap.nil? -%>
175
+ apiVersion: v1
176
+ kind: ConfigMap
177
+ metadata:
178
+ name: <%= configmap_name(id) %>
179
+ namespace: <%= namespace %>
180
+ labels:
181
+ job: <%= id %>
182
+ data:
183
+ <%- configmap.files.each do |file| -%>
184
+ <%- next if file.data.nil? || file.filename.nil? -%>
185
+ <%= file.filename %>: |
186
+ <% config_data_lines(file.data).each do |line| %><%= line %><% end %>
187
+ <%- end # end for configmap files -%>
188
+ <%- end # end configmap.nil? %>