Merge branch '11017-docker-migration'
[arvados.git] / services / api / test / unit / job_test.rb
1 require 'test_helper'
2 require 'helpers/git_test_helper'
3 require 'helpers/docker_migration_helper'
4
5 class JobTest < ActiveSupport::TestCase
6   include DockerMigrationHelper
7   include GitTestHelper
8
9   BAD_COLLECTION = "#{'f' * 32}+0"
10
11   setup do
12     set_user_from_auth :active
13   end
14
15   def job_attrs merge_me={}
16     # Default (valid) set of attributes, with given overrides
17     {
18       script: "hash",
19       script_version: "master",
20       repository: "active/foo",
21     }.merge(merge_me)
22   end
23
24   test "Job without Docker image doesn't get locator" do
25     job = Job.new job_attrs
26     assert job.valid?, job.errors.full_messages.to_s
27     assert_nil job.docker_image_locator
28   end
29
30   { 'name' => [:links, :docker_image_collection_tag, :name],
31     'hash' => [:links, :docker_image_collection_hash, :name],
32     'locator' => [:collections, :docker_image, :portable_data_hash],
33   }.each_pair do |spec_type, (fixture_type, fixture_name, fixture_attr)|
34     test "Job initialized with Docker image #{spec_type} gets locator" do
35       image_spec = send(fixture_type, fixture_name).send(fixture_attr)
36       job = Job.new job_attrs(runtime_constraints:
37                               {'docker_image' => image_spec})
38       assert job.valid?, job.errors.full_messages.to_s
39       assert_equal(collections(:docker_image).portable_data_hash, job.docker_image_locator)
40     end
41
42     test "Job modified with Docker image #{spec_type} gets locator" do
43       job = Job.new job_attrs
44       assert job.valid?, job.errors.full_messages.to_s
45       assert_nil job.docker_image_locator
46       image_spec = send(fixture_type, fixture_name).send(fixture_attr)
47       job.runtime_constraints['docker_image'] = image_spec
48       assert job.valid?, job.errors.full_messages.to_s
49       assert_equal(collections(:docker_image).portable_data_hash, job.docker_image_locator)
50     end
51   end
52
53   test "removing a Docker runtime constraint removes the locator" do
54     image_locator = collections(:docker_image).portable_data_hash
55     job = Job.new job_attrs(runtime_constraints:
56                             {'docker_image' => image_locator})
57     assert job.valid?, job.errors.full_messages.to_s
58     assert_equal(image_locator, job.docker_image_locator)
59     job.runtime_constraints = {}
60     assert job.valid?, job.errors.full_messages.to_s + "after clearing runtime constraints"
61     assert_nil job.docker_image_locator
62   end
63
64   test "locate a Docker image with a repository + tag" do
65     image_repo, image_tag =
66       links(:docker_image_collection_tag2).name.split(':', 2)
67     job = Job.new job_attrs(runtime_constraints:
68                             {'docker_image' => image_repo,
69                               'docker_image_tag' => image_tag})
70     assert job.valid?, job.errors.full_messages.to_s
71     assert_equal(collections(:docker_image).portable_data_hash, job.docker_image_locator)
72   end
73
74   test "can't locate a Docker image with a nonexistent tag" do
75     image_repo = links(:docker_image_collection_tag).name
76     image_tag = '__nonexistent tag__'
77     job = Job.new job_attrs(runtime_constraints:
78                             {'docker_image' => image_repo,
79                               'docker_image_tag' => image_tag})
80     assert(job.invalid?, "Job with bad Docker tag valid")
81   end
82
83   [
84     false,
85     true
86   ].each do |use_config|
87     test "Job with no Docker image uses default docker image when configuration is set #{use_config}" do
88       default_docker_image = collections(:docker_image)[:portable_data_hash]
89       Rails.configuration.default_docker_image_for_jobs = default_docker_image if use_config
90
91       job = Job.new job_attrs
92       assert job.valid?, job.errors.full_messages.to_s
93
94       if use_config
95         refute_nil job.docker_image_locator
96         assert_equal default_docker_image, job.docker_image_locator
97       else
98         assert_nil job.docker_image_locator
99       end
100     end
101   end
102
103   test "create a job with a disambiguated script_version branch name" do
104     job = Job.
105       new(script: "testscript",
106           script_version: "heads/7387838c69a21827834586cc42b467ff6c63293b",
107           repository: "active/shabranchnames",
108           script_parameters: {})
109     assert(job.save)
110     assert_equal("abec49829bf1758413509b7ffcab32a771b71e81", job.script_version)
111   end
112
113   test "locate a Docker image with a partial hash" do
114     image_hash = links(:docker_image_collection_hash).name[0..24]
115     job = Job.new job_attrs(runtime_constraints:
116                             {'docker_image' => image_hash})
117     assert job.valid?, job.errors.full_messages.to_s + " with partial hash #{image_hash}"
118     assert_equal(collections(:docker_image).portable_data_hash, job.docker_image_locator)
119   end
120
121   { 'name' => 'arvados_test_nonexistent',
122     'hash' => 'f' * 64,
123     'locator' => BAD_COLLECTION,
124   }.each_pair do |spec_type, image_spec|
125     test "Job validation fails with nonexistent Docker image #{spec_type}" do
126       job = Job.new job_attrs(runtime_constraints:
127                               {'docker_image' => image_spec})
128       assert(job.invalid?, "nonexistent Docker image #{spec_type} was valid")
129     end
130   end
131
132   test "Job validation fails with non-Docker Collection constraint" do
133     job = Job.new job_attrs(runtime_constraints:
134                             {'docker_image' => collections(:foo_file).uuid})
135     assert(job.invalid?, "non-Docker Collection constraint was valid")
136   end
137
138   test "can create Job with Docker image Collection without Docker links" do
139     image_uuid = collections(:unlinked_docker_image).portable_data_hash
140     job = Job.new job_attrs(runtime_constraints: {"docker_image" => image_uuid})
141     assert(job.valid?, "Job created with unlinked Docker image was invalid")
142     assert_equal(image_uuid, job.docker_image_locator)
143   end
144
145   def check_attrs_unset(job, attrs)
146     assert_empty(attrs.each_key.map { |key| job.send(key) }.compact,
147                  "job has values for #{attrs.keys}")
148   end
149
150   def check_creation_prohibited(attrs)
151     begin
152       job = Job.new(job_attrs(attrs))
153     rescue ActiveModel::MassAssignmentSecurity::Error
154       # Test passes - expected attribute protection
155     else
156       check_attrs_unset(job, attrs)
157     end
158   end
159
160   def check_modification_prohibited(attrs)
161     job = Job.new(job_attrs)
162     attrs.each_pair do |key, value|
163       assert_raises(NoMethodError) { job.send("{key}=".to_sym, value) }
164     end
165     check_attrs_unset(job, attrs)
166   end
167
168   test "can't create Job with Docker image locator" do
169     check_creation_prohibited(docker_image_locator: BAD_COLLECTION)
170   end
171
172   test "can't assign Docker image locator to Job" do
173     check_modification_prohibited(docker_image_locator: BAD_COLLECTION)
174   end
175
176   [
177    {script_parameters: ""},
178    {script_parameters: []},
179    {script_parameters: {symbols: :are_not_allowed_here}},
180    {runtime_constraints: ""},
181    {runtime_constraints: []},
182    {tasks_summary: ""},
183    {tasks_summary: []},
184    {script_version: "no/branch/could/ever/possibly/have/this/name"},
185   ].each do |invalid_attrs|
186     test "validation failures set error messages: #{invalid_attrs.to_json}" do
187       # Ensure valid_attrs doesn't produce errors -- otherwise we will
188       # not know whether errors reported below are actually caused by
189       # invalid_attrs.
190       Job.create! job_attrs
191
192       job = Job.create job_attrs(invalid_attrs)
193       assert_raises(ActiveRecord::RecordInvalid, ArgumentError,
194                     "save! did not raise the expected exception") do
195         job.save!
196       end
197       assert_not_empty job.errors, "validation failure did not provide errors"
198     end
199   end
200
201   [
202     # Each test case is of the following format
203     # Array of parameters where each parameter is of the format:
204     #  attr name to be changed, attr value, and array of expectations (where each expectation is an array)
205     [['running', false, [['state', 'Queued']]]],
206     [['state', 'Running', [['started_at', 'not_nil']]]],
207     [['is_locked_by_uuid', 'use_current_user_uuid', [['state', 'Queued']]], ['state', 'Running', [['running', true], ['started_at', 'not_nil'], ['success', 'nil']]]],
208     [['running', false, [['state', 'Queued']]], ['state', 'Complete', [['success', true]]]],
209     [['running', true, [['state', 'Running']]], ['cancelled_at', Time.now, [['state', 'Cancelled']]]],
210     [['running', true, [['state', 'Running']]], ['state', 'Cancelled', [['cancelled_at', 'not_nil']]]],
211     [['running', true, [['state', 'Running']]], ['success', true, [['state', 'Complete']]]],
212     [['running', true, [['state', 'Running']]], ['success', false, [['state', 'Failed']]]],
213     [['running', true, [['state', 'Running']]], ['state', 'Complete', [['success', true],['finished_at', 'not_nil']]]],
214     [['running', true, [['state', 'Running']]], ['state', 'Failed', [['success', false],['finished_at', 'not_nil']]]],
215     [['cancelled_at', Time.now, [['state', 'Cancelled']]], ['success', false, [['state', 'Cancelled'],['finished_at', 'nil'], ['cancelled_at', 'not_nil']]]],
216     [['cancelled_at', Time.now, [['state', 'Cancelled'],['running', false]]], ['success', true, [['state', 'Cancelled'],['running', false],['finished_at', 'nil'],['cancelled_at', 'not_nil']]]],
217     # potential migration cases
218     [['state', nil, [['state', 'Queued']]]],
219     [['state', nil, [['state', 'Queued']]], ['cancelled_at', Time.now, [['state', 'Cancelled']]]],
220     [['running', true, [['state', 'Running']]], ['state', nil, [['state', 'Running']]]],
221   ].each do |parameters|
222     test "verify job status #{parameters}" do
223       job = Job.create! job_attrs
224       assert_equal 'Queued', job.state, "job.state"
225
226       parameters.each do |parameter|
227         expectations = parameter[2]
228         if 'use_current_user_uuid' == parameter[1]
229           parameter[1] = Thread.current[:user].uuid
230         end
231
232         if expectations.instance_of? Array
233           job[parameter[0]] = parameter[1]
234           assert_equal true, job.save, job.errors.full_messages.to_s
235           expectations.each do |expectation|
236             if expectation[1] == 'not_nil'
237               assert_not_nil job[expectation[0]], expectation[0]
238             elsif expectation[1] == 'nil'
239               assert_nil job[expectation[0]], expectation[0]
240             else
241               assert_equal expectation[1], job[expectation[0]], expectation[0]
242             end
243           end
244         else
245           raise 'I do not know how to handle this expectation'
246         end
247       end
248     end
249   end
250
251   test "Test job state changes" do
252     all = ["Queued", "Running", "Complete", "Failed", "Cancelled"]
253     valid = {"Queued" => all, "Running" => ["Complete", "Failed", "Cancelled"]}
254     all.each do |start|
255       all.each do |finish|
256         if start != finish
257           job = Job.create! job_attrs(state: start)
258           assert_equal start, job.state
259           job.state = finish
260           job.save
261           job.reload
262           if valid[start] and valid[start].include? finish
263             assert_equal finish, job.state
264           else
265             assert_equal start, job.state
266           end
267         end
268       end
269     end
270   end
271
272   test "Test job locking" do
273     set_user_from_auth :active_trustedclient
274     job = Job.create! job_attrs
275
276     assert_equal "Queued", job.state
277
278     # Should be able to lock successfully
279     job.lock current_user.uuid
280     assert_equal "Running", job.state
281
282     assert_raises ArvadosModel::AlreadyLockedError do
283       # Can't lock it again
284       job.lock current_user.uuid
285     end
286     job.reload
287     assert_equal "Running", job.state
288
289     set_user_from_auth :project_viewer
290     assert_raises ArvadosModel::AlreadyLockedError do
291       # Can't lock it as a different user either
292       job.lock current_user.uuid
293     end
294     job.reload
295     assert_equal "Running", job.state
296
297     assert_raises ArvadosModel::PermissionDeniedError do
298       # Can't update fields as a different user
299       job.update_attributes(state: "Failed")
300     end
301     job.reload
302     assert_equal "Running", job.state
303
304
305     set_user_from_auth :active_trustedclient
306
307     # Can update fields as the locked_by user
308     job.update_attributes(state: "Failed")
309     assert_equal "Failed", job.state
310   end
311
312   test "admin user can cancel a running job despite lock" do
313     set_user_from_auth :active_trustedclient
314     job = Job.create! job_attrs
315     job.lock current_user.uuid
316     assert_equal Job::Running, job.state
317
318     set_user_from_auth :spectator
319     assert_raises do
320       job.update_attributes!(state: Job::Cancelled)
321     end
322
323     set_user_from_auth :admin
324     job.reload
325     assert_equal Job::Running, job.state
326     job.update_attributes!(state: Job::Cancelled)
327     assert_equal Job::Cancelled, job.state
328   end
329
330   test "verify job queue position" do
331     job1 = Job.create! job_attrs
332     assert_equal 'Queued', job1.state, "Incorrect job state for newly created job1"
333
334     job2 = Job.create! job_attrs
335     assert_equal 'Queued', job2.state, "Incorrect job state for newly created job2"
336
337     assert_not_nil job1.queue_position, "Expected non-nil queue position for job1"
338     assert_not_nil job2.queue_position, "Expected non-nil queue position for job2"
339   end
340
341   SDK_MASTER = "ca68b24e51992e790f29df5cc4bc54ce1da4a1c2"
342   SDK_TAGGED = "00634b2b8a492d6f121e3cf1d6587b821136a9a7"
343
344   def sdk_constraint(version)
345     {runtime_constraints: {
346         "arvados_sdk_version" => version,
347         "docker_image" => links(:docker_image_collection_tag).name,
348       }}
349   end
350
351   def check_job_sdk_version(expected)
352     job = yield
353     if expected.nil?
354       refute(job.valid?, "job valid with bad Arvados SDK version")
355     else
356       assert(job.valid?, "job not valid with good Arvados SDK version")
357       assert_equal(expected, job.arvados_sdk_version)
358     end
359   end
360
361   { "master" => SDK_MASTER,
362     "commit2" => SDK_TAGGED,
363     SDK_TAGGED[0, 8] => SDK_TAGGED,
364     "__nonexistent__" => nil,
365   }.each_pair do |search, commit_hash|
366     test "creating job with SDK version '#{search}'" do
367       check_job_sdk_version(commit_hash) do
368         Job.new(job_attrs(sdk_constraint(search)))
369       end
370     end
371
372     test "updating job from no SDK to version '#{search}'" do
373       job = Job.create!(job_attrs)
374       assert_nil job.arvados_sdk_version
375       check_job_sdk_version(commit_hash) do
376         job.runtime_constraints = sdk_constraint(search)[:runtime_constraints]
377         job
378       end
379     end
380
381     test "updating job from SDK version 'master' to '#{search}'" do
382       job = Job.create!(job_attrs(sdk_constraint("master")))
383       assert_equal(SDK_MASTER, job.arvados_sdk_version)
384       check_job_sdk_version(commit_hash) do
385         job.runtime_constraints = sdk_constraint(search)[:runtime_constraints]
386         job
387       end
388     end
389   end
390
391   test "clear the SDK version" do
392     job = Job.create!(job_attrs(sdk_constraint("master")))
393     assert_equal(SDK_MASTER, job.arvados_sdk_version)
394     job.runtime_constraints = {}
395     assert(job.valid?, "job invalid after clearing SDK version")
396     assert_nil(job.arvados_sdk_version)
397   end
398
399   test "job with SDK constraint, without Docker image is invalid" do
400     sdk_attrs = sdk_constraint("master")
401     sdk_attrs[:runtime_constraints].delete("docker_image")
402     job = Job.create(job_attrs(sdk_attrs))
403     refute(job.valid?, "Job valid with SDK version, without Docker image")
404     sdk_errors = job.errors.messages[:arvados_sdk_version] || []
405     refute_empty(sdk_errors.grep(/\bDocker\b/),
406                  "no Job SDK errors mention that Docker is required")
407   end
408
409   test "invalid to clear Docker image constraint when SDK constraint exists" do
410     job = Job.create!(job_attrs(sdk_constraint("master")))
411     job.runtime_constraints.delete("docker_image")
412     refute(job.valid?,
413            "Job with SDK constraint valid after clearing Docker image")
414   end
415
416   test "use migrated docker image if requesting old-format image by tag" do
417     Rails.configuration.docker_image_formats = ['v2']
418     add_docker19_migration_link
419     job = Job.create!(
420       job_attrs(
421         script: 'foo',
422         runtime_constraints: {
423           'docker_image' => links(:docker_image_collection_tag).name}))
424     assert(job.valid?)
425     assert_equal(job.docker_image_locator, collections(:docker_image_1_12).portable_data_hash)
426   end
427
428   test "use migrated docker image if requesting old-format image by pdh" do
429     Rails.configuration.docker_image_formats = ['v2']
430     add_docker19_migration_link
431     job = Job.create!(
432       job_attrs(
433         script: 'foo',
434         runtime_constraints: {
435           'docker_image' => collections(:docker_image).portable_data_hash}))
436     assert(job.valid?)
437     assert_equal(job.docker_image_locator, collections(:docker_image_1_12).portable_data_hash)
438   end
439
440   [[:docker_image, :docker_image, :docker_image_1_12],
441    [:docker_image_1_12, :docker_image, :docker_image_1_12],
442    [:docker_image, :docker_image_1_12, :docker_image_1_12],
443    [:docker_image_1_12, :docker_image_1_12, :docker_image_1_12],
444   ].each do |existing_image, request_image, expect_image|
445     test "if a #{existing_image} job exists, #{request_image} yields #{expect_image} after migration" do
446       Rails.configuration.docker_image_formats = ['v1']
447       oldjob = Job.create!(
448         job_attrs(
449           script: 'foobar1',
450           runtime_constraints: {
451             'docker_image' => collections(existing_image).portable_data_hash}))
452       oldjob.reload
453       assert_equal(oldjob.docker_image_locator,
454                    collections(existing_image).portable_data_hash)
455
456       Rails.configuration.docker_image_formats = ['v2']
457       add_docker19_migration_link
458
459       newjob = Job.create!(
460         job_attrs(
461           script: 'foobar1',
462           runtime_constraints: {
463             'docker_image' => collections(request_image).portable_data_hash}))
464       newjob.reload
465       assert_equal(newjob.docker_image_locator,
466                    collections(expect_image).portable_data_hash)
467     end
468   end
469
470   test "can't create job with SDK version assigned directly" do
471     check_creation_prohibited(arvados_sdk_version: SDK_MASTER)
472   end
473
474   test "can't modify job to assign SDK version directly" do
475     check_modification_prohibited(arvados_sdk_version: SDK_MASTER)
476   end
477
478   test "job validation fails when collection uuid found in script_parameters" do
479     bad_params = {
480       script_parameters: {
481         'input' => {
482           'param1' => 'the collection uuid zzzzz-4zz18-012345678901234'
483         }
484       }
485     }
486     assert_raises(ActiveRecord::RecordInvalid,
487                   "created job with a collection uuid in script_parameters") do
488       Job.create!(job_attrs(bad_params))
489     end
490   end
491
492   test "job validation succeeds when no collection uuid in script_parameters" do
493     good_params = {
494       script_parameters: {
495         'arg1' => 'foo',
496         'arg2' => [ 'bar', 'baz' ],
497         'arg3' => {
498           'a' => 1,
499           'b' => [2, 3, 4],
500         }
501       }
502     }
503     job = Job.create!(job_attrs(good_params))
504     assert job.valid?
505   end
506
507   test 'update job uuid tag in internal.git when version changes' do
508     authorize_with :active
509     j = jobs :queued
510     j.update_attributes repository: 'active/foo', script_version: 'b1'
511     assert_equal('1de84a854e2b440dc53bf42f8548afa4c17da332',
512                  internal_tag(j.uuid))
513     j.update_attributes repository: 'active/foo', script_version: 'master'
514     assert_equal('077ba2ad3ea24a929091a9e6ce545c93199b8e57',
515                  internal_tag(j.uuid))
516   end
517
518   test 'script_parameters_digest is independent of key order' do
519     j1 = Job.new(job_attrs(script_parameters: {'a' => 'a', 'ddee' => {'d' => 'd', 'e' => 'e'}}))
520     j2 = Job.new(job_attrs(script_parameters: {'ddee' => {'e' => 'e', 'd' => 'd'}, 'a' => 'a'}))
521     assert j1.valid?
522     assert j2.valid?
523     assert_equal(j1.script_parameters_digest, j2.script_parameters_digest)
524   end
525
526   test 'job fixtures have correct script_parameters_digest' do
527     Job.all.each do |j|
528       d = j.script_parameters_digest
529       assert_equal(j.update_script_parameters_digest, d,
530                    "wrong script_parameters_digest for #{j.uuid}")
531     end
532   end
533
534   test 'deep_sort_hash on array of hashes' do
535     a = {'z' => [[{'a' => 'a', 'b' => 'b'}]]}
536     b = {'z' => [[{'b' => 'b', 'a' => 'a'}]]}
537     assert_equal Job.deep_sort_hash(a).to_json, Job.deep_sort_hash(b).to_json
538   end
539
540   test 'find_reusable' do
541     foobar = jobs(:foobar)
542     example_attrs = {
543       script_version: foobar.script_version,
544       script: foobar.script,
545       script_parameters: foobar.script_parameters,
546       repository: foobar.repository,
547     }
548
549     # Two matching jobs exist with identical outputs. The older one
550     # should be reused.
551     j = Job.find_reusable(example_attrs, {}, [], [users(:active)])
552     assert j
553     assert_equal foobar.uuid, j.uuid
554
555     # Two matching jobs exist with different outputs. Neither should
556     # be reused.
557     Job.where(uuid: jobs(:job_with_latest_version).uuid).
558       update_all(output: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+1')
559     assert_nil Job.find_reusable(example_attrs, {}, [], [users(:active)])
560   end
561
562   [
563     true,
564     false,
565   ].each do |cascade|
566     test "cancel job with cascade #{cascade}" do
567       job = Job.find_by_uuid jobs(:running_job_with_components_at_level_1).uuid
568       job.cancel cascade: cascade
569       assert_equal Job::Cancelled, job.state
570
571       descendents = ['zzzzz-8i9sb-jobcomponentsl2',
572                      'zzzzz-d1hrv-picomponentsl02',
573                      'zzzzz-8i9sb-job1atlevel3noc',
574                      'zzzzz-8i9sb-job2atlevel3noc']
575
576       jobs = Job.where(uuid: descendents)
577       jobs.each do |j|
578         assert_equal ('Cancelled' == j.state), cascade
579       end
580
581       pipelines = PipelineInstance.where(uuid: descendents)
582       pipelines.each do |pi|
583         assert_equal ('Paused' == pi.state), cascade
584       end
585     end
586   end
587
588   test 'cancelling a completed job raises error' do
589     job = Job.find_by_uuid jobs(:job_with_latest_version).uuid
590     assert job
591     assert_equal 'Complete', job.state
592
593     assert_raises(ArvadosModel::InvalidStateTransitionError) do
594       job.cancel
595     end
596   end
597
598   test 'cancelling a job with circular relationship with another does not result in an infinite loop' do
599     job = Job.find_by_uuid jobs(:running_job_2_with_circular_component_relationship).uuid
600
601     job.cancel cascade: true
602
603     assert_equal Job::Cancelled, job.state
604
605     child = Job.find_by_uuid job.components.collect{|_, uuid| uuid}[0]
606     assert_equal Job::Cancelled, child.state
607   end
608 end