2853: Merge branch 'master' into 2853-rendezvous
[arvados.git] / services / api / test / unit / job_test.rb
1 require 'test_helper'
2 require 'helpers/git_test_helper'
3
4 class JobTest < ActiveSupport::TestCase
5   include GitTestHelper
6
7   BAD_COLLECTION = "#{'f' * 32}+0"
8
9   setup do
10     set_user_from_auth :active
11   end
12
13   def job_attrs merge_me={}
14     # Default (valid) set of attributes, with given overrides
15     {
16       script: "hash",
17       script_version: "master",
18       repository: "foo",
19     }.merge(merge_me)
20   end
21
22   test "Job without Docker image doesn't get locator" do
23     job = Job.new job_attrs
24     assert job.valid?, job.errors.full_messages.to_s
25     assert_nil job.docker_image_locator
26   end
27
28   { 'name' => [:links, :docker_image_collection_tag, :name],
29     'hash' => [:links, :docker_image_collection_hash, :name],
30     'locator' => [:collections, :docker_image, :portable_data_hash],
31   }.each_pair do |spec_type, (fixture_type, fixture_name, fixture_attr)|
32     test "Job initialized with Docker image #{spec_type} gets locator" do
33       image_spec = send(fixture_type, fixture_name).send(fixture_attr)
34       job = Job.new job_attrs(runtime_constraints:
35                               {'docker_image' => image_spec})
36       assert job.valid?, job.errors.full_messages.to_s
37       assert_equal(collections(:docker_image).portable_data_hash, job.docker_image_locator)
38     end
39
40     test "Job modified with Docker image #{spec_type} gets locator" do
41       job = Job.new job_attrs
42       assert job.valid?, job.errors.full_messages.to_s
43       assert_nil job.docker_image_locator
44       image_spec = send(fixture_type, fixture_name).send(fixture_attr)
45       job.runtime_constraints['docker_image'] = image_spec
46       assert job.valid?, job.errors.full_messages.to_s
47       assert_equal(collections(:docker_image).portable_data_hash, job.docker_image_locator)
48     end
49   end
50
51   test "removing a Docker runtime constraint removes the locator" do
52     image_locator = collections(:docker_image).portable_data_hash
53     job = Job.new job_attrs(runtime_constraints:
54                             {'docker_image' => image_locator})
55     assert job.valid?, job.errors.full_messages.to_s
56     assert_equal(image_locator, job.docker_image_locator)
57     job.runtime_constraints = {}
58     assert job.valid?, job.errors.full_messages.to_s + "after clearing runtime constraints"
59     assert_nil job.docker_image_locator
60   end
61
62   test "locate a Docker image with a repository + tag" do
63     image_repo, image_tag =
64       links(:docker_image_collection_tag2).name.split(':', 2)
65     job = Job.new job_attrs(runtime_constraints:
66                             {'docker_image' => image_repo,
67                               'docker_image_tag' => image_tag})
68     assert job.valid?, job.errors.full_messages.to_s
69     assert_equal(collections(:docker_image).portable_data_hash, job.docker_image_locator)
70   end
71
72   test "can't locate a Docker image with a nonexistent tag" do
73     image_repo = links(:docker_image_collection_tag).name
74     image_tag = '__nonexistent tag__'
75     job = Job.new job_attrs(runtime_constraints:
76                             {'docker_image' => image_repo,
77                               'docker_image_tag' => image_tag})
78     assert(job.invalid?, "Job with bad Docker tag valid")
79   end
80
81   test "locate a Docker image with a partial hash" do
82     image_hash = links(:docker_image_collection_hash).name[0..24]
83     job = Job.new job_attrs(runtime_constraints:
84                             {'docker_image' => image_hash})
85     assert job.valid?, job.errors.full_messages.to_s + " with partial hash #{image_hash}"
86     assert_equal(collections(:docker_image).portable_data_hash, job.docker_image_locator)
87   end
88
89   { 'name' => 'arvados_test_nonexistent',
90     'hash' => 'f' * 64,
91     'locator' => BAD_COLLECTION,
92   }.each_pair do |spec_type, image_spec|
93     test "Job validation fails with nonexistent Docker image #{spec_type}" do
94       job = Job.new job_attrs(runtime_constraints:
95                               {'docker_image' => image_spec})
96       assert(job.invalid?, "nonexistent Docker image #{spec_type} was valid")
97     end
98   end
99
100   test "Job validation fails with non-Docker Collection constraint" do
101     job = Job.new job_attrs(runtime_constraints:
102                             {'docker_image' => collections(:foo_file).uuid})
103     assert(job.invalid?, "non-Docker Collection constraint was valid")
104   end
105
106   test "can create Job with Docker image Collection without Docker links" do
107     image_uuid = collections(:unlinked_docker_image).portable_data_hash
108     job = Job.new job_attrs(runtime_constraints: {"docker_image" => image_uuid})
109     assert(job.valid?, "Job created with unlinked Docker image was invalid")
110     assert_equal(image_uuid, job.docker_image_locator)
111   end
112
113   def check_attrs_unset(job, attrs)
114     assert_empty(attrs.each_key.map { |key| job.send(key) }.compact,
115                  "job has values for #{attrs.keys}")
116   end
117
118   def check_creation_prohibited(attrs)
119     begin
120       job = Job.new(job_attrs(attrs))
121     rescue ActiveModel::MassAssignmentSecurity::Error
122       # Test passes - expected attribute protection
123     else
124       check_attrs_unset(job, attrs)
125     end
126   end
127
128   def check_modification_prohibited(attrs)
129     job = Job.new(job_attrs)
130     attrs.each_pair do |key, value|
131       assert_raises(NoMethodError) { job.send("{key}=".to_sym, value) }
132     end
133     check_attrs_unset(job, attrs)
134   end
135
136   test "can't create Job with Docker image locator" do
137     check_creation_prohibited(docker_image_locator: BAD_COLLECTION)
138   end
139
140   test "can't assign Docker image locator to Job" do
141     check_modification_prohibited(docker_image_locator: BAD_COLLECTION)
142   end
143
144   [
145    {script_parameters: ""},
146    {script_parameters: []},
147    {script_parameters: {symbols: :are_not_allowed_here}},
148    {runtime_constraints: ""},
149    {runtime_constraints: []},
150    {tasks_summary: ""},
151    {tasks_summary: []},
152    {script_version: "no/branch/could/ever/possibly/have/this/name"},
153   ].each do |invalid_attrs|
154     test "validation failures set error messages: #{invalid_attrs.to_json}" do
155       # Ensure valid_attrs doesn't produce errors -- otherwise we will
156       # not know whether errors reported below are actually caused by
157       # invalid_attrs.
158       dummy = Job.create! job_attrs
159
160       job = Job.create job_attrs(invalid_attrs)
161       assert_raises(ActiveRecord::RecordInvalid, ArgumentError,
162                     "save! did not raise the expected exception") do
163         job.save!
164       end
165       assert_not_empty job.errors, "validation failure did not provide errors"
166     end
167   end
168
169   [
170     # Each test case is of the following format
171     # Array of parameters where each parameter is of the format:
172     #  attr name to be changed, attr value, and array of expectations (where each expectation is an array)
173     [['running', false, [['state', 'Queued']]]],
174     [['state', 'Running', [['started_at', 'not_nil']]]],
175     [['is_locked_by_uuid', 'use_current_user_uuid', [['state', 'Queued']]], ['state', 'Running', [['running', true], ['started_at', 'not_nil'], ['success', 'nil']]]],
176     [['running', false, [['state', 'Queued']]], ['state', 'Complete', [['success', true]]]],
177     [['running', true, [['state', 'Running']]], ['cancelled_at', Time.now, [['state', 'Cancelled']]]],
178     [['running', true, [['state', 'Running']]], ['state', 'Cancelled', [['cancelled_at', 'not_nil']]]],
179     [['running', true, [['state', 'Running']]], ['success', true, [['state', 'Complete']]]],
180     [['running', true, [['state', 'Running']]], ['success', false, [['state', 'Failed']]]],
181     [['running', true, [['state', 'Running']]], ['state', 'Complete', [['success', true],['finished_at', 'not_nil']]]],
182     [['running', true, [['state', 'Running']]], ['state', 'Failed', [['success', false],['finished_at', 'not_nil']]]],
183     [['cancelled_at', Time.now, [['state', 'Cancelled']]], ['success', false, [['state', 'Cancelled'],['finished_at', 'nil'], ['cancelled_at', 'not_nil']]]],
184     [['cancelled_at', Time.now, [['state', 'Cancelled'],['running', false]]], ['success', true, [['state', 'Cancelled'],['running', false],['finished_at', 'nil'],['cancelled_at', 'not_nil']]]],
185     # potential migration cases
186     [['state', nil, [['state', 'Queued']]]],
187     [['state', nil, [['state', 'Queued']]], ['cancelled_at', Time.now, [['state', 'Cancelled']]]],
188     [['running', true, [['state', 'Running']]], ['state', nil, [['state', 'Running']]]],
189   ].each do |parameters|
190     test "verify job status #{parameters}" do
191       job = Job.create! job_attrs
192       assert job.valid?, job.errors.full_messages.to_s
193       assert_equal 'Queued', job.state, "job.state"
194
195       parameters.each do |parameter|
196         expectations = parameter[2]
197         if parameter[1] == 'use_current_user_uuid'
198           parameter[1] = Thread.current[:user].uuid
199         end
200
201         if expectations.instance_of? Array
202           job[parameter[0]] = parameter[1]
203           assert_equal true, job.save, job.errors.full_messages.to_s
204           expectations.each do |expectation|
205             if expectation[1] == 'not_nil'
206               assert_not_nil job[expectation[0]], expectation[0]
207             elsif expectation[1] == 'nil'
208               assert_nil job[expectation[0]], expectation[0]
209             else
210               assert_equal expectation[1], job[expectation[0]], expectation[0]
211             end
212           end
213         else
214           raise 'I do not know how to handle this expectation'
215         end
216       end
217     end
218   end
219
220   test "Test job state changes" do
221     all = ["Queued", "Running", "Complete", "Failed", "Cancelled"]
222     valid = {"Queued" => all, "Running" => ["Complete", "Failed", "Cancelled"]}
223     all.each do |start|
224       all.each do |finish|
225         if start != finish
226           job = Job.create! job_attrs(state: start)
227           assert_equal start, job.state
228           job.state = finish
229           job.save
230           job.reload
231           if valid[start] and valid[start].include? finish
232             assert_equal finish, job.state
233           else
234             assert_equal start, job.state
235           end
236         end
237       end
238     end
239   end
240
241   test "Test job locking" do
242     set_user_from_auth :active_trustedclient
243     job = Job.create! job_attrs
244
245     assert_equal "Queued", job.state
246
247     # Should be able to lock successfully
248     job.lock current_user.uuid
249     assert_equal "Running", job.state
250
251     assert_raises ArvadosModel::AlreadyLockedError do
252       # Can't lock it again
253       job.lock current_user.uuid
254     end
255     job.reload
256     assert_equal "Running", job.state
257
258     set_user_from_auth :project_viewer
259     assert_raises ArvadosModel::AlreadyLockedError do
260       # Can't lock it as a different user either
261       job.lock current_user.uuid
262     end
263     job.reload
264     assert_equal "Running", job.state
265
266     assert_raises ArvadosModel::PermissionDeniedError do
267       # Can't update fields as a different user
268       job.update_attributes(state: "Failed")
269     end
270     job.reload
271     assert_equal "Running", job.state
272
273
274     set_user_from_auth :active_trustedclient
275
276     # Can update fields as the locked_by user
277     job.update_attributes(state: "Failed")
278     assert_equal "Failed", job.state
279   end
280
281   test "verify job queue position" do
282     job1 = Job.create! job_attrs
283     assert job1.valid?, job1.errors.full_messages.to_s
284     assert_equal 'Queued', job1.state, "Incorrect job state for newly created job1"
285
286     job2 = Job.create! job_attrs
287     assert job2.valid?, job2.errors.full_messages.to_s
288     assert_equal 'Queued', job2.state, "Incorrect job state for newly created job2"
289
290     assert_not_nil job1.queue_position, "Expected non-nil queue position for job1"
291     assert_not_nil job2.queue_position, "Expected non-nil queue position for job2"
292     assert_not_equal job1.queue_position, job2.queue_position
293   end
294
295   SDK_MASTER = "ca68b24e51992e790f29df5cc4bc54ce1da4a1c2"
296   SDK_TAGGED = "00634b2b8a492d6f121e3cf1d6587b821136a9a7"
297
298   def sdk_constraint(version)
299     {runtime_constraints: {"arvados_sdk_version" => version}}
300   end
301
302   def check_job_sdk_version(expected)
303     job = yield
304     if expected.nil?
305       refute(job.valid?, "job valid with bad Arvados SDK version")
306     else
307       assert(job.valid?, "job not valid with good Arvados SDK version")
308       assert_equal(expected, job.arvados_sdk_version)
309     end
310   end
311
312   { "master" => SDK_MASTER,
313     "commit2" => SDK_TAGGED,
314     SDK_TAGGED[0, 8] => SDK_TAGGED,
315     "__nonexistent__" => nil,
316   }.each_pair do |search, commit_hash|
317     test "creating job with SDK version '#{search}'" do
318       check_job_sdk_version(commit_hash) do
319         Job.new(job_attrs(sdk_constraint(search)))
320       end
321     end
322
323     test "updating job with SDK version '#{search}'" do
324       job = Job.create!(job_attrs)
325       assert_nil job.arvados_sdk_version
326       check_job_sdk_version(commit_hash) do
327         job.runtime_constraints = sdk_constraint(search)[:runtime_constraints]
328         job
329       end
330     end
331   end
332
333   test "clear the SDK version" do
334     job = Job.create!(job_attrs(sdk_constraint("master")))
335     assert_equal(SDK_MASTER, job.arvados_sdk_version)
336     job.runtime_constraints = {}
337     assert(job.valid?, "job invalid after clearing SDK version")
338     assert_nil(job.arvados_sdk_version)
339   end
340
341   test "can't create job with SDK version assigned directly" do
342     check_creation_prohibited(arvados_sdk_version: SDK_MASTER)
343   end
344
345   test "can't modify job to assign SDK version directly" do
346     check_modification_prohibited(arvados_sdk_version: SDK_MASTER)
347   end
348 end