Merge branch '6429-crunch2-api' closes #6429
authorPeter Amstutz <peter.amstutz@curoverse.com>
Mon, 14 Dec 2015 20:22:39 +0000 (15:22 -0500)
committerPeter Amstutz <peter.amstutz@curoverse.com>
Mon, 14 Dec 2015 20:22:39 +0000 (15:22 -0500)
15 files changed:
apps/workbench/app/controllers/container_requests_controller.rb [new file with mode: 0644]
apps/workbench/app/controllers/containers_controller.rb [new file with mode: 0644]
apps/workbench/app/models/container.rb [new file with mode: 0644]
apps/workbench/app/models/container_request.rb [new file with mode: 0644]
apps/workbench/config/routes.rb
services/api/app/controllers/arvados/v1/container_requests_controller.rb [new file with mode: 0644]
services/api/app/controllers/arvados/v1/containers_controller.rb [new file with mode: 0644]
services/api/app/models/container.rb [new file with mode: 0644]
services/api/app/models/container_request.rb [new file with mode: 0644]
services/api/config/routes.rb
services/api/db/migrate/20151202151426_create_containers_and_requests.rb [new file with mode: 0644]
services/api/db/structure.sql
services/api/lib/whitelist_update.rb [new file with mode: 0644]
services/api/test/unit/container_request_test.rb [new file with mode: 0644]
services/api/test/unit/container_test.rb [new file with mode: 0644]

diff --git a/apps/workbench/app/controllers/container_requests_controller.rb b/apps/workbench/app/controllers/container_requests_controller.rb
new file mode 100644 (file)
index 0000000..94cc513
--- /dev/null
@@ -0,0 +1,2 @@
+class ContainerRequestsController < ApplicationController
+end
diff --git a/apps/workbench/app/controllers/containers_controller.rb b/apps/workbench/app/controllers/containers_controller.rb
new file mode 100644 (file)
index 0000000..57c2700
--- /dev/null
@@ -0,0 +1,2 @@
+class ContainersController < ApplicationController
+end
diff --git a/apps/workbench/app/models/container.rb b/apps/workbench/app/models/container.rb
new file mode 100644 (file)
index 0000000..bee82ef
--- /dev/null
@@ -0,0 +1,3 @@
+class Container < ArvadosBase
+
+end
diff --git a/apps/workbench/app/models/container_request.rb b/apps/workbench/app/models/container_request.rb
new file mode 100644 (file)
index 0000000..73609c5
--- /dev/null
@@ -0,0 +1,3 @@
+class ContainerRequest < ArvadosBase
+
+end
index 487fb3fdd329a06e13bfa35216de0517e9822c6c..10426099937e2bdda42b6f7fb0976c0ada764a51 100644 (file)
@@ -17,6 +17,8 @@ ArvadosWorkbench::Application.routes.draw do
   resources :traits
   resources :api_client_authorizations
   resources :virtual_machines
+  resources :containers
+  resources :container_requests
   get '/virtual_machines/:id/webshell/:login' => 'virtual_machines#webshell', :as => :webshell_virtual_machine
   resources :authorized_keys
   resources :job_tasks
diff --git a/services/api/app/controllers/arvados/v1/container_requests_controller.rb b/services/api/app/controllers/arvados/v1/container_requests_controller.rb
new file mode 100644 (file)
index 0000000..fe4696e
--- /dev/null
@@ -0,0 +1,6 @@
+class Arvados::V1::ContainerRequestsController < ApplicationController
+  accept_attribute_as_json :environment, Hash
+  accept_attribute_as_json :mounts, Hash
+  accept_attribute_as_json :runtime_constraints, Hash
+  accept_attribute_as_json :command, Array
+end
diff --git a/services/api/app/controllers/arvados/v1/containers_controller.rb b/services/api/app/controllers/arvados/v1/containers_controller.rb
new file mode 100644 (file)
index 0000000..04a5ed0
--- /dev/null
@@ -0,0 +1,7 @@
+class Arvados::V1::ContainersController < ApplicationController
+  accept_attribute_as_json :environment, Hash
+  accept_attribute_as_json :mounts, Hash
+  accept_attribute_as_json :runtime_constraints, Hash
+  accept_attribute_as_json :command, Array
+
+end
diff --git a/services/api/app/models/container.rb b/services/api/app/models/container.rb
new file mode 100644 (file)
index 0000000..48f4916
--- /dev/null
@@ -0,0 +1,171 @@
+require 'whitelist_update'
+
+class Container < ArvadosModel
+  include HasUuid
+  include KindAndEtag
+  include CommonApiTemplate
+  include WhitelistUpdate
+
+  serialize :environment, Hash
+  serialize :mounts, Hash
+  serialize :runtime_constraints, Hash
+  serialize :command, Array
+
+  before_validation :fill_field_defaults, :if => :new_record?
+  before_validation :set_timestamps
+  validates :command, :container_image, :output_path, :cwd, :priority, :presence => true
+  validate :validate_state_change
+  validate :validate_change
+  after_save :handle_completed
+
+  has_many :container_requests, :foreign_key => :container_uuid, :class_name => 'ContainerRequest', :primary_key => :uuid
+
+  api_accessible :user, extend: :common do |t|
+    t.add :command
+    t.add :container_image
+    t.add :cwd
+    t.add :environment
+    t.add :finished_at
+    t.add :log
+    t.add :mounts
+    t.add :output
+    t.add :output_path
+    t.add :priority
+    t.add :progress
+    t.add :runtime_constraints
+    t.add :started_at
+    t.add :state
+  end
+
+  # Supported states for a container
+  States =
+    [
+     (Queued = 'Queued'),
+     (Running = 'Running'),
+     (Complete = 'Complete'),
+     (Cancelled = 'Cancelled')
+    ]
+
+  State_transitions = {
+    nil => [Queued],
+    Queued => [Running, Cancelled],
+    Running => [Complete, Cancelled]
+  }
+
+  def state_transitions
+    State_transitions
+  end
+
+  def update_priority!
+    if [Queued, Running].include? self.state
+      # Update the priority of this container to the maximum priority of any of
+      # its committed container requests and save the record.
+      max = 0
+      ContainerRequest.where(container_uuid: uuid).each do |cr|
+        if cr.state == ContainerRequest::Committed and cr.priority > max
+          max = cr.priority
+        end
+      end
+      self.priority = max
+      self.save!
+    end
+  end
+
+  protected
+
+  def fill_field_defaults
+    self.state ||= Queued
+    self.environment ||= {}
+    self.runtime_constraints ||= {}
+    self.mounts ||= {}
+    self.cwd ||= "."
+    self.priority ||= 1
+  end
+
+  def permission_to_create
+    current_user.andand.is_admin
+  end
+
+  def permission_to_update
+    current_user.andand.is_admin
+  end
+
+  def set_timestamps
+    if self.state_changed? and self.state == Running
+      self.started_at ||= db_current_time
+    end
+
+    if self.state_changed? and [Complete, Cancelled].include? self.state
+      self.finished_at ||= db_current_time
+    end
+  end
+
+  def validate_change
+    permitted = []
+
+    if self.new_record?
+      permitted.push :owner_uuid, :command, :container_image, :cwd, :environment,
+                     :mounts, :output_path, :priority, :runtime_constraints, :state
+    end
+
+    case self.state
+    when Queued
+      # permit priority change only.
+      permitted.push :priority
+
+    when Running
+      if self.state_changed?
+        # At point of state change, can set state and started_at
+        permitted.push :state, :started_at
+      else
+        # While running, can update priority and progress.
+        permitted.push :priority, :progress
+      end
+
+    when Complete
+      if self.state_changed?
+        permitted.push :state, :finished_at, :output, :log
+      else
+        errors.add :state, "cannot update record"
+      end
+
+    when Cancelled
+      if self.state_changed?
+        if self.state_was == Running
+          permitted.push :state, :finished_at, :output, :log
+        elsif self.state_was == Queued
+          permitted.push :state, :finished_at
+        end
+      else
+        errors.add :state, "cannot update record"
+      end
+
+    else
+      errors.add :state, "invalid state"
+    end
+
+    check_update_whitelist permitted
+  end
+
+  def handle_completed
+    # This container is finished so finalize any associated container requests
+    # that are associated with this container.
+    if self.state_changed? and [Complete, Cancelled].include? self.state
+      act_as_system_user do
+        # Notify container requests associated with this container
+        ContainerRequest.where(container_uuid: uuid,
+                               :state => ContainerRequest::Committed).each do |cr|
+          cr.container_completed!
+        end
+
+        # Try to cancel any outstanding container requests made by this container.
+        ContainerRequest.where(requesting_container_uuid: uuid,
+                               :state => ContainerRequest::Committed).each do |cr|
+          cr.priority = 0
+          cr.save
+        end
+      end
+    end
+  end
+
+end
diff --git a/services/api/app/models/container_request.rb b/services/api/app/models/container_request.rb
new file mode 100644 (file)
index 0000000..acb751c
--- /dev/null
@@ -0,0 +1,175 @@
+require 'whitelist_update'
+
+class ContainerRequest < ArvadosModel
+  include HasUuid
+  include KindAndEtag
+  include CommonApiTemplate
+  include WhitelistUpdate
+
+  serialize :properties, Hash
+  serialize :environment, Hash
+  serialize :mounts, Hash
+  serialize :runtime_constraints, Hash
+  serialize :command, Array
+
+  before_validation :fill_field_defaults, :if => :new_record?
+  before_validation :set_container
+  validates :command, :container_image, :output_path, :cwd, :presence => true
+  validate :validate_state_change
+  validate :validate_change
+  after_save :update_priority
+
+  api_accessible :user, extend: :common do |t|
+    t.add :command
+    t.add :container_count_max
+    t.add :container_image
+    t.add :container_uuid
+    t.add :cwd
+    t.add :description
+    t.add :environment
+    t.add :expires_at
+    t.add :filters
+    t.add :mounts
+    t.add :name
+    t.add :output_path
+    t.add :priority
+    t.add :properties
+    t.add :requesting_container_uuid
+    t.add :runtime_constraints
+    t.add :state
+  end
+
+  # Supported states for a container request
+  States =
+    [
+     (Uncommitted = 'Uncommitted'),
+     (Committed = 'Committed'),
+     (Final = 'Final'),
+    ]
+
+  State_transitions = {
+    nil => [Uncommitted, Committed],
+    Uncommitted => [Committed],
+    Committed => [Final]
+  }
+
+  def state_transitions
+    State_transitions
+  end
+
+  def skip_uuid_read_permission_check
+    # XXX temporary until permissions are sorted out.
+    %w(modified_by_client_uuid container_uuid requesting_container_uuid)
+  end
+
+  def container_completed!
+    # may implement retry logic here in the future.
+    self.state = ContainerRequest::Final
+    self.save!
+  end
+
+  protected
+
+  def fill_field_defaults
+    self.state ||= Uncommitted
+    self.environment ||= {}
+    self.runtime_constraints ||= {}
+    self.mounts ||= {}
+    self.cwd ||= "."
+  end
+
+  # Turn a container request into a container.
+  def resolve
+    # In the future this will do things like resolve symbolic git and keep
+    # references to content addresses.
+    Container.create!({ :command => self.command,
+                        :container_image => self.container_image,
+                        :cwd => self.cwd,
+                        :environment => self.environment,
+                        :mounts => self.mounts,
+                        :output_path => self.output_path,
+                        :runtime_constraints => self.runtime_constraints })
+  end
+
+  def set_container
+    if self.container_uuid_changed?
+      if not current_user.andand.is_admin and not self.container_uuid.nil?
+        errors.add :container_uuid, "can only be updated to nil."
+      end
+    else
+      if self.state_changed?
+        if self.state == Committed and (self.state_was == Uncommitted or self.state_was.nil?)
+          act_as_system_user do
+            self.container_uuid = self.resolve.andand.uuid
+          end
+        end
+      end
+    end
+  end
+
+  def validate_change
+    permitted = [:owner_uuid]
+
+    case self.state
+    when Uncommitted
+      # Permit updating most fields
+      permitted.push :command, :container_count_max,
+                     :container_image, :cwd, :description, :environment,
+                     :filters, :mounts, :name, :output_path, :priority,
+                     :properties, :requesting_container_uuid, :runtime_constraints,
+                     :state, :container_uuid
+
+    when Committed
+      if container_uuid.nil?
+        errors.add :container_uuid, "has not been resolved to a container."
+      end
+
+      if priority.nil?
+        errors.add :priority, "cannot be nil"
+      end
+
+      # Can update priority, container count.
+      permitted.push :priority, :container_count_max, :container_uuid
+
+      if self.state_changed?
+        # Allow create-and-commit in a single operation.
+        permitted.push :command, :container_image, :cwd, :description, :environment,
+                       :filters, :mounts, :name, :output_path, :properties,
+                       :requesting_container_uuid, :runtime_constraints,
+                       :state, :container_uuid
+      end
+
+    when Final
+      if not current_user.andand.is_admin
+        errors.add :state, "of container request can only be set to Final by system."
+      end
+
+      if self.state_changed?
+          permitted.push :state
+      else
+        errors.add :state, "does not allow updates"
+      end
+
+    else
+      errors.add :state, "invalid value"
+    end
+
+    check_update_whitelist permitted
+  end
+
+  def update_priority
+    if [Committed, Final].include? self.state and (self.state_changed? or
+                                                   self.priority_changed? or
+                                                   self.container_uuid_changed?)
+      [self.container_uuid_was, self.container_uuid].each do |cuuid|
+        unless cuuid.nil?
+          c = Container.find_by_uuid cuuid
+          act_as_system_user do
+            c.update_priority!
+          end
+        end
+      end
+    end
+  end
+
+end
index 27fd67cece046268f449a8cc18ad35b30d128b44..c85a3fc57af20d2461516c384623a452a86dbdda 100644 (file)
@@ -28,6 +28,8 @@ Server::Application.routes.draw do
       end
       resources :humans
       resources :job_tasks
+      resources :containers
+      resources :container_requests
       resources :jobs do
         get 'queue', on: :collection
         get 'queue_size', on: :collection
diff --git a/services/api/db/migrate/20151202151426_create_containers_and_requests.rb b/services/api/db/migrate/20151202151426_create_containers_and_requests.rb
new file mode 100644 (file)
index 0000000..4741515
--- /dev/null
@@ -0,0 +1,59 @@
+class CreateContainersAndRequests < ActiveRecord::Migration
+  def change
+    create_table :containers do |t|
+      t.string :uuid
+      t.string :owner_uuid
+      t.datetime :created_at
+      t.datetime :modified_at
+      t.string :modified_by_client_uuid
+      t.string :modified_by_user_uuid
+      t.string :state
+      t.datetime :started_at
+      t.datetime :finished_at
+      t.string :log
+      t.text :environment
+      t.string :cwd
+      t.text :command
+      t.string :output_path
+      t.text :mounts
+      t.text :runtime_constraints
+      t.string :output
+      t.string :container_image
+      t.float :progress
+      t.integer :priority
+
+      t.timestamps
+    end
+
+    create_table :container_requests do |t|
+      t.string :uuid
+      t.string :owner_uuid
+      t.datetime :created_at
+      t.datetime :modified_at
+      t.string :modified_by_client_uuid
+      t.string :modified_by_user_uuid
+      t.string :name
+      t.text :description
+      t.text :properties
+      t.string :state
+      t.string :requesting_container_uuid
+      t.string :container_uuid
+      t.integer :container_count_max
+      t.text :mounts
+      t.text :runtime_constraints
+      t.string :container_image
+      t.text :environment
+      t.string :cwd
+      t.text :command
+      t.string :output_path
+      t.integer :priority
+      t.datetime :expires_at
+      t.text :filters
+
+      t.timestamps
+    end
+
+    add_index :containers, :uuid, :unique => true
+    add_index :container_requests, :uuid, :unique => true
+  end
+end
index 01bb4172f1963cb12ddc557e2904eb021237a10d..1ce44cde4ce4b3c3f1a6c36302e5ba9196058daa 100644 (file)
@@ -259,6 +259,107 @@ CREATE SEQUENCE commits_id_seq
 ALTER SEQUENCE commits_id_seq OWNED BY commits.id;
 
 
+--
+-- Name: container_requests; Type: TABLE; Schema: public; Owner: -; Tablespace: 
+--
+
+CREATE TABLE container_requests (
+    id integer NOT NULL,
+    uuid character varying(255),
+    owner_uuid character varying(255),
+    created_at timestamp without time zone NOT NULL,
+    modified_at timestamp without time zone,
+    modified_by_client_uuid character varying(255),
+    modified_by_user_uuid character varying(255),
+    name character varying(255),
+    description text,
+    properties text,
+    state character varying(255),
+    requesting_container_uuid character varying(255),
+    container_uuid character varying(255),
+    container_count_max integer,
+    mounts text,
+    runtime_constraints text,
+    container_image character varying(255),
+    environment text,
+    cwd character varying(255),
+    command text,
+    output_path character varying(255),
+    priority integer,
+    expires_at timestamp without time zone,
+    filters text,
+    updated_at timestamp without time zone NOT NULL
+);
+
+
+--
+-- Name: container_requests_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE container_requests_id_seq
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+
+--
+-- Name: container_requests_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE container_requests_id_seq OWNED BY container_requests.id;
+
+
+--
+-- Name: containers; Type: TABLE; Schema: public; Owner: -; Tablespace: 
+--
+
+CREATE TABLE containers (
+    id integer NOT NULL,
+    uuid character varying(255),
+    owner_uuid character varying(255),
+    created_at timestamp without time zone NOT NULL,
+    modified_at timestamp without time zone,
+    modified_by_client_uuid character varying(255),
+    modified_by_user_uuid character varying(255),
+    state character varying(255),
+    started_at timestamp without time zone,
+    finished_at timestamp without time zone,
+    log character varying(255),
+    environment text,
+    cwd character varying(255),
+    command text,
+    output_path character varying(255),
+    mounts text,
+    runtime_constraints text,
+    output character varying(255),
+    container_image character varying(255),
+    progress double precision,
+    priority integer,
+    updated_at timestamp without time zone NOT NULL
+);
+
+
+--
+-- Name: containers_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE containers_id_seq
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+
+--
+-- Name: containers_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE containers_id_seq OWNED BY containers.id;
+
+
 --
 -- Name: groups; Type: TABLE; Schema: public; Owner: -; Tablespace: 
 --
@@ -990,6 +1091,20 @@ ALTER TABLE ONLY commit_ancestors ALTER COLUMN id SET DEFAULT nextval('commit_an
 ALTER TABLE ONLY commits ALTER COLUMN id SET DEFAULT nextval('commits_id_seq'::regclass);
 
 
+--
+-- Name: id; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY container_requests ALTER COLUMN id SET DEFAULT nextval('container_requests_id_seq'::regclass);
+
+
+--
+-- Name: id; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY containers ALTER COLUMN id SET DEFAULT nextval('containers_id_seq'::regclass);
+
+
 --
 -- Name: id; Type: DEFAULT; Schema: public; Owner: -
 --
@@ -1150,6 +1265,22 @@ ALTER TABLE ONLY commits
     ADD CONSTRAINT commits_pkey PRIMARY KEY (id);
 
 
+--
+-- Name: container_requests_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: 
+--
+
+ALTER TABLE ONLY container_requests
+    ADD CONSTRAINT container_requests_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: containers_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: 
+--
+
+ALTER TABLE ONLY containers
+    ADD CONSTRAINT containers_pkey PRIMARY KEY (id);
+
+
 --
 -- Name: groups_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: 
 --
@@ -1467,6 +1598,20 @@ CREATE UNIQUE INDEX index_commit_ancestors_on_descendant_and_ancestor ON commit_
 CREATE UNIQUE INDEX index_commits_on_repository_name_and_sha1 ON commits USING btree (repository_name, sha1);
 
 
+--
+-- Name: index_container_requests_on_uuid; Type: INDEX; Schema: public; Owner: -; Tablespace: 
+--
+
+CREATE UNIQUE INDEX index_container_requests_on_uuid ON container_requests USING btree (uuid);
+
+
+--
+-- Name: index_containers_on_uuid; Type: INDEX; Schema: public; Owner: -; Tablespace: 
+--
+
+CREATE UNIQUE INDEX index_containers_on_uuid ON containers USING btree (uuid);
+
+
 --
 -- Name: index_groups_on_created_at; Type: INDEX; Schema: public; Owner: -; Tablespace: 
 --
@@ -2381,4 +2526,6 @@ INSERT INTO schema_migrations (version) VALUES ('20150423145759');
 
 INSERT INTO schema_migrations (version) VALUES ('20150512193020');
 
-INSERT INTO schema_migrations (version) VALUES ('20150526180251');
\ No newline at end of file
+INSERT INTO schema_migrations (version) VALUES ('20150526180251');
+
+INSERT INTO schema_migrations (version) VALUES ('20151202151426');
\ No newline at end of file
diff --git a/services/api/lib/whitelist_update.rb b/services/api/lib/whitelist_update.rb
new file mode 100644 (file)
index 0000000..a81f992
--- /dev/null
@@ -0,0 +1,18 @@
+module WhitelistUpdate
+  def check_update_whitelist permitted_fields
+    attribute_names.each do |field|
+      if not permitted_fields.include? field.to_sym and self.send((field.to_s + "_changed?").to_sym)
+        errors.add field, "illegal update of field"
+      end
+    end
+  end
+
+  def validate_state_change
+    if self.state_changed?
+      unless state_transitions[self.state_was].andand.include? self.state
+        errors.add :state, "invalid state change from #{self.state_was} to #{self.state}"
+        return false
+      end
+    end
+  end
+end
diff --git a/services/api/test/unit/container_request_test.rb b/services/api/test/unit/container_request_test.rb
new file mode 100644 (file)
index 0000000..7f4206b
--- /dev/null
@@ -0,0 +1,368 @@
+require 'test_helper'
+
+class ContainerRequestTest < ActiveSupport::TestCase
+  def check_illegal_modify c
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.command = ["echo", "bar"]
+        c.save!
+      end
+
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.container_image = "img2"
+        c.save!
+      end
+
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.cwd = "/tmp2"
+        c.save!
+      end
+
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.environment = {"FOO" => "BAR"}
+        c.save!
+      end
+
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.mounts = {"FOO" => "BAR"}
+        c.save!
+      end
+
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.output_path = "/tmp3"
+        c.save!
+      end
+
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.runtime_constraints = {"FOO" => "BAR"}
+        c.save!
+      end
+
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.name = "baz"
+        c.save!
+      end
+
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.description = "baz"
+        c.save!
+      end
+
+  end
+
+  def check_bogus_states c
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.state = nil
+        c.save!
+      end
+
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.state = "Flubber"
+        c.save!
+      end
+  end
+
+  test "Container request create" do
+    set_user_from_auth :active_trustedclient
+    cr = ContainerRequest.new
+    cr.command = ["echo", "foo"]
+    cr.container_image = "img"
+    cr.cwd = "/tmp"
+    cr.environment = {}
+    cr.mounts = {"BAR" => "FOO"}
+    cr.output_path = "/tmpout"
+    cr.runtime_constraints = {}
+    cr.name = "foo"
+    cr.description = "bar"
+    cr.save!
+
+    assert_nil cr.container_uuid
+    assert_nil cr.priority
+
+    check_bogus_states cr
+
+    cr.reload
+    cr.command = ["echo", "foo3"]
+    cr.container_image = "img3"
+    cr.cwd = "/tmp3"
+    cr.environment = {"BUP" => "BOP"}
+    cr.mounts = {"BAR" => "BAZ"}
+    cr.output_path = "/tmp4"
+    cr.priority = 2
+    cr.runtime_constraints = {"X" => "Y"}
+    cr.name = "foo3"
+    cr.description = "bar3"
+    cr.save!
+
+    assert_nil cr.container_uuid
+  end
+
+  test "Container request priority must be non-nil" do
+    set_user_from_auth :active_trustedclient
+    cr = ContainerRequest.new
+    cr.command = ["echo", "foo"]
+    cr.container_image = "img"
+    cr.cwd = "/tmp"
+    cr.environment = {}
+    cr.mounts = {"BAR" => "FOO"}
+    cr.output_path = "/tmpout"
+    cr.runtime_constraints = {}
+    cr.name = "foo"
+    cr.description = "bar"
+    cr.save!
+
+    cr.reload
+    cr.state = "Committed"
+    assert_raises(ActiveRecord::RecordInvalid) do
+      cr.save!
+    end
+  end
+
+  test "Container request commit" do
+    set_user_from_auth :active_trustedclient
+    cr = ContainerRequest.new
+    cr.command = ["echo", "foo"]
+    cr.container_image = "img"
+    cr.cwd = "/tmp"
+    cr.environment = {}
+    cr.mounts = {"BAR" => "FOO"}
+    cr.output_path = "/tmpout"
+    cr.priority = 1
+    cr.runtime_constraints = {}
+    cr.name = "foo"
+    cr.description = "bar"
+    cr.save!
+
+    cr.reload
+    assert_nil cr.container_uuid
+
+    cr.reload
+    cr.state = "Committed"
+    cr.save!
+
+    cr.reload
+
+    c = Container.find_by_uuid cr.container_uuid
+    assert_equal ["echo", "foo"], c.command
+    assert_equal "img", c.container_image
+    assert_equal "/tmp", c.cwd
+    assert_equal({}, c.environment)
+    assert_equal({"BAR" => "FOO"}, c.mounts)
+    assert_equal "/tmpout", c.output_path
+    assert_equal({}, c.runtime_constraints)
+    assert_equal 1, c.priority
+
+    assert_raises(ActiveRecord::RecordInvalid) do
+      cr.priority = nil
+      cr.save!
+    end
+
+    cr.priority = 0
+    cr.save!
+
+    cr.reload
+    c.reload
+    assert_equal 0, cr.priority
+    assert_equal 0, c.priority
+
+  end
+
+
+  test "Container request max priority" do
+    set_user_from_auth :active_trustedclient
+    cr = ContainerRequest.new
+    cr.state = "Committed"
+    cr.container_image = "img"
+    cr.command = ["foo", "bar"]
+    cr.output_path = "/tmp"
+    cr.cwd = "/tmp"
+    cr.priority = 5
+    cr.save!
+
+    c = Container.find_by_uuid cr.container_uuid
+    assert_equal 5, c.priority
+
+    cr2 = ContainerRequest.new
+    cr2.container_image = "img"
+    cr2.command = ["foo", "bar"]
+    cr2.output_path = "/tmp"
+    cr2.cwd = "/tmp"
+    cr2.priority = 10
+    cr2.save!
+
+    act_as_system_user do
+      cr2.state = "Committed"
+      cr2.container_uuid = cr.container_uuid
+      cr2.save!
+    end
+
+    c.reload
+    assert_equal 10, c.priority
+
+    cr2.reload
+    cr2.priority = 0
+    cr2.save!
+
+    c.reload
+    assert_equal 5, c.priority
+
+    cr.reload
+    cr.priority = 0
+    cr.save!
+
+    c.reload
+    assert_equal 0, c.priority
+
+  end
+
+
+  test "Independent container requests" do
+    set_user_from_auth :active_trustedclient
+    cr = ContainerRequest.new
+    cr.state = "Committed"
+    cr.container_image = "img"
+    cr.command = ["foo", "bar"]
+    cr.output_path = "/tmp"
+    cr.cwd = "/tmp"
+    cr.priority = 5
+    cr.save!
+
+    c2 = ContainerRequest.new
+    c2.state = "Committed"
+    c2.container_image = "img"
+    c2.command = ["foo", "bar"]
+    c2.output_path = "/tmp"
+    c2.cwd = "/tmp"
+    c2.priority = 10
+    c2.save!
+
+    c = Container.find_by_uuid cr.container_uuid
+    assert_equal 5, c.priority
+
+    c2 = Container.find_by_uuid c2.container_uuid
+    assert_equal 10, c2.priority
+
+    cr.priority = 0
+    cr.save!
+
+    c.reload
+    assert_equal 0, c.priority
+
+    c2.reload
+    assert_equal 10, c2.priority
+  end
+
+
+  test "Container cancelled finalizes request" do
+    set_user_from_auth :active_trustedclient
+    cr = ContainerRequest.new
+    cr.state = "Committed"
+    cr.container_image = "img"
+    cr.command = ["foo", "bar"]
+    cr.output_path = "/tmp"
+    cr.cwd = "/tmp"
+    cr.priority = 5
+    cr.save!
+
+    cr.reload
+    assert_equal "Committed", cr.state
+
+    c = Container.find_by_uuid cr.container_uuid
+    assert_equal "Queued", c.state
+
+    act_as_system_user do
+      c.state = "Cancelled"
+      c.save!
+    end
+
+    cr.reload
+    assert_equal "Final", cr.state
+
+  end
+
+
+  test "Container complete finalizes request" do
+    set_user_from_auth :active_trustedclient
+    cr = ContainerRequest.new
+    cr.state = "Committed"
+    cr.container_image = "img"
+    cr.command = ["foo", "bar"]
+    cr.output_path = "/tmp"
+    cr.cwd = "/tmp"
+    cr.priority = 5
+    cr.save!
+
+    cr.reload
+    assert_equal "Committed", cr.state
+
+    c = Container.find_by_uuid cr.container_uuid
+    assert_equal "Queued", c.state
+
+    act_as_system_user do
+      c.state = "Running"
+      c.save!
+    end
+
+    cr.reload
+    assert_equal "Committed", cr.state
+
+    act_as_system_user do
+      c.state = "Complete"
+      c.save!
+    end
+
+    cr.reload
+    assert_equal "Final", cr.state
+
+  end
+
+  test "Container makes container request, then is cancelled" do
+    set_user_from_auth :active_trustedclient
+    cr = ContainerRequest.new
+    cr.state = "Committed"
+    cr.container_image = "img"
+    cr.command = ["foo", "bar"]
+    cr.output_path = "/tmp"
+    cr.cwd = "/tmp"
+    cr.priority = 5
+    cr.save!
+
+    c = Container.find_by_uuid cr.container_uuid
+    assert_equal 5, c.priority
+
+    c2 = ContainerRequest.new
+    c2.state = "Committed"
+    c2.container_image = "img"
+    c2.command = ["foo", "bar"]
+    c2.output_path = "/tmp"
+    c2.cwd = "/tmp"
+    c2.priority = 10
+    c2.requesting_container_uuid = c.uuid
+    c2.save!
+
+    c2 = Container.find_by_uuid c2.container_uuid
+    assert_equal 10, c2.priority
+
+    act_as_system_user do
+      c.state = "Cancelled"
+      c.save!
+    end
+
+    cr.reload
+    assert_equal "Final", cr.state
+
+    c2.reload
+    assert_equal 0, c2.priority
+  end
+
+end
diff --git a/services/api/test/unit/container_test.rb b/services/api/test/unit/container_test.rb
new file mode 100644 (file)
index 0000000..d3216fc
--- /dev/null
@@ -0,0 +1,200 @@
+require 'test_helper'
+
+class ContainerTest < ActiveSupport::TestCase
+  def check_illegal_modify c
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.command = ["echo", "bar"]
+        c.save!
+      end
+
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.container_image = "img2"
+        c.save!
+      end
+
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.cwd = "/tmp2"
+        c.save!
+      end
+
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.environment = {"FOO" => "BAR"}
+        c.save!
+      end
+
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.mounts = {"FOO" => "BAR"}
+        c.save!
+      end
+
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.output_path = "/tmp3"
+        c.save!
+      end
+
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.runtime_constraints = {"FOO" => "BAR"}
+        c.save!
+      end
+
+  end
+
+  def check_bogus_states c
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.state = nil
+        c.save!
+      end
+
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.state = "Flubber"
+        c.save!
+      end
+  end
+
+  def check_no_change_from_complete c
+      check_illegal_modify c
+      check_bogus_states c
+
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.priority = 3
+        c.save!
+      end
+
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.state = "Queued"
+        c.save!
+      end
+
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.state = "Running"
+        c.save!
+      end
+
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.state = "Complete"
+        c.save!
+      end
+
+  end
+
+  test "Container create" do
+    act_as_system_user do
+      c = Container.new
+      c.command = ["echo", "foo"]
+      c.container_image = "img"
+      c.cwd = "/tmp"
+      c.environment = {}
+      c.mounts = {"BAR" => "FOO"}
+      c.output_path = "/tmp"
+      c.priority = 1
+      c.runtime_constraints = {}
+      c.save!
+
+      check_illegal_modify c
+      check_bogus_states c
+
+      c.reload
+      c.priority = 2
+      c.save!
+    end
+  end
+
+  test "Container running" do
+    act_as_system_user do
+      c = Container.new
+      c.command = ["echo", "foo"]
+      c.container_image = "img"
+      c.output_path = "/tmp"
+      c.save!
+
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.state = "Complete"
+        c.save!
+      end
+
+      c.reload
+      c.state = "Running"
+      c.save!
+
+      check_illegal_modify c
+      check_bogus_states c
+
+      assert_raises(ActiveRecord::RecordInvalid) do
+        c.reload
+        c.state = "Queued"
+        c.save!
+      end
+
+      c.reload
+      c.priority = 3
+      c.save!
+    end
+  end
+
+  test "Container queued cancel" do
+    act_as_system_user do
+      c = Container.new
+      c.command = ["echo", "foo"]
+      c.container_image = "img"
+      c.output_path = "/tmp"
+      c.save!
+
+      c.reload
+      c.state = "Cancelled"
+      c.save!
+
+      check_no_change_from_complete c
+    end
+  end
+
+  test "Container running cancel" do
+    act_as_system_user do
+      c = Container.new
+      c.command = ["echo", "foo"]
+      c.container_image = "img"
+      c.output_path = "/tmp"
+      c.save!
+
+      c.reload
+      c.state = "Running"
+      c.save!
+
+      c.reload
+      c.state = "Cancelled"
+      c.save!
+
+      check_no_change_from_complete c
+    end
+  end
+
+  test "Container create forbidden for non-admin" do
+    set_user_from_auth :active_trustedclient
+    c = Container.new
+    c.command = ["echo", "foo"]
+    c.container_image = "img"
+    c.cwd = "/tmp"
+    c.environment = {}
+    c.mounts = {"BAR" => "FOO"}
+    c.output_path = "/tmp"
+    c.priority = 1
+    c.runtime_constraints = {}
+    assert_raises(ArvadosModel::PermissionDeniedError) do
+      c.save!
+    end
+  end
+
+end