Add 'apps/arv-web/' from commit 'f9732ad8460d013c2f28363655d0d1b91894dca5'
[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_equal 'Queued', job.state, "job.state"
193
194       parameters.each do |parameter|
195         expectations = parameter[2]
196         if parameter[1] == 'use_current_user_uuid'
197           parameter[1] = Thread.current[:user].uuid
198         end
199
200         if expectations.instance_of? Array
201           job[parameter[0]] = parameter[1]
202           assert_equal true, job.save, job.errors.full_messages.to_s
203           expectations.each do |expectation|
204             if expectation[1] == 'not_nil'
205               assert_not_nil job[expectation[0]], expectation[0]
206             elsif expectation[1] == 'nil'
207               assert_nil job[expectation[0]], expectation[0]
208             else
209               assert_equal expectation[1], job[expectation[0]], expectation[0]
210             end
211           end
212         else
213           raise 'I do not know how to handle this expectation'
214         end
215       end
216     end
217   end
218
219   test "Test job state changes" do
220     all = ["Queued", "Running", "Complete", "Failed", "Cancelled"]
221     valid = {"Queued" => all, "Running" => ["Complete", "Failed", "Cancelled"]}
222     all.each do |start|
223       all.each do |finish|
224         if start != finish
225           job = Job.create! job_attrs(state: start)
226           assert_equal start, job.state
227           job.state = finish
228           job.save
229           job.reload
230           if valid[start] and valid[start].include? finish
231             assert_equal finish, job.state
232           else
233             assert_equal start, job.state
234           end
235         end
236       end
237     end
238   end
239
240   test "Test job locking" do
241     set_user_from_auth :active_trustedclient
242     job = Job.create! job_attrs
243
244     assert_equal "Queued", job.state
245
246     # Should be able to lock successfully
247     job.lock current_user.uuid
248     assert_equal "Running", job.state
249
250     assert_raises ArvadosModel::AlreadyLockedError do
251       # Can't lock it again
252       job.lock current_user.uuid
253     end
254     job.reload
255     assert_equal "Running", job.state
256
257     set_user_from_auth :project_viewer
258     assert_raises ArvadosModel::AlreadyLockedError do
259       # Can't lock it as a different user either
260       job.lock current_user.uuid
261     end
262     job.reload
263     assert_equal "Running", job.state
264
265     assert_raises ArvadosModel::PermissionDeniedError do
266       # Can't update fields as a different user
267       job.update_attributes(state: "Failed")
268     end
269     job.reload
270     assert_equal "Running", job.state
271
272
273     set_user_from_auth :active_trustedclient
274
275     # Can update fields as the locked_by user
276     job.update_attributes(state: "Failed")
277     assert_equal "Failed", job.state
278   end
279
280   test "verify job queue position" do
281     job1 = Job.create! job_attrs
282     assert_equal 'Queued', job1.state, "Incorrect job state for newly created job1"
283
284     job2 = Job.create! job_attrs
285     assert_equal 'Queued', job2.state, "Incorrect job state for newly created job2"
286
287     assert_not_nil job1.queue_position, "Expected non-nil queue position for job1"
288     assert_not_nil job2.queue_position, "Expected non-nil queue position for job2"
289     assert_not_equal job1.queue_position, job2.queue_position
290   end
291
292   SDK_MASTER = "ca68b24e51992e790f29df5cc4bc54ce1da4a1c2"
293   SDK_TAGGED = "00634b2b8a492d6f121e3cf1d6587b821136a9a7"
294
295   def sdk_constraint(version)
296     {runtime_constraints: {
297         "arvados_sdk_version" => version,
298         "docker_image" => links(:docker_image_collection_tag).name,
299       }}
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 from no SDK to 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
332     test "updating job from SDK version 'master' to '#{search}'" do
333       job = Job.create!(job_attrs(sdk_constraint("master")))
334       assert_equal(SDK_MASTER, job.arvados_sdk_version)
335       check_job_sdk_version(commit_hash) do
336         job.runtime_constraints = sdk_constraint(search)[:runtime_constraints]
337         job
338       end
339     end
340   end
341
342   test "clear the SDK version" do
343     job = Job.create!(job_attrs(sdk_constraint("master")))
344     assert_equal(SDK_MASTER, job.arvados_sdk_version)
345     job.runtime_constraints = {}
346     assert(job.valid?, "job invalid after clearing SDK version")
347     assert_nil(job.arvados_sdk_version)
348   end
349
350   test "job with SDK constraint, without Docker image is invalid" do
351     sdk_attrs = sdk_constraint("master")
352     sdk_attrs[:runtime_constraints].delete("docker_image")
353     job = Job.create(job_attrs(sdk_attrs))
354     refute(job.valid?, "Job valid with SDK version, without Docker image")
355     sdk_errors = job.errors.messages[:arvados_sdk_version] || []
356     refute_empty(sdk_errors.grep(/\bDocker\b/),
357                  "no Job SDK errors mention that Docker is required")
358   end
359
360   test "invalid to clear Docker image constraint when SDK constraint exists" do
361     job = Job.create!(job_attrs(sdk_constraint("master")))
362     job.runtime_constraints.delete("docker_image")
363     refute(job.valid?,
364            "Job with SDK constraint valid after clearing Docker image")
365   end
366
367   test "can't create job with SDK version assigned directly" do
368     check_creation_prohibited(arvados_sdk_version: SDK_MASTER)
369   end
370
371   test "can't modify job to assign SDK version directly" do
372     check_modification_prohibited(arvados_sdk_version: SDK_MASTER)
373   end
374
375   test "job validation fails when collection uuid found in script_parameters" do
376     bad_params = {
377       script_parameters: {
378         'input' => {
379           'param1' => 'the collection uuid zzzzz-4zz18-012345678901234'
380         }
381       }
382     }
383     assert_raises(ActiveRecord::RecordInvalid,
384                   "created job with a collection uuid in script_parameters") do
385       job = Job.create!(job_attrs(bad_params))
386     end
387   end
388
389   test "job validation succeeds when no collection uuid in script_parameters" do
390     good_params = {
391       script_parameters: {
392         'arg1' => 'foo',
393         'arg2' => [ 'bar', 'baz' ],
394         'arg3' => {
395           'a' => 1,
396           'b' => [2, 3, 4],
397         }
398       }
399     }
400     job = Job.create!(job_attrs(good_params))
401     assert job.valid?
402   end
403 end