Merge branch 'master' into 9998-unsigned_manifest
[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: "active/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   [
82     false,
83     true
84   ].each do |use_config|
85     test "Job with no Docker image uses default docker image when configuration is set #{use_config}" do
86       default_docker_image = collections(:docker_image)[:portable_data_hash]
87       Rails.configuration.default_docker_image_for_jobs = default_docker_image if use_config
88
89       job = Job.new job_attrs
90       assert job.valid?, job.errors.full_messages.to_s
91
92       if use_config
93         refute_nil job.docker_image_locator
94         assert_equal default_docker_image, job.docker_image_locator
95       else
96         assert_nil job.docker_image_locator
97       end
98     end
99   end
100
101   test "create a job with a disambiguated script_version branch name" do
102     job = Job.
103       new(script: "testscript",
104           script_version: "heads/7387838c69a21827834586cc42b467ff6c63293b",
105           repository: "active/shabranchnames",
106           script_parameters: {})
107     assert(job.save)
108     assert_equal("abec49829bf1758413509b7ffcab32a771b71e81", job.script_version)
109   end
110
111   test "locate a Docker image with a partial hash" do
112     image_hash = links(:docker_image_collection_hash).name[0..24]
113     job = Job.new job_attrs(runtime_constraints:
114                             {'docker_image' => image_hash})
115     assert job.valid?, job.errors.full_messages.to_s + " with partial hash #{image_hash}"
116     assert_equal(collections(:docker_image).portable_data_hash, job.docker_image_locator)
117   end
118
119   { 'name' => 'arvados_test_nonexistent',
120     'hash' => 'f' * 64,
121     'locator' => BAD_COLLECTION,
122   }.each_pair do |spec_type, image_spec|
123     test "Job validation fails with nonexistent Docker image #{spec_type}" do
124       job = Job.new job_attrs(runtime_constraints:
125                               {'docker_image' => image_spec})
126       assert(job.invalid?, "nonexistent Docker image #{spec_type} was valid")
127     end
128   end
129
130   test "Job validation fails with non-Docker Collection constraint" do
131     job = Job.new job_attrs(runtime_constraints:
132                             {'docker_image' => collections(:foo_file).uuid})
133     assert(job.invalid?, "non-Docker Collection constraint was valid")
134   end
135
136   test "can create Job with Docker image Collection without Docker links" do
137     image_uuid = collections(:unlinked_docker_image).portable_data_hash
138     job = Job.new job_attrs(runtime_constraints: {"docker_image" => image_uuid})
139     assert(job.valid?, "Job created with unlinked Docker image was invalid")
140     assert_equal(image_uuid, job.docker_image_locator)
141   end
142
143   def check_attrs_unset(job, attrs)
144     assert_empty(attrs.each_key.map { |key| job.send(key) }.compact,
145                  "job has values for #{attrs.keys}")
146   end
147
148   def check_creation_prohibited(attrs)
149     begin
150       job = Job.new(job_attrs(attrs))
151     rescue ActiveModel::MassAssignmentSecurity::Error
152       # Test passes - expected attribute protection
153     else
154       check_attrs_unset(job, attrs)
155     end
156   end
157
158   def check_modification_prohibited(attrs)
159     job = Job.new(job_attrs)
160     attrs.each_pair do |key, value|
161       assert_raises(NoMethodError) { job.send("{key}=".to_sym, value) }
162     end
163     check_attrs_unset(job, attrs)
164   end
165
166   test "can't create Job with Docker image locator" do
167     check_creation_prohibited(docker_image_locator: BAD_COLLECTION)
168   end
169
170   test "can't assign Docker image locator to Job" do
171     check_modification_prohibited(docker_image_locator: BAD_COLLECTION)
172   end
173
174   [
175    {script_parameters: ""},
176    {script_parameters: []},
177    {script_parameters: {symbols: :are_not_allowed_here}},
178    {runtime_constraints: ""},
179    {runtime_constraints: []},
180    {tasks_summary: ""},
181    {tasks_summary: []},
182    {script_version: "no/branch/could/ever/possibly/have/this/name"},
183   ].each do |invalid_attrs|
184     test "validation failures set error messages: #{invalid_attrs.to_json}" do
185       # Ensure valid_attrs doesn't produce errors -- otherwise we will
186       # not know whether errors reported below are actually caused by
187       # invalid_attrs.
188       dummy = Job.create! job_attrs
189
190       job = Job.create job_attrs(invalid_attrs)
191       assert_raises(ActiveRecord::RecordInvalid, ArgumentError,
192                     "save! did not raise the expected exception") do
193         job.save!
194       end
195       assert_not_empty job.errors, "validation failure did not provide errors"
196     end
197   end
198
199   [
200     # Each test case is of the following format
201     # Array of parameters where each parameter is of the format:
202     #  attr name to be changed, attr value, and array of expectations (where each expectation is an array)
203     [['running', false, [['state', 'Queued']]]],
204     [['state', 'Running', [['started_at', 'not_nil']]]],
205     [['is_locked_by_uuid', 'use_current_user_uuid', [['state', 'Queued']]], ['state', 'Running', [['running', true], ['started_at', 'not_nil'], ['success', 'nil']]]],
206     [['running', false, [['state', 'Queued']]], ['state', 'Complete', [['success', true]]]],
207     [['running', true, [['state', 'Running']]], ['cancelled_at', Time.now, [['state', 'Cancelled']]]],
208     [['running', true, [['state', 'Running']]], ['state', 'Cancelled', [['cancelled_at', 'not_nil']]]],
209     [['running', true, [['state', 'Running']]], ['success', true, [['state', 'Complete']]]],
210     [['running', true, [['state', 'Running']]], ['success', false, [['state', 'Failed']]]],
211     [['running', true, [['state', 'Running']]], ['state', 'Complete', [['success', true],['finished_at', 'not_nil']]]],
212     [['running', true, [['state', 'Running']]], ['state', 'Failed', [['success', false],['finished_at', 'not_nil']]]],
213     [['cancelled_at', Time.now, [['state', 'Cancelled']]], ['success', false, [['state', 'Cancelled'],['finished_at', 'nil'], ['cancelled_at', 'not_nil']]]],
214     [['cancelled_at', Time.now, [['state', 'Cancelled'],['running', false]]], ['success', true, [['state', 'Cancelled'],['running', false],['finished_at', 'nil'],['cancelled_at', 'not_nil']]]],
215     # potential migration cases
216     [['state', nil, [['state', 'Queued']]]],
217     [['state', nil, [['state', 'Queued']]], ['cancelled_at', Time.now, [['state', 'Cancelled']]]],
218     [['running', true, [['state', 'Running']]], ['state', nil, [['state', 'Running']]]],
219   ].each do |parameters|
220     test "verify job status #{parameters}" do
221       job = Job.create! job_attrs
222       assert_equal 'Queued', job.state, "job.state"
223
224       parameters.each do |parameter|
225         expectations = parameter[2]
226         if parameter[1] == 'use_current_user_uuid'
227           parameter[1] = Thread.current[:user].uuid
228         end
229
230         if expectations.instance_of? Array
231           job[parameter[0]] = parameter[1]
232           assert_equal true, job.save, job.errors.full_messages.to_s
233           expectations.each do |expectation|
234             if expectation[1] == 'not_nil'
235               assert_not_nil job[expectation[0]], expectation[0]
236             elsif expectation[1] == 'nil'
237               assert_nil job[expectation[0]], expectation[0]
238             else
239               assert_equal expectation[1], job[expectation[0]], expectation[0]
240             end
241           end
242         else
243           raise 'I do not know how to handle this expectation'
244         end
245       end
246     end
247   end
248
249   test "Test job state changes" do
250     all = ["Queued", "Running", "Complete", "Failed", "Cancelled"]
251     valid = {"Queued" => all, "Running" => ["Complete", "Failed", "Cancelled"]}
252     all.each do |start|
253       all.each do |finish|
254         if start != finish
255           job = Job.create! job_attrs(state: start)
256           assert_equal start, job.state
257           job.state = finish
258           job.save
259           job.reload
260           if valid[start] and valid[start].include? finish
261             assert_equal finish, job.state
262           else
263             assert_equal start, job.state
264           end
265         end
266       end
267     end
268   end
269
270   test "Test job locking" do
271     set_user_from_auth :active_trustedclient
272     job = Job.create! job_attrs
273
274     assert_equal "Queued", job.state
275
276     # Should be able to lock successfully
277     job.lock current_user.uuid
278     assert_equal "Running", job.state
279
280     assert_raises ArvadosModel::AlreadyLockedError do
281       # Can't lock it again
282       job.lock current_user.uuid
283     end
284     job.reload
285     assert_equal "Running", job.state
286
287     set_user_from_auth :project_viewer
288     assert_raises ArvadosModel::AlreadyLockedError do
289       # Can't lock it as a different user either
290       job.lock current_user.uuid
291     end
292     job.reload
293     assert_equal "Running", job.state
294
295     assert_raises ArvadosModel::PermissionDeniedError do
296       # Can't update fields as a different user
297       job.update_attributes(state: "Failed")
298     end
299     job.reload
300     assert_equal "Running", job.state
301
302
303     set_user_from_auth :active_trustedclient
304
305     # Can update fields as the locked_by user
306     job.update_attributes(state: "Failed")
307     assert_equal "Failed", job.state
308   end
309
310   test "verify job queue position" do
311     job1 = Job.create! job_attrs
312     assert_equal 'Queued', job1.state, "Incorrect job state for newly created job1"
313
314     job2 = Job.create! job_attrs
315     assert_equal 'Queued', job2.state, "Incorrect job state for newly created job2"
316
317     assert_not_nil job1.queue_position, "Expected non-nil queue position for job1"
318     assert_not_nil job2.queue_position, "Expected non-nil queue position for job2"
319   end
320
321   SDK_MASTER = "ca68b24e51992e790f29df5cc4bc54ce1da4a1c2"
322   SDK_TAGGED = "00634b2b8a492d6f121e3cf1d6587b821136a9a7"
323
324   def sdk_constraint(version)
325     {runtime_constraints: {
326         "arvados_sdk_version" => version,
327         "docker_image" => links(:docker_image_collection_tag).name,
328       }}
329   end
330
331   def check_job_sdk_version(expected)
332     job = yield
333     if expected.nil?
334       refute(job.valid?, "job valid with bad Arvados SDK version")
335     else
336       assert(job.valid?, "job not valid with good Arvados SDK version")
337       assert_equal(expected, job.arvados_sdk_version)
338     end
339   end
340
341   { "master" => SDK_MASTER,
342     "commit2" => SDK_TAGGED,
343     SDK_TAGGED[0, 8] => SDK_TAGGED,
344     "__nonexistent__" => nil,
345   }.each_pair do |search, commit_hash|
346     test "creating job with SDK version '#{search}'" do
347       check_job_sdk_version(commit_hash) do
348         Job.new(job_attrs(sdk_constraint(search)))
349       end
350     end
351
352     test "updating job from no SDK to version '#{search}'" do
353       job = Job.create!(job_attrs)
354       assert_nil job.arvados_sdk_version
355       check_job_sdk_version(commit_hash) do
356         job.runtime_constraints = sdk_constraint(search)[:runtime_constraints]
357         job
358       end
359     end
360
361     test "updating job from SDK version 'master' to '#{search}'" do
362       job = Job.create!(job_attrs(sdk_constraint("master")))
363       assert_equal(SDK_MASTER, job.arvados_sdk_version)
364       check_job_sdk_version(commit_hash) do
365         job.runtime_constraints = sdk_constraint(search)[:runtime_constraints]
366         job
367       end
368     end
369   end
370
371   test "clear the SDK version" do
372     job = Job.create!(job_attrs(sdk_constraint("master")))
373     assert_equal(SDK_MASTER, job.arvados_sdk_version)
374     job.runtime_constraints = {}
375     assert(job.valid?, "job invalid after clearing SDK version")
376     assert_nil(job.arvados_sdk_version)
377   end
378
379   test "job with SDK constraint, without Docker image is invalid" do
380     sdk_attrs = sdk_constraint("master")
381     sdk_attrs[:runtime_constraints].delete("docker_image")
382     job = Job.create(job_attrs(sdk_attrs))
383     refute(job.valid?, "Job valid with SDK version, without Docker image")
384     sdk_errors = job.errors.messages[:arvados_sdk_version] || []
385     refute_empty(sdk_errors.grep(/\bDocker\b/),
386                  "no Job SDK errors mention that Docker is required")
387   end
388
389   test "invalid to clear Docker image constraint when SDK constraint exists" do
390     job = Job.create!(job_attrs(sdk_constraint("master")))
391     job.runtime_constraints.delete("docker_image")
392     refute(job.valid?,
393            "Job with SDK constraint valid after clearing Docker image")
394   end
395
396   test "can't create job with SDK version assigned directly" do
397     check_creation_prohibited(arvados_sdk_version: SDK_MASTER)
398   end
399
400   test "can't modify job to assign SDK version directly" do
401     check_modification_prohibited(arvados_sdk_version: SDK_MASTER)
402   end
403
404   test "job validation fails when collection uuid found in script_parameters" do
405     bad_params = {
406       script_parameters: {
407         'input' => {
408           'param1' => 'the collection uuid zzzzz-4zz18-012345678901234'
409         }
410       }
411     }
412     assert_raises(ActiveRecord::RecordInvalid,
413                   "created job with a collection uuid in script_parameters") do
414       job = Job.create!(job_attrs(bad_params))
415     end
416   end
417
418   test "job validation succeeds when no collection uuid in script_parameters" do
419     good_params = {
420       script_parameters: {
421         'arg1' => 'foo',
422         'arg2' => [ 'bar', 'baz' ],
423         'arg3' => {
424           'a' => 1,
425           'b' => [2, 3, 4],
426         }
427       }
428     }
429     job = Job.create!(job_attrs(good_params))
430     assert job.valid?
431   end
432
433   test 'update job uuid tag in internal.git when version changes' do
434     authorize_with :active
435     j = jobs :queued
436     j.update_attributes repository: 'active/foo', script_version: 'b1'
437     assert_equal('1de84a854e2b440dc53bf42f8548afa4c17da332',
438                  internal_tag(j.uuid))
439     j.update_attributes repository: 'active/foo', script_version: 'master'
440     assert_equal('077ba2ad3ea24a929091a9e6ce545c93199b8e57',
441                  internal_tag(j.uuid))
442   end
443
444   test 'script_parameters_digest is independent of key order' do
445     j1 = Job.new(job_attrs(script_parameters: {'a' => 'a', 'ddee' => {'d' => 'd', 'e' => 'e'}}))
446     j2 = Job.new(job_attrs(script_parameters: {'ddee' => {'e' => 'e', 'd' => 'd'}, 'a' => 'a'}))
447     assert j1.valid?
448     assert j2.valid?
449     assert_equal(j1.script_parameters_digest, j2.script_parameters_digest)
450   end
451
452   test 'job fixtures have correct script_parameters_digest' do
453     Job.all.each do |j|
454       d = j.script_parameters_digest
455       assert_equal(j.update_script_parameters_digest, d,
456                    "wrong script_parameters_digest for #{j.uuid}")
457     end
458   end
459
460   test 'deep_sort_hash on array of hashes' do
461     a = {'z' => [[{'a' => 'a', 'b' => 'b'}]]}
462     b = {'z' => [[{'b' => 'b', 'a' => 'a'}]]}
463     assert_equal Job.deep_sort_hash(a).to_json, Job.deep_sort_hash(b).to_json
464   end
465
466   test 'find_reusable' do
467     foobar = jobs(:foobar)
468     example_attrs = {
469       script_version: foobar.script_version,
470       script: foobar.script,
471       script_parameters: foobar.script_parameters,
472       repository: foobar.repository,
473     }
474
475     # Two matching jobs exist with identical outputs. The older one
476     # should be reused.
477     j = Job.find_reusable(example_attrs, {}, [], [users(:active)])
478     assert j
479     assert_equal foobar.uuid, j.uuid
480
481     # Two matching jobs exist with different outputs. Neither should
482     # be reused.
483     Job.where(uuid: jobs(:job_with_latest_version).uuid).
484       update_all(output: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+1')
485     assert_nil Job.find_reusable(example_attrs, {}, [], [users(:active)])
486   end
487 end