Merge branch '10312-nodemanager-quotas' refs #10312
authorPeter Amstutz <peter.amstutz@curoverse.com>
Sat, 10 Jun 2017 01:29:59 +0000 (21:29 -0400)
committerPeter Amstutz <peter.amstutz@curoverse.com>
Sat, 10 Jun 2017 01:29:59 +0000 (21:29 -0400)
65 files changed:
apps/workbench/app/assets/images/trash-icon.png [new file with mode: 0644]
apps/workbench/app/assets/javascripts/filterable.js
apps/workbench/app/assets/javascripts/select_modal.js
apps/workbench/app/assets/javascripts/selection.js.erb
apps/workbench/app/controllers/search_controller.rb
apps/workbench/app/controllers/trash_items_controller.rb [new file with mode: 0644]
apps/workbench/app/models/arvados_base.rb
apps/workbench/app/models/arvados_resource_list.rb
apps/workbench/app/models/collection.rb
apps/workbench/app/views/application/_breadcrumbs.html.erb
apps/workbench/app/views/trash_items/_create_new_object_button.html.erb [new file with mode: 0644]
apps/workbench/app/views/trash_items/_show_recent_trash.html.erb [new file with mode: 0644]
apps/workbench/app/views/trash_items/_show_trash_rows.html.erb [new file with mode: 0644]
apps/workbench/app/views/trash_items/_untrash_item.html.erb [new file with mode: 0644]
apps/workbench/app/views/trash_items/index.html.erb [new file with mode: 0644]
apps/workbench/app/views/trash_items/untrash_items.js.erb [new file with mode: 0644]
apps/workbench/app/views/work_units/_show_all_processes.html.erb
apps/workbench/config/routes.rb
apps/workbench/test/controllers/search_controller_test.rb
apps/workbench/test/integration/trash_test.rb [new file with mode: 0644]
build/run-tests.sh
doc/api/methods/groups.html.textile.liquid
sdk/cli/bin/crunch-job
sdk/go/arvados/client.go
sdk/go/arvadosclient/arvadosclient.go
sdk/go/arvadosclient/arvadosclient_test.go
sdk/go/keepclient/discover.go
sdk/go/keepclient/discover_test.go
sdk/go/keepclient/keepclient.go
sdk/go/keepclient/keepclient_test.go
sdk/go/keepclient/support.go
services/api/app/controllers/arvados/v1/collections_controller.rb
services/api/app/controllers/arvados/v1/groups_controller.rb
services/api/app/controllers/arvados/v1/keep_services_controller.rb
services/api/app/models/arvados_model.rb
services/api/app/models/job.rb
services/api/app/models/user.rb
services/api/config/routes.rb
services/api/lib/can_be_an_owner.rb
services/api/lib/create_ancestor_view.sql [new file with mode: 0644]
services/api/test/fixtures/collections.yml
services/api/test/fixtures/jobs.yml
services/api/test/functional/arvados/v1/collections_controller_test.rb
services/api/test/functional/arvados/v1/groups_controller_test.rb
services/api/test/functional/arvados/v1/job_reuse_controller_test.rb
services/api/test/functional/arvados/v1/keep_services_controller_test.rb
services/api/test/functional/arvados/v1/schema_controller_test.rb
services/keep-balance/integration_test.go
services/keep-web/cache.go [new file with mode: 0644]
services/keep-web/cache_test.go [new file with mode: 0644]
services/keep-web/handler.go
services/keep-web/handler_test.go
services/keep-web/main.go
services/keep-web/server_test.go
services/keep-web/status_test.go [new file with mode: 0644]
services/keep-web/usage.go
services/keepproxy/keepproxy.go
services/keepproxy/keepproxy_test.go
services/keepstore/keepstore.go
services/keepstore/pull_worker_integration_test.go
tools/keep-block-check/keep-block-check.go
tools/keep-block-check/keep-block-check_test.go
tools/keep-exercise/keep-exercise.go
tools/keep-rsync/keep-rsync.go
tools/keep-rsync/keep-rsync_test.go

diff --git a/apps/workbench/app/assets/images/trash-icon.png b/apps/workbench/app/assets/images/trash-icon.png
new file mode 100644 (file)
index 0000000..5c26c24
Binary files /dev/null and b/apps/workbench/app/assets/images/trash-icon.png differ
index 5f6370c2905171bda848622f9d2e6e638d33ad9b..e3639d0f2bdefea6d72da8b0fd6cd34bbe47f4c2 100644 (file)
@@ -57,6 +57,7 @@ function updateFilterableQueryNow($target) {
     } else {
       params.filters = [['any', '@@', newquery.trim().concat(':*')]];
     }
+    $(".modal-dialog-preview-pane").html("");
     $target.data('infinite-content-params-filterable', params);
     $target.data('filterable-query', newquery);
 }
index 17b334eb643438631eb35c3b8ffa31d04d9c2d30..d31cb45dbaf8bcd9cb2ac1ec65a1ab2a8b3c773d 100644 (file)
@@ -120,6 +120,7 @@ $(document).on('click', '.selectable', function() {
                   'project_uuid': project_uuid
                  };
     }
+    $(".modal-dialog-preview-pane").html("");
     // Use current selection as dropdown button label
     $(this).
         closest('.dropdown-menu').
index f60bef7ddb432cda137c935f395b1a46abe997ad..a8e2738fe0ed69c8f4850b1bb9fef8bb3b1b1ab2 100644 (file)
@@ -81,6 +81,10 @@ function enable_disable_selection_actions() {
         toggleClass('disabled',
                     ($checked.length < 0) ||
                     !($checked.length > 0 && collection_lock_classes && collection_lock_classes.indexOf("fa-unlock") !=-1));
+    $('[data-selection-action=untrash-selected-items]', $container).
+        closest('li').
+        toggleClass('disabled',
+                    ($checked.length < 1));
 }
 
 $(document).
index 2511ab08188fbb4aa2e17d250b1feecafff03bd9..3fa78365b9f35c98b5b12a1476634359ee875811 100644 (file)
@@ -15,6 +15,7 @@ class SearchController < ApplicationController
     end
     @objects = search_what.contents(limit: @limit,
                                     offset: @offset,
+                                    recursive: true,
                                     count: 'none',
                                     last_object_class: params["last_object_class"],
                                     filters: @filters)
@@ -24,6 +25,7 @@ class SearchController < ApplicationController
   def next_page_href with_params={}
     super with_params.merge(last_object_class: @objects.last.class.to_s,
                             project_uuid: params[:project_uuid],
+                            recursive: true,
                             count: 'none',
                             filters: @filters.to_json)
   end
diff --git a/apps/workbench/app/controllers/trash_items_controller.rb b/apps/workbench/app/controllers/trash_items_controller.rb
new file mode 100644 (file)
index 0000000..5f91a60
--- /dev/null
@@ -0,0 +1,88 @@
+class TrashItemsController < ApplicationController
+  def model_class
+    Collection
+  end
+
+  def index_pane_list
+    %w(Recent_trash)
+  end
+
+  def find_objects_for_index
+    # If it's not the index rows partial display, just return
+    # The /index request will again be invoked to display the
+    # partial at which time, we will be using the objects found.
+    return if !params[:partial]
+
+    trashed_items
+
+    if @objects.any?
+      @objects = @objects.sort_by { |obj| obj.trash_at }.reverse
+      @next_page_filters = next_page_filters('<=')
+      @next_page_href = url_for(partial: :trash_rows,
+                                filters: @next_page_filters.to_json)
+    else
+      @next_page_href = nil
+    end
+  end
+
+  def next_page_href with_params={}
+    @next_page_href
+  end
+
+  def next_page_filters nextpage_operator
+    next_page_filters = @filters.reject do |attr, op, val|
+      (attr == 'trash_at' and op == nextpage_operator) or
+      (attr == 'uuid' and op == 'not in')
+    end
+
+    if @objects.any?
+      last_trash_at = @objects.last.trash_at
+
+      last_uuids = []
+      @objects.each do |obj|
+        last_uuids << obj.uuid if obj.trash_at.eql?(last_trash_at)
+      end
+
+      next_page_filters += [['trash_at', nextpage_operator, last_trash_at]]
+      next_page_filters += [['uuid', 'not in', last_uuids]]
+    end
+
+    next_page_filters
+  end
+
+  def trashed_items
+    # API server index doesn't return manifest_text by default, but our
+    # callers want it unless otherwise specified.
+    @select ||= Collection.columns.map(&:name)
+    limit = if params[:limit] then params[:limit].to_i else 100 end
+    offset = if params[:offset] then params[:offset].to_i else 0 end
+
+    base_search = Collection.select(@select).include_trash(true).where(is_trashed: true)
+    base_search = base_search.filter(params[:filters]) if params[:filters]
+
+    if params[:search].andand.length.andand > 0
+      tags = Link.where(any: ['contains', params[:search]])
+      base_search = base_search.limit(limit).offset(offset)
+      @objects = (base_search.where(uuid: tags.collect(&:head_uuid)) |
+                  base_search.where(any: ['contains', params[:search]])).
+                  uniq { |c| c.uuid }
+    else
+      @objects = base_search.limit(limit).offset(offset)
+    end
+  end
+
+  def untrash_items
+    @untrashed_uuids = []
+
+    updates = {trash_at: nil}
+
+    Collection.include_trash(1).where(uuid: params[:selection]).each do |c|
+      c.untrash
+      @untrashed_uuids << c.uuid
+    end
+
+    respond_to do |format|
+      format.js
+    end
+  end
+end
index 5d6a4c94b9bebb581d771ce4b0557f3ba828e119..58e3fb7a5c5b6133d1746d95126ed8ef79b474a2 100644 (file)
@@ -152,6 +152,14 @@ class ArvadosBase < ActiveRecord::Base
     ArvadosResourceList.new(self).distinct(*args)
   end
 
+  def self.include_trash(*args)
+    ArvadosResourceList.new(self).include_trash(*args)
+  end
+
+  def self.recursive(*args)
+    ArvadosResourceList.new(self).recursive(*args)
+  end
+
   def self.eager(*args)
     ArvadosResourceList.new(self).eager(*args)
   end
index 35dcde38da24cabc5290e24e0a99131f467181b4..dea2f30d1de523176a04df8dd5e06a033855d623 100644 (file)
@@ -21,6 +21,16 @@ class ArvadosResourceList
     self
   end
 
+  def include_trash(option=nil)
+    @include_trash = option
+    self
+  end
+
+  def recursive(option=nil)
+    @recursive = option
+    self
+  end
+
   def limit(max_results)
     if not max_results.nil? and not max_results.is_a? Integer
       raise ArgumentError("argument to limit() must be an Integer or nil")
@@ -192,6 +202,7 @@ class ArvadosResourceList
     api_params[:order] = @orderby_spec if @orderby_spec
     api_params[:filters] = @filters if @filters
     api_params[:distinct] = @distinct if @distinct
+    api_params[:include_trash] = @include_trash if @include_trash
     if @fetch_multiple_pages
       # Default limit to (effectively) api server's MAX_LIMIT
       api_params[:limit] = 2**(0.size*8 - 1) - 1
index ea81ad8c0a7588edc00062585d99ba9fa116035f..305ea015306fe898c97bd16b443a13d9ff230781 100644 (file)
@@ -98,4 +98,7 @@ class Collection < ArvadosBase
     [ 'description' ]
   end
 
+  def untrash
+    arvados_api_client.api(self.class, "/#{self.uuid}/untrash", {})
+  end
 end
index 3ef2aec17c35575013dd0be2ca51afe95953c8ba..1ead5787b8b54363bec746003a8aa878d7f356b9 100644 (file)
             <% end %>
           <% end %>
         </ul>
+        <ul class="nav navbar-nav navbar-right">
+          <li>
+            <a href="/trash">
+              <%= image_tag("trash-icon.png", size: "20x20" ) %> Trash
+            </a>
+          </li>
+        </ul>
       </nav>
diff --git a/apps/workbench/app/views/trash_items/_create_new_object_button.html.erb b/apps/workbench/app/views/trash_items/_create_new_object_button.html.erb
new file mode 100644 (file)
index 0000000..2ba9e1a
--- /dev/null
@@ -0,0 +1 @@
+<%# There is no such thing %>
diff --git a/apps/workbench/app/views/trash_items/_show_recent_trash.html.erb b/apps/workbench/app/views/trash_items/_show_recent_trash.html.erb
new file mode 100644 (file)
index 0000000..14c5409
--- /dev/null
@@ -0,0 +1,54 @@
+<div class="container selection-action-container" style="width: 100%">
+  <div class="col-md-2 pull-left">
+    <div class="btn-group btn-group-sm">
+      <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">Selection... <span class="caret"></span></button>
+      <ul class="dropdown-menu" role="menu">
+        <li><%= link_to "Un-trash selected items", '#',
+                method: :post,
+                remote: true,
+                'id' => 'untrash_selected_items',
+                'data-href' => untrash_items_trash_items_path,
+                'data-selection-param-name' => 'selection[]',
+                'data-selection-action' => 'untrash-selected-items',
+                'data-toggle' => 'dropdown'
+          %></li>
+      </ul>
+    </div>
+  </div>
+  <div class="col-md-4 pull-right">
+    <input type="text" class="form-control filterable-control recent-trash-items"
+           placeholder="Search trash"
+           data-filterable-target="#recent-trash-items"
+           value="<%= params[:search] %>" />
+  </div>
+
+  <div>
+    <table id="trash-index" class="topalign table table-condensed table-fixedlayout">
+      <colgroup>
+        <col width="5%" />
+        <col width="20%" />
+        <col width="15%" />
+        <col width="15%" />
+        <col width="10%" />
+        <col width="30%" />
+        <col width="5%" />
+      </colgroup>
+
+      <thead>
+        <tr class="contain-align-left">
+          <th></th>
+          <th>Name</th>
+          <th>Trashed at</th>
+          <th title="After this time, no longer available to be recovered from Trash">Permanently<br/>Deleted At</th>
+          <th>Owner</th>
+          <th>Contents</th>
+          <th></th>
+        </tr>
+      </thead>
+
+      <tbody data-infinite-scroller="#recent-trash-items" id="recent-trash-items"
+        data-infinite-content-href="<%= url_for partial: :trash_rows %>" >
+      </tbody>
+    </table>
+  </div>
+</div>
diff --git a/apps/workbench/app/views/trash_items/_show_trash_rows.html.erb b/apps/workbench/app/views/trash_items/_show_trash_rows.html.erb
new file mode 100644 (file)
index 0000000..747f185
--- /dev/null
@@ -0,0 +1,32 @@
+<% @objects.each do |obj| %>
+    <tr data-object-uuid="<%= obj.uuid %>" data-kind="<%= obj.kind %>" >
+      <td>
+        <% if obj.editable? %>
+          <%= check_box_tag 'uuids[]', obj.uuid, false, :class => 'persistent-selection', style: 'cursor: pointer;' %>
+        <% end %>
+      </td>
+      <td>
+        <%= if !obj.name.blank? then obj.name else obj.uuid end %>
+      <td>
+        <%= render_localized_date(obj.trash_at) if obj.trash_at %>
+      <td>
+        <%= render_localized_date(obj.delete_at) if obj.delete_at %>
+      </td>
+      <td>
+        <%= link_to_if_arvados_object obj.owner_uuid, friendly_name: true %>
+      </td>
+      <td>
+        <% for i in (0..[2, obj.files.length-1].min) %>
+          <% file = obj.files[i] %>
+          <% file_path = "#{file[0]}/#{file[1]}" %>
+          <%= file_path %><br />
+        <% end %>
+        <% if obj.files.length > 3 %>
+          <%= "(#{obj.files.length-3} more files)" %>
+        <% end %>
+      </td>
+      <td>
+        <%= render partial: 'untrash_item', locals: {object:obj} %>
+      </td>
+    </tr>
+<% end %>
diff --git a/apps/workbench/app/views/trash_items/_untrash_item.html.erb b/apps/workbench/app/views/trash_items/_untrash_item.html.erb
new file mode 100644 (file)
index 0000000..74255c3
--- /dev/null
@@ -0,0 +1,7 @@
+<% if object.editable? %>
+  <% msg = "Untrash '" + if !object.name.blank? then object.name else object.uuid end + "'?" %>
+  <%= link_to({action: 'untrash_items', selection: [object.uuid]}, remote: true, method: :post,
+      title: "Untrash", style: 'cursor: pointer;') do %>
+    <i class="fa fa-fw fa-recycle"></i>
+  <% end %>
+<% end %>
diff --git a/apps/workbench/app/views/trash_items/index.html.erb b/apps/workbench/app/views/trash_items/index.html.erb
new file mode 100644 (file)
index 0000000..5f5bc83
--- /dev/null
@@ -0,0 +1 @@
+<%= render file: 'application/index.html.erb', locals: local_assigns %>
diff --git a/apps/workbench/app/views/trash_items/untrash_items.js.erb b/apps/workbench/app/views/trash_items/untrash_items.js.erb
new file mode 100644 (file)
index 0000000..3d26658
--- /dev/null
@@ -0,0 +1,5 @@
+<% @untrashed_uuids.each do |uuid| %>
+       $('[data-object-uuid=<%= uuid %>]').hide('slow', function() {
+           $(this).remove();
+       });
+<% end %>
index ea178438cfef1fe912bed6f22ec5a5f54bb9b6b2..0fc1ef625f4d96dbd7470abbbe13a7c439dd150a 100644 (file)
@@ -1,4 +1,4 @@
-<div class="container">
+<div class="container" style="width: 100%">
   <div class="row">
     <div class="pull-right">
       <input type="text" class="form-control filterable-control recent-all-processes-filterable-control"
index 0eef73f8ae3d0116ba96ccf8f93f6663fc4204e8..badb471d64ae666924fb06a21a408237bbed3f26 100644 (file)
@@ -108,6 +108,11 @@ ArvadosWorkbench::Application.routes.draw do
 
   resources :workflows
 
+  get "trash" => 'trash_items#index', :as => :trash
+  resources :trash_items do
+    post 'untrash_items', on: :collection
+  end
+
   post 'actions' => 'actions#post'
   get 'actions' => 'actions#show'
   get 'websockets' => 'websocket#index'
index a09d966a184c219cd5fad8a5dc69abea06d1a86c..9b7192f7831b143b54dc2bc3c76de4fc90aea3f6 100644 (file)
@@ -39,4 +39,27 @@ class SearchControllerTest < ActionController::TestCase
     assert_empty(json_response['content'],
                  'search results for empty project should be empty')
   end
+
+  test 'search results for aproject and verify recursive contents' do
+    xhr :get, :choose, {
+      format: :json,
+      partial: true,
+      project_uuid: api_fixture('groups')['aproject']['uuid'],
+    }, session_for(:active)
+    assert_response :success
+    assert_not_empty(json_response['content'],
+                 'search results for aproject should not be empty')
+    items = []
+    json_response['content'].scan /<div[^>]+>/ do |div_tag|
+      div_tag.scan(/\ data-object-uuid=\"(.*?)\"/).each do |uuid,|
+        items << uuid
+      end
+    end
+
+    assert_includes(items, api_fixture('collections')['collection_to_move_around_in_aproject']['uuid'])
+    assert_includes(items, api_fixture('groups')['asubproject']['uuid'])
+    assert_includes(items, api_fixture('collections')['baz_collection_name_in_asubproject']['uuid'])
+    assert_includes(items,
+      api_fixture('groups')['subproject_in_asubproject_with_same_name_as_one_in_active_user_home']['uuid'])
+  end
 end
diff --git a/apps/workbench/test/integration/trash_test.rb b/apps/workbench/test/integration/trash_test.rb
new file mode 100644 (file)
index 0000000..857e57a
--- /dev/null
@@ -0,0 +1,79 @@
+require 'integration_helper'
+
+class TrashTest < ActionDispatch::IntegrationTest
+  setup do
+    need_javascript
+  end
+
+  test "trash page" do
+    deleted = api_fixture('collections')['deleted_on_next_sweep']
+    expired1 = api_fixture('collections')['unique_expired_collection']
+    expired2 = api_fixture('collections')['unique_expired_collection2']
+
+    # visit trash page
+    visit page_with_token('active', "/trash")
+
+    assert_text deleted['name']
+    assert_text expired1['name']
+    assert_no_text expired2['name']   # not readable by this user
+    assert_no_text 'foo_file'         # not trash
+
+    # Un-trash one item using selection dropdown
+    within('tr', text: deleted['name']) do
+      first('input').click
+    end
+
+    click_button 'Selection...'
+    within('.selection-action-container') do
+      click_link 'Un-trash selected items'
+    end
+
+    wait_for_ajax
+
+    assert_text expired1['name']      # this should still be there
+    assert_no_text deleted['name']    # this should no longer be here
+
+    # Un-trash another item using the recycle button
+    within('tr', text: expired1['name']) do
+      first('.fa-recycle').click
+    end
+
+    wait_for_ajax
+
+    assert_no_text expired1['name']
+
+    # verify that the two un-trashed items are now shown in /collections page
+    visit page_with_token('active', "/collections")
+    assert_text deleted['uuid']
+    assert_text expired1['uuid']
+    assert_no_text expired2['uuid']
+  end
+
+  test "trash page with search" do
+    deleted = api_fixture('collections')['deleted_on_next_sweep']
+    expired = api_fixture('collections')['unique_expired_collection']
+
+    visit page_with_token('active', "/trash")
+
+    assert_text deleted['name']
+    assert_text expired['name']
+
+    page.find_field('Search trash').set 'expired'
+
+    assert_text expired['name']
+    assert_no_text deleted['name']
+
+    click_button 'Selection...'
+    within('.selection-action-container') do
+      assert_selector 'li.disabled', text: 'Un-trash selected items'
+    end
+
+    first('input').click
+
+    click_button 'Selection...'
+    within('.selection-action-container') do
+      assert_selector 'li', text: 'Un-trash selected items'
+      assert_selector 'li.disabled', text: 'Un-trash selected items'
+    end
+  end
+end
index de29db67e83f38705730cb6b51bd8bcd5e23c707..352d05b945ea168fb0700614c9327bfd1e3fa033 100755 (executable)
@@ -568,7 +568,7 @@ do_test_once() {
         # mode makes Go show the wrong line numbers when reporting
         # compilation errors.
         go get -t "git.curoverse.com/arvados.git/$1" && \
-            cd "$WORKSPACE/$1" && \
+            cd "$GOPATH/src/git.curoverse.com/arvados.git/$1" && \
             [[ -z "$(gofmt -e -d . | tee /dev/stderr)" ]] && \
             if [[ -n "${testargs[$1]}" ]]
         then
index 7a15d20d5aaf3d5b1ff63aedf9f958d6e91efc3e..a57a089eec89de73b8a888a8e489fd909327555c 100644 (file)
@@ -43,6 +43,7 @@ table(table table-bordered table-condensed).
 |limit|integer (default 100)|Maximum number of items to return.|query||
 |order|string|Order in which to return matching items.  Sort within a resource type by prefixing the attribute with the resource name and a dot.|query|@"collections.modified_at desc"@|
 |filters|array|Conditions for filtering items.|query|@[["uuid", "is_a", "arvados#job"]]@|
+|recursive|boolean (default false)|Include items owned by subprojects.|query|@true@|
 
 Note: Because adding access tokens to manifests can be computationally expensive, the @manifest_text@ field is not included in listed collections.  If you need it, request a "list of collections":{{site.baseurl}}/api/methods/collections.html with the filter @["owner_uuid", "=", GROUP_UUID]@, and @"manifest_text"@ listed in the select parameter.
 
index 55e35b04b9cf933f702df4e109eded633230a15a..35a3b1f23bc1222db58c24aaaf05184868153dbf 100755 (executable)
@@ -1130,10 +1130,10 @@ freeze();
 my $collated_output = save_output_collection();
 Log (undef, "finish");
 
-save_meta();
+my $final_log = save_meta();
 
 my $final_state;
-if ($collated_output && $main::success) {
+if ($collated_output && $final_log && $main::success) {
   $final_state = 'Complete';
 } else {
   $final_state = 'Failed';
@@ -1760,7 +1760,7 @@ sub log_writer_start($)
   $log_pipe_pid = open2($log_pipe_out, $log_pipe_in,
                         'arv-put',
                         '--stream',
-                        '--retries', '3',
+                        '--retries', '6',
                         '--filename', $logfilename,
                         '-');
   $log_pipe_out_buf = "";
@@ -1898,6 +1898,8 @@ sub save_meta
     });
   Log(undef, "log collection is " . $log_coll->{portable_data_hash});
   $Job->update_attributes('log' => $log_coll->{portable_data_hash});
+
+  return $log_coll->{portable_data_hash};
 }
 
 
index 9691e7a07e475668cc73a80dffba466addb3b4ef..d7eb811b8a7a26a08931b07b9c66fcfec83d78a3 100644 (file)
@@ -6,6 +6,7 @@ import (
        "fmt"
        "io"
        "io/ioutil"
+       "log"
        "math"
        "net/http"
        "net/url"
@@ -63,13 +64,25 @@ var DefaultSecureClient = &http.Client{
 // ARVADOS_API_* environment variables.
 func NewClientFromEnv() *Client {
        var svcs []string
-       if s := os.Getenv("ARVADOS_KEEP_SERVICES"); s != "" {
-               svcs = strings.Split(s, " ")
+       for _, s := range strings.Split(os.Getenv("ARVADOS_KEEP_SERVICES"), " ") {
+               if s == "" {
+                       continue
+               } else if u, err := url.Parse(s); err != nil {
+                       log.Printf("ARVADOS_KEEP_SERVICES: %q: %s", s, err)
+               } else if !u.IsAbs() {
+                       log.Printf("ARVADOS_KEEP_SERVICES: %q: not an absolute URI", s)
+               } else {
+                       svcs = append(svcs, s)
+               }
+       }
+       var insecure bool
+       if s := strings.ToLower(os.Getenv("ARVADOS_API_HOST_INSECURE")); s == "1" || s == "yes" || s == "true" {
+               insecure = true
        }
        return &Client{
                APIHost:         os.Getenv("ARVADOS_API_HOST"),
                AuthToken:       os.Getenv("ARVADOS_API_TOKEN"),
-               Insecure:        os.Getenv("ARVADOS_API_HOST_INSECURE") != "",
+               Insecure:        insecure,
                KeepServiceURIs: svcs,
        }
 }
index 021b9471ff93814b81c933923e819f821efd8f1b..4cfda94581518fd9360ebc4f3268e231893797c7 100644 (file)
@@ -11,11 +11,13 @@ import (
        "fmt"
        "io"
        "io/ioutil"
+       "log"
        "net/http"
        "net/url"
        "os"
        "regexp"
        "strings"
+       "sync"
        "time"
 
        "git.curoverse.com/arvados.git/sdk/go/arvados"
@@ -38,6 +40,12 @@ var MaxIdleConnectionDuration = 30 * time.Second
 
 var RetryDelay = 2 * time.Second
 
+var (
+       defaultInsecureHTTPClient *http.Client
+       defaultSecureHTTPClient   *http.Client
+       defaultHTTPClientMtx      sync.Mutex
+)
+
 // Indicates an error that was returned by the API server.
 type APIServerError struct {
        // Address of server returning error, of the form "host:port".
@@ -66,6 +74,13 @@ func (e APIServerError) Error() string {
        }
 }
 
+// StringBool tests whether s is suggestive of true. It returns true
+// if s is a mixed/uppoer/lower-case variant of "1", "yes", or "true".
+func StringBool(s string) bool {
+       s = strings.ToLower(s)
+       return s == "1" || s == "yes" || s == "true"
+}
+
 // Helper type so we don't have to write out 'map[string]interface{}' every time.
 type Dict map[string]interface{}
 
@@ -111,26 +126,31 @@ var CertFiles = []string{
        "/etc/pki/tls/certs/ca-bundle.crt",   // Fedora/RHEL
 }
 
-// MakeTLSConfig sets up TLS configuration for communicating with Arvados and Keep services.
+// MakeTLSConfig sets up TLS configuration for communicating with
+// Arvados and Keep services.
 func MakeTLSConfig(insecure bool) *tls.Config {
        tlsconfig := tls.Config{InsecureSkipVerify: insecure}
 
        if !insecure {
-               // Look for /etc/arvados/ca-certificates.crt in addition to normal system certs.
+               // Use the first entry in CertFiles that we can read
+               // certificates from. If none of those work out, use
+               // the Go defaults.
                certs := x509.NewCertPool()
                for _, file := range CertFiles {
                        data, err := ioutil.ReadFile(file)
-                       if err == nil {
-                               success := certs.AppendCertsFromPEM(data)
-                               if !success {
-                                       fmt.Printf("Unable to load any certificates from %v", file)
-                               } else {
-                                       tlsconfig.RootCAs = certs
-                                       break
+                       if err != nil {
+                               if !os.IsNotExist(err) {
+                                       log.Printf("error reading %q: %s", file, err)
                                }
+                               continue
+                       }
+                       if !certs.AppendCertsFromPEM(data) {
+                               log.Printf("unable to load any certificates from %v", file)
+                               continue
                        }
+                       tlsconfig.RootCAs = certs
+                       break
                }
-               // Will use system default CA roots instead.
        }
 
        return &tlsconfig
@@ -150,6 +170,7 @@ func New(c *arvados.Client) (*ArvadosClient, error) {
                        TLSClientConfig: MakeTLSConfig(c.Insecure)}},
                External:          false,
                Retries:           2,
+               KeepServiceURIs:   c.KeepServiceURIs,
                lastClosedIdlesAt: time.Now(),
        }
 
@@ -161,42 +182,12 @@ func New(c *arvados.Client) (*ArvadosClient, error) {
 // ARVADOS_API_HOST_INSECURE, ARVADOS_EXTERNAL_CLIENT, and
 // ARVADOS_KEEP_SERVICES.
 func MakeArvadosClient() (ac *ArvadosClient, err error) {
-       var matchTrue = regexp.MustCompile("^(?i:1|yes|true)$")
-       insecure := matchTrue.MatchString(os.Getenv("ARVADOS_API_HOST_INSECURE"))
-       external := matchTrue.MatchString(os.Getenv("ARVADOS_EXTERNAL_CLIENT"))
-
-       ac = &ArvadosClient{
-               Scheme:      "https",
-               ApiServer:   os.Getenv("ARVADOS_API_HOST"),
-               ApiToken:    os.Getenv("ARVADOS_API_TOKEN"),
-               ApiInsecure: insecure,
-               Client: &http.Client{Transport: &http.Transport{
-                       TLSClientConfig: MakeTLSConfig(insecure)}},
-               External: external,
-               Retries:  2}
-
-       for _, s := range strings.Split(os.Getenv("ARVADOS_KEEP_SERVICES"), " ") {
-               if s == "" {
-                       continue
-               }
-               if u, err := url.Parse(s); err != nil {
-                       return ac, fmt.Errorf("ARVADOS_KEEP_SERVICES: %q: %s", s, err)
-               } else if !u.IsAbs() {
-                       return ac, fmt.Errorf("ARVADOS_KEEP_SERVICES: %q: not an absolute URI", s)
-               }
-               ac.KeepServiceURIs = append(ac.KeepServiceURIs, s)
-       }
-
-       if ac.ApiServer == "" {
-               return ac, MissingArvadosApiHost
-       }
-       if ac.ApiToken == "" {
-               return ac, MissingArvadosApiToken
+       ac, err = New(arvados.NewClientFromEnv())
+       if err != nil {
+               return
        }
-
-       ac.lastClosedIdlesAt = time.Now()
-
-       return ac, err
+       ac.External = StringBool(os.Getenv("ARVADOS_EXTERNAL_CLIENT"))
+       return
 }
 
 // CallRaw is the same as Call() but returns a Reader that reads the
@@ -420,3 +411,20 @@ func (c *ArvadosClient) Discovery(parameter string) (value interface{}, err erro
                return value, ErrInvalidArgument
        }
 }
+
+func (ac *ArvadosClient) httpClient() *http.Client {
+       if ac.Client != nil {
+               return ac.Client
+       }
+       c := &defaultSecureHTTPClient
+       if ac.ApiInsecure {
+               c = &defaultInsecureHTTPClient
+       }
+       if *c == nil {
+               defaultHTTPClientMtx.Lock()
+               defer defaultHTTPClientMtx.Unlock()
+               *c = &http.Client{Transport: &http.Transport{
+                       TLSClientConfig: MakeTLSConfig(ac.ApiInsecure)}}
+       }
+       return *c
+}
index 54591d30ba34706cc9f4359c02c612f86b396bf8..794a3ce16f732ede733ab1a710be294da4780ace 100644 (file)
@@ -41,21 +41,21 @@ func (s *ServerRequiredSuite) SetUpTest(c *C) {
 
 func (s *ServerRequiredSuite) TestMakeArvadosClientSecure(c *C) {
        os.Setenv("ARVADOS_API_HOST_INSECURE", "")
-       kc, err := MakeArvadosClient()
+       ac, err := MakeArvadosClient()
        c.Assert(err, Equals, nil)
-       c.Check(kc.ApiServer, Equals, os.Getenv("ARVADOS_API_HOST"))
-       c.Check(kc.ApiToken, Equals, os.Getenv("ARVADOS_API_TOKEN"))
-       c.Check(kc.ApiInsecure, Equals, false)
+       c.Check(ac.ApiServer, Equals, os.Getenv("ARVADOS_API_HOST"))
+       c.Check(ac.ApiToken, Equals, os.Getenv("ARVADOS_API_TOKEN"))
+       c.Check(ac.ApiInsecure, Equals, false)
 }
 
 func (s *ServerRequiredSuite) TestMakeArvadosClientInsecure(c *C) {
        os.Setenv("ARVADOS_API_HOST_INSECURE", "true")
-       kc, err := MakeArvadosClient()
+       ac, err := MakeArvadosClient()
        c.Assert(err, Equals, nil)
-       c.Check(kc.ApiInsecure, Equals, true)
-       c.Check(kc.ApiServer, Equals, os.Getenv("ARVADOS_API_HOST"))
-       c.Check(kc.ApiToken, Equals, os.Getenv("ARVADOS_API_TOKEN"))
-       c.Check(kc.Client.Transport.(*http.Transport).TLSClientConfig.InsecureSkipVerify, Equals, true)
+       c.Check(ac.ApiInsecure, Equals, true)
+       c.Check(ac.ApiServer, Equals, os.Getenv("ARVADOS_API_HOST"))
+       c.Check(ac.ApiToken, Equals, os.Getenv("ARVADOS_API_TOKEN"))
+       c.Check(ac.Client.Transport.(*http.Transport).TLSClientConfig.InsecureSkipVerify, Equals, true)
 }
 
 func (s *ServerRequiredSuite) TestGetInvalidUUID(c *C) {
index f3e39606980b79b71ddb62ec7d61d90f9b6d0056..e2cd329fc4c22ccfdb73ff6099ce8c95f6f28d16 100644 (file)
@@ -4,105 +4,167 @@ import (
        "encoding/json"
        "fmt"
        "log"
-       "net/http"
        "os"
        "os/signal"
-       "reflect"
        "strings"
+       "sync"
        "syscall"
        "time"
+
+       "git.curoverse.com/arvados.git/sdk/go/arvadosclient"
 )
 
-// DiscoverKeepServers gets list of available keep services from the
-// API server.
-//
-// If a list of services is provided in the arvadosclient (e.g., from
-// an environment variable or local config), that list is used
-// instead.
-func (this *KeepClient) DiscoverKeepServers() error {
-       if this.Arvados.KeepServiceURIs != nil {
-               this.foundNonDiskSvc = true
-               this.replicasPerService = 0
-               if c, ok := this.Client.(*http.Client); ok {
-                       this.setClientSettingsNonDisk(c)
-               }
-               roots := make(map[string]string)
-               for i, uri := range this.Arvados.KeepServiceURIs {
-                       roots[fmt.Sprintf("00000-bi6l4-%015d", i)] = uri
-               }
-               this.SetServiceRoots(roots, roots, roots)
-               return nil
+// ClearCache clears the Keep service discovery cache.
+func RefreshServiceDiscovery() {
+       svcListCacheMtx.Lock()
+       defer svcListCacheMtx.Unlock()
+       for _, ent := range svcListCache {
+               ent.clear <- struct{}{}
        }
+}
 
-       // ArvadosClient did not provide a services list. Ask API
-       // server for a list of accessible services.
-       var list svcList
-       err := this.Arvados.Call("GET", "keep_services", "", "accessible", nil, &list)
-       if err != nil {
-               return err
+// ClearCacheOnSIGHUP installs a signal handler that calls
+// ClearCache when SIGHUP is received.
+func RefreshServiceDiscoveryOnSIGHUP() {
+       svcListCacheMtx.Lock()
+       defer svcListCacheMtx.Unlock()
+       if svcListCacheSignal != nil {
+               return
        }
-       return this.loadKeepServers(list)
+       svcListCacheSignal = make(chan os.Signal, 1)
+       signal.Notify(svcListCacheSignal, syscall.SIGHUP)
+       go func() {
+               for range svcListCacheSignal {
+                       RefreshServiceDiscovery()
+               }
+       }()
 }
 
-// LoadKeepServicesFromJSON gets list of available keep services from given JSON
-func (this *KeepClient) LoadKeepServicesFromJSON(services string) error {
-       var list svcList
-
-       // Load keep services from given json
-       dec := json.NewDecoder(strings.NewReader(services))
-       if err := dec.Decode(&list); err != nil {
-               return err
-       }
+var (
+       svcListCache       = map[string]cachedSvcList{}
+       svcListCacheSignal chan os.Signal
+       svcListCacheMtx    sync.Mutex
+)
 
-       return this.loadKeepServers(list)
+type cachedSvcList struct {
+       arv    *arvadosclient.ArvadosClient
+       latest chan svcList
+       clear  chan struct{}
 }
 
-// RefreshServices calls DiscoverKeepServers to refresh the keep
-// service list on SIGHUP; when the given interval has elapsed since
-// the last refresh; and (if the last refresh failed) the given
-// errInterval has elapsed.
-func (kc *KeepClient) RefreshServices(interval, errInterval time.Duration) {
-       var previousRoots = []map[string]string{}
-
-       timer := time.NewTimer(interval)
-       gotHUP := make(chan os.Signal, 1)
-       signal.Notify(gotHUP, syscall.SIGHUP)
+// Check for new services list every few minutes. Send the latest list
+// to the "latest" channel as needed.
+func (ent *cachedSvcList) poll() {
+       wakeup := make(chan struct{})
+
+       replace := make(chan svcList)
+       go func() {
+               wakeup <- struct{}{}
+               current := <-replace
+               for {
+                       select {
+                       case <-ent.clear:
+                               wakeup <- struct{}{}
+                               // Wait here for the next success, in
+                               // order to avoid returning stale
+                               // results on the "latest" channel.
+                               current = <-replace
+                       case current = <-replace:
+                       case ent.latest <- current:
+                       }
+               }
+       }()
 
+       okDelay := 5 * time.Minute
+       errDelay := 3 * time.Second
+       timer := time.NewTimer(okDelay)
        for {
                select {
-               case <-gotHUP:
                case <-timer.C:
+               case <-wakeup:
+                       if !timer.Stop() {
+                               // Lost race stopping timer; skip extra firing
+                               <-timer.C
+                       }
                }
-               timer.Reset(interval)
-
-               if err := kc.DiscoverKeepServers(); err != nil {
-                       log.Printf("WARNING: Error retrieving services list: %v (retrying in %v)", err, errInterval)
-                       timer.Reset(errInterval)
+               var next svcList
+               err := ent.arv.Call("GET", "keep_services", "", "accessible", nil, &next)
+               if err != nil {
+                       log.Printf("WARNING: Error retrieving services list: %v (retrying in %v)", err, errDelay)
+                       timer.Reset(errDelay)
                        continue
                }
-               newRoots := []map[string]string{kc.LocalRoots(), kc.GatewayRoots()}
+               replace <- next
+               timer.Reset(okDelay)
+       }
+}
 
-               if !reflect.DeepEqual(previousRoots, newRoots) {
-                       DebugPrintf("DEBUG: Updated services list: locals %v gateways %v", newRoots[0], newRoots[1])
-                       previousRoots = newRoots
+// discoverServices gets the list of available keep services from
+// the API server.
+//
+// If a list of services is provided in the arvadosclient (e.g., from
+// an environment variable or local config), that list is used
+// instead.
+//
+// If an API call is made, the result is cached for 5 minutes or until
+// ClearCache() is called, and during this interval it is reused by
+// other KeepClients that use the same API server host.
+func (kc *KeepClient) discoverServices() error {
+       if kc.disableDiscovery {
+               return nil
+       }
+
+       if kc.Arvados.KeepServiceURIs != nil {
+               kc.disableDiscovery = true
+               kc.foundNonDiskSvc = true
+               kc.replicasPerService = 0
+               roots := make(map[string]string)
+               for i, uri := range kc.Arvados.KeepServiceURIs {
+                       roots[fmt.Sprintf("00000-bi6l4-%015d", i)] = uri
                }
+               kc.setServiceRoots(roots, roots, roots)
+               return nil
+       }
 
-               if len(newRoots[0]) == 0 {
-                       log.Printf("WARNING: No local services (retrying in %v)", errInterval)
-                       timer.Reset(errInterval)
+       svcListCacheMtx.Lock()
+       cacheEnt, ok := svcListCache[kc.Arvados.ApiServer]
+       if !ok {
+               arv := *kc.Arvados
+               cacheEnt = cachedSvcList{
+                       latest: make(chan svcList),
+                       clear:  make(chan struct{}),
+                       arv:    &arv,
                }
+               go cacheEnt.poll()
+               svcListCache[kc.Arvados.ApiServer] = cacheEnt
        }
+       svcListCacheMtx.Unlock()
+
+       return kc.loadKeepServers(<-cacheEnt.latest)
 }
 
-// loadKeepServers
-func (this *KeepClient) loadKeepServers(list svcList) error {
+// LoadKeepServicesFromJSON gets list of available keep services from
+// given JSON and disables automatic service discovery.
+func (kc *KeepClient) LoadKeepServicesFromJSON(services string) error {
+       kc.disableDiscovery = true
+
+       var list svcList
+       dec := json.NewDecoder(strings.NewReader(services))
+       if err := dec.Decode(&list); err != nil {
+               return err
+       }
+
+       return kc.loadKeepServers(list)
+}
+
+func (kc *KeepClient) loadKeepServers(list svcList) error {
        listed := make(map[string]bool)
        localRoots := make(map[string]string)
        gatewayRoots := make(map[string]string)
        writableLocalRoots := make(map[string]string)
 
        // replicasPerService is 1 for disks; unknown or unlimited otherwise
-       this.replicasPerService = 1
+       kc.replicasPerService = 1
 
        for _, service := range list.Items {
                scheme := "http"
@@ -121,12 +183,12 @@ func (this *KeepClient) loadKeepServers(list svcList) error {
                if service.ReadOnly == false {
                        writableLocalRoots[service.Uuid] = url
                        if service.SvcType != "disk" {
-                               this.replicasPerService = 0
+                               kc.replicasPerService = 0
                        }
                }
 
                if service.SvcType != "disk" {
-                       this.foundNonDiskSvc = true
+                       kc.foundNonDiskSvc = true
                }
 
                // Gateway services are only used when specified by
@@ -137,14 +199,6 @@ func (this *KeepClient) loadKeepServers(list svcList) error {
                gatewayRoots[service.Uuid] = url
        }
 
-       if client, ok := this.Client.(*http.Client); ok {
-               if this.foundNonDiskSvc {
-                       this.setClientSettingsNonDisk(client)
-               } else {
-                       this.setClientSettingsDisk(client)
-               }
-       }
-
-       this.SetServiceRoots(localRoots, writableLocalRoots, gatewayRoots)
+       kc.setServiceRoots(localRoots, writableLocalRoots, gatewayRoots)
        return nil
 }
index 379d44c820aec0e0b88d84b3c52e5fd316480844..4065ce342e43dfaa5172ca48f4a3ec3282045296 100644 (file)
@@ -3,28 +3,15 @@ package keepclient
 import (
        "crypto/md5"
        "fmt"
-       "gopkg.in/check.v1"
        "net/http"
        "os"
-       "time"
+
+       "gopkg.in/check.v1"
 
        "git.curoverse.com/arvados.git/sdk/go/arvadosclient"
        "git.curoverse.com/arvados.git/sdk/go/arvadostest"
 )
 
-func ExampleKeepClient_RefreshServices() {
-       arv, err := arvadosclient.MakeArvadosClient()
-       if err != nil {
-               panic(err)
-       }
-       kc, err := MakeKeepClient(arv)
-       if err != nil {
-               panic(err)
-       }
-       go kc.RefreshServices(5*time.Minute, 3*time.Second)
-       fmt.Printf("LocalRoots: %#v\n", kc.LocalRoots())
-}
-
 func (s *ServerRequiredSuite) TestOverrideDiscovery(c *check.C) {
        defer os.Setenv("ARVADOS_KEEP_SERVICES", "")
 
index b56cc7f724b3ba64ee26033f5ddd4b6f888f2422..029c6ee7f3a5834a8af149b64917aff2d5dc4bcb 100644 (file)
@@ -8,11 +8,13 @@ import (
        "fmt"
        "io"
        "io/ioutil"
+       "net"
        "net/http"
        "regexp"
        "strconv"
        "strings"
        "sync"
+       "time"
 
        "git.curoverse.com/arvados.git/sdk/go/arvadosclient"
        "git.curoverse.com/arvados.git/sdk/go/streamer"
@@ -21,6 +23,18 @@ import (
 // A Keep "block" is 64MB.
 const BLOCKSIZE = 64 * 1024 * 1024
 
+var (
+       DefaultRequestTimeout      = 20 * time.Second
+       DefaultConnectTimeout      = 2 * time.Second
+       DefaultTLSHandshakeTimeout = 4 * time.Second
+       DefaultKeepAlive           = 180 * time.Second
+
+       DefaultProxyRequestTimeout      = 300 * time.Second
+       DefaultProxyConnectTimeout      = 30 * time.Second
+       DefaultProxyTLSHandshakeTimeout = 10 * time.Second
+       DefaultProxyKeepAlive           = 120 * time.Second
+)
+
 // Error interface with an error and boolean indicating whether the error is temporary
 type Error interface {
        error
@@ -74,11 +88,11 @@ type HTTPClient interface {
 type KeepClient struct {
        Arvados            *arvadosclient.ArvadosClient
        Want_replicas      int
-       localRoots         *map[string]string
-       writableLocalRoots *map[string]string
-       gatewayRoots       *map[string]string
+       localRoots         map[string]string
+       writableLocalRoots map[string]string
+       gatewayRoots       map[string]string
        lock               sync.RWMutex
-       Client             HTTPClient
+       HTTPClient         HTTPClient
        Retries            int
        BlockCache         *BlockCache
 
@@ -87,16 +101,21 @@ type KeepClient struct {
 
        // Any non-disk typed services found in the list of keepservers?
        foundNonDiskSvc bool
+
+       // Disable automatic discovery of keep services
+       disableDiscovery bool
 }
 
-// MakeKeepClient creates a new KeepClient by contacting the API server to discover Keep servers.
+// MakeKeepClient creates a new KeepClient, calls
+// DiscoverKeepServices(), and returns when the client is ready to
+// use.
 func MakeKeepClient(arv *arvadosclient.ArvadosClient) (*KeepClient, error) {
        kc := New(arv)
-       return kc, kc.DiscoverKeepServers()
+       return kc, kc.discoverServices()
 }
 
-// New func creates a new KeepClient struct.
-// This func does not discover keep servers. It is the caller's responsibility.
+// New creates a new KeepClient. Service discovery will occur on the
+// next read/write operation.
 func New(arv *arvadosclient.ArvadosClient) *KeepClient {
        defaultReplicationLevel := 2
        value, err := arv.Discovery("defaultCollectionReplication")
@@ -106,15 +125,11 @@ func New(arv *arvadosclient.ArvadosClient) *KeepClient {
                        defaultReplicationLevel = int(v)
                }
        }
-
-       kc := &KeepClient{
+       return &KeepClient{
                Arvados:       arv,
                Want_replicas: defaultReplicationLevel,
-               Client: &http.Client{Transport: &http.Transport{
-                       TLSClientConfig: arvadosclient.MakeTLSConfig(arv.ApiInsecure)}},
-               Retries: 2,
+               Retries:       2,
        }
-       return kc
 }
 
 // Put a block given the block hash, a reader, and the number of bytes
@@ -204,7 +219,7 @@ func (kc *KeepClient) getOrHead(method string, locator string) (io.ReadCloser, i
                                continue
                        }
                        req.Header.Add("Authorization", fmt.Sprintf("OAuth2 %s", kc.Arvados.ApiToken))
-                       resp, err := kc.Client.Do(req)
+                       resp, err := kc.httpClient().Do(req)
                        if err != nil {
                                // Probably a network error, may be transient,
                                // can try again.
@@ -305,7 +320,7 @@ func (kc *KeepClient) GetIndex(keepServiceUUID, prefix string) (io.Reader, error
        }
 
        req.Header.Add("Authorization", fmt.Sprintf("OAuth2 %s", kc.Arvados.ApiToken))
-       resp, err := kc.Client.Do(req)
+       resp, err := kc.httpClient().Do(req)
        if err != nil {
                return nil, err
        }
@@ -336,55 +351,47 @@ func (kc *KeepClient) GetIndex(keepServiceUUID, prefix string) (io.Reader, error
 // LocalRoots() returns the map of local (i.e., disk and proxy) Keep
 // services: uuid -> baseURI.
 func (kc *KeepClient) LocalRoots() map[string]string {
+       kc.discoverServices()
        kc.lock.RLock()
        defer kc.lock.RUnlock()
-       return *kc.localRoots
+       return kc.localRoots
 }
 
 // GatewayRoots() returns the map of Keep remote gateway services:
 // uuid -> baseURI.
 func (kc *KeepClient) GatewayRoots() map[string]string {
+       kc.discoverServices()
        kc.lock.RLock()
        defer kc.lock.RUnlock()
-       return *kc.gatewayRoots
+       return kc.gatewayRoots
 }
 
 // WritableLocalRoots() returns the map of writable local Keep services:
 // uuid -> baseURI.
 func (kc *KeepClient) WritableLocalRoots() map[string]string {
+       kc.discoverServices()
        kc.lock.RLock()
        defer kc.lock.RUnlock()
-       return *kc.writableLocalRoots
+       return kc.writableLocalRoots
 }
 
-// SetServiceRoots updates the localRoots and gatewayRoots maps,
-// without risk of disrupting operations that are already in progress.
+// SetServiceRoots disables service discovery and updates the
+// localRoots and gatewayRoots maps, without disrupting operations
+// that are already in progress.
 //
-// The KeepClient makes its own copy of the supplied maps, so the
-// caller can reuse/modify them after SetServiceRoots returns, but
-// they should not be modified by any other goroutine while
-// SetServiceRoots is running.
-func (kc *KeepClient) SetServiceRoots(newLocals, newWritableLocals, newGateways map[string]string) {
-       locals := make(map[string]string)
-       for uuid, root := range newLocals {
-               locals[uuid] = root
-       }
-
-       writables := make(map[string]string)
-       for uuid, root := range newWritableLocals {
-               writables[uuid] = root
-       }
-
-       gateways := make(map[string]string)
-       for uuid, root := range newGateways {
-               gateways[uuid] = root
-       }
+// The supplied maps must not be modified after calling
+// SetServiceRoots.
+func (kc *KeepClient) SetServiceRoots(locals, writables, gateways map[string]string) {
+       kc.disableDiscovery = true
+       kc.setServiceRoots(locals, writables, gateways)
+}
 
+func (kc *KeepClient) setServiceRoots(locals, writables, gateways map[string]string) {
        kc.lock.Lock()
        defer kc.lock.Unlock()
-       kc.localRoots = &locals
-       kc.writableLocalRoots = &writables
-       kc.gatewayRoots = &gateways
+       kc.localRoots = locals
+       kc.writableLocalRoots = writables
+       kc.gatewayRoots = gateways
 }
 
 // getSortedRoots returns a list of base URIs of Keep services, in the
@@ -423,6 +430,80 @@ func (kc *KeepClient) cache() *BlockCache {
        }
 }
 
+var (
+       // There are four global http.Client objects for the four
+       // possible permutations of TLS behavior (verify/skip-verify)
+       // and timeout settings (proxy/non-proxy).
+       defaultClient = map[bool]map[bool]HTTPClient{
+               // defaultClient[false] is used for verified TLS reqs
+               false: {},
+               // defaultClient[true] is used for unverified
+               // (insecure) TLS reqs
+               true: {},
+       }
+       defaultClientMtx sync.Mutex
+)
+
+// httpClient returns the HTTPClient field if it's not nil, otherwise
+// whichever of the four global http.Client objects is suitable for
+// the current environment (i.e., TLS verification on/off, keep
+// services are/aren't proxies).
+func (kc *KeepClient) httpClient() HTTPClient {
+       if kc.HTTPClient != nil {
+               return kc.HTTPClient
+       }
+       defaultClientMtx.Lock()
+       defer defaultClientMtx.Unlock()
+       if c, ok := defaultClient[kc.Arvados.ApiInsecure][kc.foundNonDiskSvc]; ok {
+               return c
+       }
+
+       var requestTimeout, connectTimeout, keepAlive, tlsTimeout time.Duration
+       if kc.foundNonDiskSvc {
+               // Use longer timeouts when connecting to a proxy,
+               // because this usually means the intervening network
+               // is slower.
+               requestTimeout = DefaultProxyRequestTimeout
+               connectTimeout = DefaultProxyConnectTimeout
+               tlsTimeout = DefaultProxyTLSHandshakeTimeout
+               keepAlive = DefaultProxyKeepAlive
+       } else {
+               requestTimeout = DefaultRequestTimeout
+               connectTimeout = DefaultConnectTimeout
+               tlsTimeout = DefaultTLSHandshakeTimeout
+               keepAlive = DefaultKeepAlive
+       }
+
+       transport, ok := http.DefaultTransport.(*http.Transport)
+       if ok {
+               copy := *transport
+               transport = &copy
+       } else {
+               // Evidently the application has replaced
+               // http.DefaultTransport with a different type, so we
+               // need to build our own from scratch using the Go 1.8
+               // defaults.
+               transport = &http.Transport{
+                       MaxIdleConns:          100,
+                       IdleConnTimeout:       90 * time.Second,
+                       ExpectContinueTimeout: time.Second,
+               }
+       }
+       transport.DialContext = (&net.Dialer{
+               Timeout:   connectTimeout,
+               KeepAlive: keepAlive,
+               DualStack: true,
+       }).DialContext
+       transport.TLSHandshakeTimeout = tlsTimeout
+       transport.TLSClientConfig = arvadosclient.MakeTLSConfig(kc.Arvados.ApiInsecure)
+       c := &http.Client{
+               Timeout:   requestTimeout,
+               Transport: transport,
+       }
+       defaultClient[kc.Arvados.ApiInsecure][kc.foundNonDiskSvc] = c
+       return c
+}
+
 type Locator struct {
        Hash  string
        Size  int      // -1 if data size is not known
index fcae4131fc028e563f5eac4ed1fa1748c5a7f5da..724d7ff3214db2315053d5e94f09e5e91adb070e 100644 (file)
@@ -35,6 +35,10 @@ type ServerRequiredSuite struct{}
 // Standalone tests
 type StandaloneSuite struct{}
 
+func (s *StandaloneSuite) SetUpTest(c *C) {
+       RefreshServiceDiscovery()
+}
+
 func pythonDir() string {
        cwd, _ := os.Getwd()
        return fmt.Sprintf("%s/../../python/tests", cwd)
@@ -50,6 +54,10 @@ func (s *ServerRequiredSuite) TearDownSuite(c *C) {
        arvadostest.StopAPI()
 }
 
+func (s *ServerRequiredSuite) SetUpTest(c *C) {
+       RefreshServiceDiscovery()
+}
+
 func (s *ServerRequiredSuite) TestMakeKeepClient(c *C) {
        arv, err := arvadosclient.MakeArvadosClient()
        c.Assert(err, Equals, nil)
@@ -1067,12 +1075,14 @@ func (s *StandaloneSuite) TestGetIndexWithNoPrefix(c *C) {
        defer ks.listener.Close()
 
        arv, err := arvadosclient.MakeArvadosClient()
-       kc, _ := MakeKeepClient(arv)
+       c.Assert(err, IsNil)
+       kc, err := MakeKeepClient(arv)
+       c.Assert(err, IsNil)
        arv.ApiToken = "abc123"
        kc.SetServiceRoots(map[string]string{"x": ks.url}, nil, nil)
 
        r, err := kc.GetIndex("x", "")
-       c.Check(err, Equals, nil)
+       c.Check(err, IsNil)
 
        content, err2 := ioutil.ReadAll(r)
        c.Check(err2, Equals, nil)
@@ -1098,7 +1108,7 @@ func (s *StandaloneSuite) TestGetIndexWithPrefix(c *C) {
        kc.SetServiceRoots(map[string]string{"x": ks.url}, nil, nil)
 
        r, err := kc.GetIndex("x", hash[0:3])
-       c.Check(err, Equals, nil)
+       c.Assert(err, Equals, nil)
 
        content, err2 := ioutil.ReadAll(r)
        c.Check(err2, Equals, nil)
@@ -1237,6 +1247,7 @@ func (s *ServerRequiredSuite) TestMakeKeepClientWithNonDiskTypeService(c *C) {
                &blobKeepService)
        c.Assert(err, Equals, nil)
        defer func() { arv.Delete("keep_services", blobKeepService["uuid"].(string), nil, nil) }()
+       RefreshServiceDiscovery()
 
        // Make a keepclient and ensure that the testblobstore is included
        kc, err := MakeKeepClient(arv)
@@ -1265,5 +1276,5 @@ func (s *ServerRequiredSuite) TestMakeKeepClientWithNonDiskTypeService(c *C) {
 
        c.Assert(kc.replicasPerService, Equals, 0)
        c.Assert(kc.foundNonDiskSvc, Equals, true)
-       c.Assert(kc.Client.(*http.Client).Timeout, Equals, 300*time.Second)
+       c.Assert(kc.httpClient().(*http.Client).Timeout, Equals, 300*time.Second)
 }
index 33ba8720bc86363dab027c6481535bb9f74d26b4..8545cb80b855d9e53606042bfc940df7388e51eb 100644 (file)
@@ -8,13 +8,11 @@ import (
        "io/ioutil"
        "log"
        "math/rand"
-       "net"
        "net/http"
        "os"
-       "regexp"
        "strings"
-       "time"
 
+       "git.curoverse.com/arvados.git/sdk/go/arvadosclient"
        "git.curoverse.com/arvados.git/sdk/go/streamer"
 )
 
@@ -24,8 +22,7 @@ import (
 var DebugPrintf = func(string, ...interface{}) {}
 
 func init() {
-       var matchTrue = regexp.MustCompile("^(?i:1|yes|true)$")
-       if matchTrue.MatchString(os.Getenv("ARVADOS_DEBUG")) {
+       if arvadosclient.StringBool(os.Getenv("ARVADOS_DEBUG")) {
                DebugPrintf = log.Printf
        }
 }
@@ -44,50 +41,6 @@ func Md5String(s string) string {
        return fmt.Sprintf("%x", md5.Sum([]byte(s)))
 }
 
-// Set timeouts applicable when connecting to non-disk services
-// (assumed to be over the Internet).
-func (*KeepClient) setClientSettingsNonDisk(client *http.Client) {
-       // Maximum time to wait for a complete response
-       client.Timeout = 300 * time.Second
-
-       // TCP and TLS connection settings
-       client.Transport = &http.Transport{
-               Dial: (&net.Dialer{
-                       // The maximum time to wait to set up
-                       // the initial TCP connection.
-                       Timeout: 30 * time.Second,
-
-                       // The TCP keep alive heartbeat
-                       // interval.
-                       KeepAlive: 120 * time.Second,
-               }).Dial,
-
-               TLSHandshakeTimeout: 10 * time.Second,
-       }
-}
-
-// Set timeouts applicable when connecting to keepstore services directly
-// (assumed to be on the local network).
-func (*KeepClient) setClientSettingsDisk(client *http.Client) {
-       // Maximum time to wait for a complete response
-       client.Timeout = 20 * time.Second
-
-       // TCP and TLS connection timeouts
-       client.Transport = &http.Transport{
-               Dial: (&net.Dialer{
-                       // The maximum time to wait to set up
-                       // the initial TCP connection.
-                       Timeout: 2 * time.Second,
-
-                       // The TCP keep alive heartbeat
-                       // interval.
-                       KeepAlive: 180 * time.Second,
-               }).Dial,
-
-               TLSHandshakeTimeout: 4 * time.Second,
-       }
-}
-
 type svcList struct {
        Items []keepService `json:"items"`
 }
@@ -115,8 +68,8 @@ func (this *KeepClient) uploadToKeepServer(host string, hash string, body io.Rea
 
        req.ContentLength = expectedLength
        if expectedLength > 0 {
-               // http.Client.Do will close the body ReadCloser when it is
-               // done with it.
+               // Do() will close the body ReadCloser when it is done
+               // with it.
                req.Body = body
        } else {
                // "For client requests, a value of 0 means unknown if Body is
@@ -131,7 +84,7 @@ func (this *KeepClient) uploadToKeepServer(host string, hash string, body io.Rea
        req.Header.Add(X_Keep_Desired_Replicas, fmt.Sprint(this.Want_replicas))
 
        var resp *http.Response
-       if resp, err = this.Client.Do(req); err != nil {
+       if resp, err = this.httpClient().Do(req); err != nil {
                DebugPrintf("DEBUG: [%08x] Upload failed %v error: %v", requestID, url, err.Error())
                upload_status <- uploadStatus{err, url, 0, 0, ""}
                return
index 939ca212579c25c6e7f245fed6bf6e251fe4998a..5c09b1fccdf09f0af508490ac34803efd5b61237 100644 (file)
@@ -3,6 +3,16 @@ require "arvados/keep"
 class Arvados::V1::CollectionsController < ApplicationController
   include DbCurrentTime
 
+  def self._index_requires_parameters
+    (super rescue {}).
+      merge({
+        include_trash: {
+          type: 'boolean', required: false, description: "Include collections whose is_trashed attribute is true."
+        },
+      })
+  end
+
+
   def create
     if resource_attrs[:uuid] and (loc = Keep::Locator.parse(resource_attrs[:uuid]))
       resource_attrs[:portable_data_hash] = loc.to_s
@@ -12,7 +22,7 @@ class Arvados::V1::CollectionsController < ApplicationController
   end
 
   def find_objects_for_index
-    if params[:include_trash] || ['destroy', 'trash'].include?(action_name)
+    if params[:include_trash] || ['destroy', 'trash', 'untrash'].include?(action_name)
       @objects = Collection.unscoped.readable_by(*@read_users)
     end
     super
@@ -63,6 +73,15 @@ class Arvados::V1::CollectionsController < ApplicationController
     show
   end
 
+  def untrash
+    if @object.is_trashed
+      @object.update_attributes!(trash_at: nil)
+    else
+      raise InvalidStateTransitionError
+    end
+    show
+  end
+
   def find_collections(visited, sp, &b)
     case sp
     when ArvadosModel
index fc6489901a967a2dc667fc106e8f2178110420a8..fc80a652dc0da16f3ab82634467a66d0f447b08f 100644 (file)
@@ -6,6 +6,9 @@ class Arvados::V1::GroupsController < ApplicationController
               uuid: {
                 type: 'string', required: false, default: nil
               },
+              recursive: {
+                type: 'boolean', required: false, description: 'Include contents from child groups recursively.'
+              },
             })
     params.delete(:select)
     params
@@ -91,6 +94,15 @@ class Arvados::V1::GroupsController < ApplicationController
       end
     end
 
+    filter_by_owner = {}
+    if @object
+      if params['recursive']
+        filter_by_owner[:owner_uuid] = [@object.uuid] + @object.descendant_project_uuids
+      else
+        filter_by_owner[:owner_uuid] = @object.uuid
+      end
+    end
+
     seen_last_class = false
     klasses.each do |klass|
       @offset = 0 if seen_last_class  # reset offset for the new next type being processed
@@ -118,12 +130,11 @@ class Arvados::V1::GroupsController < ApplicationController
         klass.default_orders.join(", ")
 
       @select = nil
-      where_conds = {}
-      where_conds[:owner_uuid] = @object.uuid if @object
+      where_conds = filter_by_owner
       if klass == Collection
         @select = klass.selectable_attributes - ["manifest_text"]
       elsif klass == Group
-        where_conds[:group_class] = "project"
+        where_conds = where_conds.merge(group_class: "project")
       end
 
       @filters = request_filters.map do |col, op, val|
index d2a512bde75f68c790360813b47872b748481be1..e59c5f25789e4e83a9be0a215a784df6742d8eb2 100644 (file)
@@ -2,6 +2,7 @@ class Arvados::V1::KeepServicesController < ApplicationController
 
   skip_before_filter :find_object_by_uuid, only: :accessible
   skip_before_filter :render_404_if_no_object, only: :accessible
+  skip_before_filter :require_auth_scope, only: :accessible
 
   def find_objects_for_index
     # all users can list all keep services
index bb33c5595aea267c4dc996545f1d73d3006b3453..d1a0bc579499198c153f465b247fcb5b64d2cd6b 100644 (file)
@@ -250,7 +250,8 @@ class ArvadosModel < ActiveRecord::Base
 
     # Check if any of the users are admin.  If so, we're done.
     if users_list.select { |u| u.is_admin }.any?
-      return self
+      # Return existing relation with no new filters.
+      return where({})
     end
 
     # Collect the UUIDs of the authorized users.
index fa38ece244afb52b2c2159f8cad6950f6b16468c..14caea42c6aa9c3f91bfe77db5a46ce095a95fe7 100644 (file)
@@ -261,6 +261,7 @@ class Job < ArvadosModel
     log_reuse_info(candidates) { "after filtering on repo, script, and custom filters #{filters.inspect}" }
 
     chosen = nil
+    chosen_output = nil
     incomplete_job = nil
     candidates.each do |j|
       if j.state != Job::Complete
@@ -275,17 +276,25 @@ class Job < ArvadosModel
         # Ignore: we have already decided not to reuse any completed
         # job.
         log_reuse_info { "job #{j.uuid} with output #{j.output} ignored, see above" }
+      elsif j.output.nil?
+        log_reuse_info { "job #{j.uuid} has nil output" }
+      elsif j.log.nil?
+        log_reuse_info { "job #{j.uuid} has nil log" }
       elsif Rails.configuration.reuse_job_if_outputs_differ
-        if Collection.readable_by(current_user).find_by_portable_data_hash(j.output)
-          log_reuse_info { "job #{j.uuid} with output #{j.output} is reusable; decision is final." }
-          return j
-        else
-          # Ignore: keep locking for an incomplete job or one whose
+        if !Collection.readable_by(current_user).find_by_portable_data_hash(j.output)
+          # Ignore: keep looking for an incomplete job or one whose
           # output is readable.
           log_reuse_info { "job #{j.uuid} output #{j.output} unavailable to user; continuing search" }
+        elsif !Collection.readable_by(current_user).find_by_portable_data_hash(j.log)
+          # Ignore: keep looking for an incomplete job or one whose
+          # log is readable.
+          log_reuse_info { "job #{j.uuid} log #{j.log} unavailable to user; continuing search" }
+        else
+          log_reuse_info { "job #{j.uuid} with output #{j.output} is reusable; decision is final." }
+          return j
         end
-      elsif chosen
-        if chosen.output != j.output
+      elsif chosen_output
+        if chosen_output != j.output
           # If two matching jobs produced different outputs, run a new
           # job (or use one that's already running/queued) instead of
           # choosing one arbitrarily.
@@ -304,9 +313,15 @@ class Job < ArvadosModel
         # any further investigation of reusable jobs is futile.
         log_reuse_info { "job #{j.uuid} output #{j.output} is unavailable to user; this means no finished job can be reused (see reuse_job_if_outputs_differ in application.default.yml)" }
         chosen = false
+      elsif !Collection.readable_by(current_user).find_by_portable_data_hash(j.log)
+        # This user cannot read the log of this job, don't try to reuse the
+        # job but consider if the output is consistent.
+        log_reuse_info { "job #{j.uuid} log #{j.log} is unavailable to user; continuing search" }
+        chosen_output = j.output
       else
         log_reuse_info { "job #{j.uuid} with output #{j.output} can be reused; continuing search in case other candidates have different outputs" }
         chosen = j
+        chosen_output = j.output
       end
     end
     j = chosen || incomplete_job
index 742db4c9b02282aecbf824bf087c7f2eb2c76dc8..d944474712708b3207f28807e10349b8f34007b0 100644 (file)
@@ -144,33 +144,20 @@ class User < ArvadosModel
   # and perm_hash[:write] are true if this user can read and write
   # objects owned by group_uuid.
   def calculate_group_permissions
-    conn = ActiveRecord::Base.connection
-    self.class.transaction do
-      # Check whether the temporary view has already been created
-      # during this connection. If not, create it.
-      conn.exec_query 'SAVEPOINT check_permission_view'
-      begin
-        conn.exec_query('SELECT 1 FROM permission_view LIMIT 0')
-      rescue
-        conn.exec_query 'ROLLBACK TO SAVEPOINT check_permission_view'
-        sql = File.read(Rails.root.join('lib', 'create_permission_view.sql'))
-        conn.exec_query(sql)
-      ensure
-        conn.exec_query 'RELEASE SAVEPOINT check_permission_view'
-      end
-    end
+    install_view('permission')
 
     group_perms = {}
-    conn.exec_query('SELECT target_owner_uuid, max(perm_level)
-                    FROM permission_view
-                    WHERE user_uuid = $1
-                    AND target_owner_uuid IS NOT NULL
-                    GROUP BY target_owner_uuid',
-                    # "name" arg is a query label that appears in logs:
-                    "group_permissions for #{uuid}",
-                    # "binds" arg is an array of [col_id, value] for '$1' vars:
-                    [[nil, uuid]],
-                    ).rows.each do |group_uuid, max_p_val|
+    ActiveRecord::Base.connection.
+      exec_query('SELECT target_owner_uuid, max(perm_level)
+                  FROM permission_view
+                  WHERE user_uuid = $1
+                  AND target_owner_uuid IS NOT NULL
+                  GROUP BY target_owner_uuid',
+                  # "name" arg is a query label that appears in logs:
+                  "group_permissions for #{uuid}",
+                  # "binds" arg is an array of [col_id, value] for '$1' vars:
+                  [[nil, uuid]],
+                  ).rows.each do |group_uuid, max_p_val|
       group_perms[group_uuid] = PERMS_FOR_VAL[max_p_val.to_i]
     end
     Rails.cache.write "groups_for_user_#{self.uuid}", group_perms
index 77e5372a15423686d3597095c455d90513779bc9..87c4d91757a9daf9d086b56bf6ffcb0f6066cc71 100644 (file)
@@ -21,6 +21,7 @@ Server::Application.routes.draw do
         get 'provenance', on: :member
         get 'used_by', on: :member
         post 'trash', on: :member
+        post 'untrash', on: :member
       end
       resources :groups do
         get 'contents', on: :collection
index 75a63509c206ac86fa64e39afa990073350a87cb..e9f016dc051a06ec09f9aa071ab98fc0a15aa236 100644 (file)
@@ -22,6 +22,22 @@ module CanBeAnOwner
     base.validate :restrict_uuid_change_breaking_associations
   end
 
+  def descendant_project_uuids
+    install_view('ancestor')
+    ActiveRecord::Base.connection.
+      exec_query('SELECT ancestor_view.uuid
+                  FROM ancestor_view
+                  LEFT JOIN groups ON groups.uuid=ancestor_view.uuid
+                  WHERE ancestor_uuid = $1 AND groups.group_class = $2',
+                  # "name" arg is a query label that appears in logs:
+                  "descendant_project_uuids for #{self.uuid}",
+                  # "binds" arg is an array of [col_id, value] for '$1' vars:
+                  [[nil, self.uuid], [nil, 'project']],
+                  ).rows.map do |project_uuid,|
+      project_uuid
+    end
+  end
+
   protected
 
   def restrict_uuid_change_breaking_associations
@@ -44,4 +60,21 @@ module CanBeAnOwner
     end
   end
 
+  def install_view(type)
+    conn = ActiveRecord::Base.connection
+    self.class.transaction do
+      # Check whether the temporary view has already been created
+      # during this connection. If not, create it.
+      conn.exec_query "SAVEPOINT check_#{type}_view"
+      begin
+        conn.exec_query("SELECT 1 FROM #{type}_view LIMIT 0")
+      rescue
+        conn.exec_query "ROLLBACK TO SAVEPOINT check_#{type}_view"
+        sql = File.read(Rails.root.join("lib", "create_#{type}_view.sql"))
+        conn.exec_query(sql)
+      ensure
+        conn.exec_query "RELEASE SAVEPOINT check_#{type}_view"
+      end
+    end
+  end
 end
diff --git a/services/api/lib/create_ancestor_view.sql b/services/api/lib/create_ancestor_view.sql
new file mode 100644 (file)
index 0000000..105fd04
--- /dev/null
@@ -0,0 +1,14 @@
+CREATE TEMPORARY VIEW ancestor_view AS
+WITH RECURSIVE
+ancestor (uuid, ancestor_uuid) AS (
+     SELECT groups.uuid::varchar(32)       AS uuid,
+            groups.owner_uuid::varchar(32) AS ancestor_uuid
+            FROM groups
+     UNION
+     SELECT ancestor.uuid::varchar(32)     AS uuid,
+            groups.owner_uuid::varchar(32) AS ancestor_uuid
+            FROM ancestor
+            INNER JOIN groups
+            ON groups.uuid = ancestor.ancestor_uuid
+)
+SELECT * FROM ancestor;
index f48fbf1b8542d0f35fd37888c778fd264e820e7a..8aedbdc7057bc1d74d0749477a6a9c3c62b9624b 100644 (file)
@@ -297,7 +297,7 @@ unique_expired_collection:
   trash_at: 2001-01-01T00:00:00Z
   delete_at: 2038-01-01T00:00:00Z
   manifest_text: ". 29d7797f1888013986899bc9083783fa+3 0:3:expired\n"
-  name: unique_expired_collection
+  name: unique_expired_collection1
 
 unique_expired_collection2:
   uuid: zzzzz-4zz18-mto52zx1s7sn3jr
index 809ba0e138b584f2f60200c2046bcb88ba123911..abd2dcaa2630138dfaea523d6eda6db0520aebe3 100644 (file)
@@ -183,6 +183,23 @@ previous_job_run:
   state: Complete
   script_parameters_digest: a5f03bbfb8ba88a2efe4a7852671605b
 
+previous_job_run_nil_log:
+  uuid: zzzzz-8i9sb-cjs4pklxxjykqq3
+  created_at: <%= 14.minute.ago.to_s(:db) %>
+  finished_at: <%= 13.minutes.ago.to_s(:db) %>
+  owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
+  repository: active/foo
+  script: hash
+  script_version: 4fe459abe02d9b365932b8f5dc419439ab4e2577
+  script_parameters:
+    input: fa7aeb5140e2848d39b416daeef4ffc5+45
+    an_integer: "3"
+  success: true
+  log: ~
+  output: ea10d51bcf88862dbcc36eb292017dfd+45
+  state: Complete
+  script_parameters_digest: 445702df4029b8a6e7075b451ff1256a
+
 previous_ancient_job_run:
   uuid: zzzzz-8i9sb-ahd7cie8jah9qui
   created_at: <%= 366.days.ago.to_s(:db) %>
@@ -217,6 +234,7 @@ previous_docker_job_run:
   docker_image_locator: fa3c1a9cb6783f85f2ecda037e07b8c3+167
   state: Complete
   script_parameters_digest: a5f03bbfb8ba88a2efe4a7852671605b
+  log: ea10d51bcf88862dbcc36eb292017dfd+45
 
 previous_ancient_docker_image_job_run:
   uuid: zzzzz-8i9sb-t3b460aolxxuldl
@@ -255,6 +273,7 @@ previous_job_run_with_arvados_sdk_version:
   output: ea10d51bcf88862dbcc36eb292017dfd+45
   state: Complete
   script_parameters_digest: a5f03bbfb8ba88a2efe4a7852671605b
+  log: ea10d51bcf88862dbcc36eb292017dfd+45
 
 previous_job_run_no_output:
   uuid: zzzzz-8i9sb-cjs4pklxxjykppp
index a31ad8af03b09fd54dd80360ad8eefa65328e298..17af916b3df244821bc596bbbc58fad799d8d380 100644 (file)
@@ -1024,4 +1024,45 @@ EOS
       assert_operator c.delete_at, :>=, time_before_trashing + Rails.configuration.default_trash_lifetime
     end
   end
+
+  test 'untrash a trashed collection' do
+    authorize_with :active
+    post :untrash, {
+      id: collections(:expired_collection).uuid,
+    }
+    assert_response 200
+    assert_equal false, json_response['is_trashed']
+    assert_nil json_response['trash_at']
+  end
+
+  test 'untrash error on not trashed collection' do
+    authorize_with :active
+    post :untrash, {
+      id: collections(:collection_owned_by_active).uuid,
+    }
+    assert_response 422
+  end
+
+  [:active, :admin].each do |user|
+    test "get trashed collections as #{user}" do
+      authorize_with user
+      get :index, {
+        filters: [["is_trashed", "=", true]],
+        include_trash: true,
+      }
+      assert_response :success
+
+      items = []
+      json_response["items"].each do |coll|
+        items << coll['uuid']
+      end
+
+      assert_includes(items, collections('unique_expired_collection')['uuid'])
+      if user == :admin
+        assert_includes(items, collections('unique_expired_collection2')['uuid'])
+      else
+        assert_not_includes(items, collections('unique_expired_collection2')['uuid'])
+      end
+    end
+  end
 end
index 02d8c153a8abf6157d099415a6f6553ac1816b71..3beec35958b45dc63611e9fcf393e98dff2575ef 100644 (file)
@@ -477,4 +477,51 @@ class Arvados::V1::GroupsControllerTest < ActionController::TestCase
     assert_operator(json_response['items'].count,
                     :<, json_response['items_available'])
   end
+
+  test 'get contents, recursive=true' do
+    authorize_with :active
+    params = {
+      id: groups(:aproject).uuid,
+      recursive: true,
+      format: :json,
+    }
+    get :contents, params
+    owners = json_response['items'].map do |item|
+      item['owner_uuid']
+    end
+    assert_includes(owners, groups(:aproject).uuid)
+    assert_includes(owners, groups(:asubproject).uuid)
+  end
+
+  [false, nil].each do |recursive|
+    test "get contents, recursive=#{recursive.inspect}" do
+      authorize_with :active
+      params = {
+        id: groups(:aproject).uuid,
+        format: :json,
+      }
+      params[:recursive] = false if recursive == false
+      get :contents, params
+      owners = json_response['items'].map do |item|
+        item['owner_uuid']
+      end
+      assert_includes(owners, groups(:aproject).uuid)
+      refute_includes(owners, groups(:asubproject).uuid)
+    end
+  end
+
+  test 'get home project contents, recursive=true' do
+    authorize_with :active
+    get :contents, {
+          id: users(:active).uuid,
+          recursive: true,
+          format: :json,
+        }
+    owners = json_response['items'].map do |item|
+      item['owner_uuid']
+    end
+    assert_includes(owners, users(:active).uuid)
+    assert_includes(owners, groups(:aproject).uuid)
+    assert_includes(owners, groups(:asubproject).uuid)
+  end
 end
index 8007fd26f8c8b64bf1295d8c7be091fed42cc1d6..afd05124cbbd162591829c1ba2b6e22511dc6955 100644 (file)
@@ -50,6 +50,26 @@ class Arvados::V1::JobReuseControllerTest < ActionController::TestCase
     assert_equal '4fe459abe02d9b365932b8f5dc419439ab4e2577', new_job['script_version']
   end
 
+  test "no reuse job with null log" do
+    post :create, {
+      job: {
+        script: "hash",
+        script_version: "4fe459abe02d9b365932b8f5dc419439ab4e2577",
+        repository: "active/foo",
+        script_parameters: {
+          input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
+          an_integer: '3'
+        }
+      },
+      find_or_create: true
+    }
+    assert_response :success
+    assert_not_nil assigns(:object)
+    new_job = JSON.parse(@response.body)
+    assert_not_equal 'zzzzz-8i9sb-cjs4pklxxjykqq3', new_job['uuid']
+    assert_equal '4fe459abe02d9b365932b8f5dc419439ab4e2577', new_job['script_version']
+  end
+
   test "reuse job with symbolic script_version" do
     post :create, {
       job: {
index 1375d4c9ce71549bdb43988cf4808eb70a908bf2..706f73ffda6157812e9cb687c9340f344464c663 100644 (file)
@@ -20,9 +20,9 @@ class Arvados::V1::KeepServicesControllerTest < ActionController::TestCase
     assert_equal true, assigns(:objects).any?
   end
 
-  [:admin, :active, :inactive, :anonymous].each do |u|
-    test "accessible to #{u} user" do
-      authorize_with u
+  [:admin, :active, :inactive, :anonymous, nil].each do |u|
+    test "accessible to #{u.inspect} user" do
+      authorize_with(u) if u
       get :accessible
       assert_response :success
       assert_not_empty json_response['items']
index 710182174621b0bcf1eaddc8432b4ca824182949..57dffca67cfcd33117e79bb760a58bcbd31d3e69 100644 (file)
@@ -61,4 +61,38 @@ class Arvados::V1::SchemaControllerTest < ActionController::TestCase
       refute_includes(discovery_doc['resources'][r]['methods'].keys(), 'create')
     end
   end
+
+  test "groups contents parameters" do
+    get :index
+    assert_response :success
+
+    discovery_doc = JSON.parse(@response.body)
+
+    group_index_params = discovery_doc['resources']['groups']['methods']['index']['parameters']
+    group_contents_params = discovery_doc['resources']['groups']['methods']['contents']['parameters']
+
+    assert_equal group_contents_params.keys.sort, (group_index_params.keys - ['select'] + ['uuid', 'recursive']).sort
+
+    recursive_param = group_contents_params['recursive']
+    assert_equal 'boolean', recursive_param['type']
+    assert_equal false, recursive_param['required']
+    assert_equal 'query', recursive_param['location']
+  end
+
+  test "collections index parameters" do
+    get :index
+    assert_response :success
+
+    discovery_doc = JSON.parse(@response.body)
+
+    specimens_index_params = discovery_doc['resources']['specimens']['methods']['index']['parameters']  # no changes from super
+    coll_index_params = discovery_doc['resources']['collections']['methods']['index']['parameters']
+
+    assert_equal coll_index_params.keys.sort, (specimens_index_params.keys + ['include_trash']).sort
+
+    include_trash_param = coll_index_params['include_trash']
+    assert_equal 'boolean', include_trash_param['type']
+    assert_equal false, include_trash_param['required']
+    assert_equal 'query', include_trash_param['location']
+  end
 end
index 148b783788e1df83afd75fdb2ed2615d60e76b84..cca1d85c824290eb304531cdee9cc1b27ba5f326 100644 (file)
@@ -3,7 +3,6 @@ package main
 import (
        "bytes"
        "log"
-       "net/http"
        "os"
        "strings"
        "testing"
@@ -35,11 +34,9 @@ func (s *integrationSuite) SetUpSuite(c *check.C) {
        arv, err := arvadosclient.MakeArvadosClient()
        arv.ApiToken = arvadostest.DataManagerToken
        c.Assert(err, check.IsNil)
-       s.keepClient = &keepclient.KeepClient{
-               Arvados: arv,
-               Client:  &http.Client{},
-       }
-       c.Assert(s.keepClient.DiscoverKeepServers(), check.IsNil)
+
+       s.keepClient, err = keepclient.MakeKeepClient(arv)
+       c.Assert(err, check.IsNil)
        s.putReplicas(c, "foo", 4)
        s.putReplicas(c, "bar", 1)
 }
diff --git a/services/keep-web/cache.go b/services/keep-web/cache.go
new file mode 100644 (file)
index 0000000..ab7c653
--- /dev/null
@@ -0,0 +1,259 @@
+package main
+
+import (
+       "fmt"
+       "sync"
+       "sync/atomic"
+       "time"
+
+       "git.curoverse.com/arvados.git/sdk/go/arvados"
+       "git.curoverse.com/arvados.git/sdk/go/arvadosclient"
+       "github.com/hashicorp/golang-lru"
+)
+
+type cache struct {
+       TTL                  arvados.Duration
+       MaxCollectionEntries int
+       MaxCollectionBytes   int64
+       MaxPermissionEntries int
+       MaxUUIDEntries       int
+
+       stats       cacheStats
+       pdhs        *lru.TwoQueueCache
+       collections *lru.TwoQueueCache
+       permissions *lru.TwoQueueCache
+       setupOnce   sync.Once
+}
+
+type cacheStats struct {
+       Requests          uint64 `json:"Cache.Requests"`
+       CollectionBytes   uint64 `json:"Cache.CollectionBytes"`
+       CollectionEntries int    `json:"Cache.CollectionEntries"`
+       CollectionHits    uint64 `json:"Cache.CollectionHits"`
+       PDHHits           uint64 `json:"Cache.UUIDHits"`
+       PermissionHits    uint64 `json:"Cache.PermissionHits"`
+       APICalls          uint64 `json:"Cache.APICalls"`
+}
+
+type cachedPDH struct {
+       expire time.Time
+       pdh    string
+}
+
+type cachedCollection struct {
+       expire     time.Time
+       collection map[string]interface{}
+}
+
+type cachedPermission struct {
+       expire time.Time
+}
+
+func (c *cache) setup() {
+       var err error
+       c.pdhs, err = lru.New2Q(c.MaxUUIDEntries)
+       if err != nil {
+               panic(err)
+       }
+       c.collections, err = lru.New2Q(c.MaxCollectionEntries)
+       if err != nil {
+               panic(err)
+       }
+       c.permissions, err = lru.New2Q(c.MaxPermissionEntries)
+       if err != nil {
+               panic(err)
+       }
+}
+
+var selectPDH = map[string]interface{}{
+       "select": []string{"portable_data_hash"},
+}
+
+func (c *cache) Stats() cacheStats {
+       c.setupOnce.Do(c.setup)
+       return cacheStats{
+               Requests:          atomic.LoadUint64(&c.stats.Requests),
+               CollectionBytes:   c.collectionBytes(),
+               CollectionEntries: c.collections.Len(),
+               CollectionHits:    atomic.LoadUint64(&c.stats.CollectionHits),
+               PDHHits:           atomic.LoadUint64(&c.stats.PDHHits),
+               PermissionHits:    atomic.LoadUint64(&c.stats.PermissionHits),
+               APICalls:          atomic.LoadUint64(&c.stats.APICalls),
+       }
+}
+
+func (c *cache) Get(arv *arvadosclient.ArvadosClient, targetID string, forceReload bool) (map[string]interface{}, error) {
+       c.setupOnce.Do(c.setup)
+
+       atomic.AddUint64(&c.stats.Requests, 1)
+
+       permOK := false
+       permKey := arv.ApiToken + "\000" + targetID
+       if forceReload {
+       } else if ent, cached := c.permissions.Get(permKey); cached {
+               ent := ent.(*cachedPermission)
+               if ent.expire.Before(time.Now()) {
+                       c.permissions.Remove(permKey)
+               } else {
+                       permOK = true
+                       atomic.AddUint64(&c.stats.PermissionHits, 1)
+               }
+       }
+
+       var pdh string
+       if arvadosclient.PDHMatch(targetID) {
+               pdh = targetID
+       } else if forceReload {
+       } else if ent, cached := c.pdhs.Get(targetID); cached {
+               ent := ent.(*cachedPDH)
+               if ent.expire.Before(time.Now()) {
+                       c.pdhs.Remove(targetID)
+               } else {
+                       pdh = ent.pdh
+                       atomic.AddUint64(&c.stats.PDHHits, 1)
+               }
+       }
+
+       var collection map[string]interface{}
+       if pdh != "" {
+               collection = c.lookupCollection(pdh)
+       }
+
+       if collection != nil && permOK {
+               return collection, nil
+       } else if collection != nil {
+               // Ask API for current PDH for this targetID. Most
+               // likely, the cached PDH is still correct; if so,
+               // _and_ the current token has permission, we can
+               // use our cached manifest.
+               atomic.AddUint64(&c.stats.APICalls, 1)
+               var current map[string]interface{}
+               err := arv.Get("collections", targetID, selectPDH, &current)
+               if err != nil {
+                       return nil, err
+               }
+               if checkPDH, ok := current["portable_data_hash"].(string); !ok {
+                       return nil, fmt.Errorf("API response for %q had no PDH", targetID)
+               } else if checkPDH == pdh {
+                       exp := time.Now().Add(time.Duration(c.TTL))
+                       c.permissions.Add(permKey, &cachedPermission{
+                               expire: exp,
+                       })
+                       if pdh != targetID {
+                               c.pdhs.Add(targetID, &cachedPDH{
+                                       expire: exp,
+                                       pdh:    pdh,
+                               })
+                       }
+                       return collection, err
+               } else {
+                       // PDH changed, but now we know we have
+                       // permission -- and maybe we already have the
+                       // new PDH in the cache.
+                       if coll := c.lookupCollection(checkPDH); coll != nil {
+                               return coll, nil
+                       }
+               }
+       }
+
+       // Collection manifest is not cached.
+       atomic.AddUint64(&c.stats.APICalls, 1)
+       err := arv.Get("collections", targetID, nil, &collection)
+       if err != nil {
+               return nil, err
+       }
+       pdh, ok := collection["portable_data_hash"].(string)
+       if !ok {
+               return nil, fmt.Errorf("API response for %q had no PDH", targetID)
+       }
+       exp := time.Now().Add(time.Duration(c.TTL))
+       c.permissions.Add(permKey, &cachedPermission{
+               expire: exp,
+       })
+       c.pdhs.Add(targetID, &cachedPDH{
+               expire: exp,
+               pdh:    pdh,
+       })
+       c.collections.Add(pdh, &cachedCollection{
+               expire:     exp,
+               collection: collection,
+       })
+       if int64(len(collection["manifest_text"].(string))) > c.MaxCollectionBytes/int64(c.MaxCollectionEntries) {
+               go c.pruneCollections()
+       }
+       return collection, nil
+}
+
+// pruneCollections checks the total bytes occupied by manifest_text
+// in the collection cache and removes old entries as needed to bring
+// the total size down to CollectionBytes. It also deletes all expired
+// entries.
+//
+// pruneCollections does not aim to be perfectly correct when there is
+// concurrent cache activity.
+func (c *cache) pruneCollections() {
+       var size int64
+       now := time.Now()
+       keys := c.collections.Keys()
+       entsize := make([]int, len(keys))
+       expired := make([]bool, len(keys))
+       for i, k := range keys {
+               v, ok := c.collections.Peek(k)
+               if !ok {
+                       continue
+               }
+               ent := v.(*cachedCollection)
+               n := len(ent.collection["manifest_text"].(string))
+               size += int64(n)
+               entsize[i] = n
+               expired[i] = ent.expire.Before(now)
+       }
+       for i, k := range keys {
+               if expired[i] {
+                       c.collections.Remove(k)
+                       size -= int64(entsize[i])
+               }
+       }
+       for i, k := range keys {
+               if size <= c.MaxCollectionBytes {
+                       break
+               }
+               if expired[i] {
+                       // already removed this entry in the previous loop
+                       continue
+               }
+               c.collections.Remove(k)
+               size -= int64(entsize[i])
+       }
+}
+
+// collectionBytes returns the approximate memory size of the
+// collection cache.
+func (c *cache) collectionBytes() uint64 {
+       var size uint64
+       for _, k := range c.collections.Keys() {
+               v, ok := c.collections.Peek(k)
+               if !ok {
+                       continue
+               }
+               size += uint64(len(v.(*cachedCollection).collection["manifest_text"].(string)))
+       }
+       return size
+}
+
+func (c *cache) lookupCollection(pdh string) map[string]interface{} {
+       if pdh == "" {
+               return nil
+       } else if ent, cached := c.collections.Get(pdh); !cached {
+               return nil
+       } else {
+               ent := ent.(*cachedCollection)
+               if ent.expire.Before(time.Now()) {
+                       c.collections.Remove(pdh)
+                       return nil
+               } else {
+                       atomic.AddUint64(&c.stats.CollectionHits, 1)
+                       return ent.collection
+               }
+       }
+}
diff --git a/services/keep-web/cache_test.go b/services/keep-web/cache_test.go
new file mode 100644 (file)
index 0000000..f8aa2b1
--- /dev/null
@@ -0,0 +1,104 @@
+package main
+
+import (
+       "git.curoverse.com/arvados.git/sdk/go/arvadosclient"
+       "git.curoverse.com/arvados.git/sdk/go/arvadostest"
+       "gopkg.in/check.v1"
+)
+
+func (s *UnitSuite) TestCache(c *check.C) {
+       arv, err := arvadosclient.MakeArvadosClient()
+       c.Assert(err, check.Equals, nil)
+
+       cache := DefaultConfig().Cache
+
+       // Hit the same collection 5 times using the same token. Only
+       // the first req should cause an API call; the next 4 should
+       // hit all caches.
+       arv.ApiToken = arvadostest.AdminToken
+       for i := 0; i < 5; i++ {
+               coll, err := cache.Get(arv, arvadostest.FooCollection, false)
+               c.Check(err, check.Equals, nil)
+               c.Assert(coll, check.NotNil)
+               c.Check(coll["portable_data_hash"], check.Equals, arvadostest.FooPdh)
+               c.Check(coll["manifest_text"].(string)[:2], check.Equals, ". ")
+       }
+       c.Check(cache.Stats().Requests, check.Equals, uint64(5))
+       c.Check(cache.Stats().CollectionHits, check.Equals, uint64(4))
+       c.Check(cache.Stats().PermissionHits, check.Equals, uint64(4))
+       c.Check(cache.Stats().PDHHits, check.Equals, uint64(4))
+       c.Check(cache.Stats().APICalls, check.Equals, uint64(1))
+
+       // Hit the same collection 2 more times, this time requesting
+       // it by PDH and using a different token. The first req should
+       // miss the permission cache. Both reqs should hit the
+       // Collection cache and skip the API lookup.
+       arv.ApiToken = arvadostest.ActiveToken
+       for i := 0; i < 2; i++ {
+               coll, err := cache.Get(arv, arvadostest.FooPdh, false)
+               c.Check(err, check.Equals, nil)
+               c.Assert(coll, check.NotNil)
+               c.Check(coll["portable_data_hash"], check.Equals, arvadostest.FooPdh)
+               c.Check(coll["manifest_text"].(string)[:2], check.Equals, ". ")
+       }
+       c.Check(cache.Stats().Requests, check.Equals, uint64(5+2))
+       c.Check(cache.Stats().CollectionHits, check.Equals, uint64(4+2))
+       c.Check(cache.Stats().PermissionHits, check.Equals, uint64(4+1))
+       c.Check(cache.Stats().PDHHits, check.Equals, uint64(4+0))
+       c.Check(cache.Stats().APICalls, check.Equals, uint64(1+1))
+
+       // Alternating between two collections N times should produce
+       // only 2 more API calls.
+       arv.ApiToken = arvadostest.AdminToken
+       for i := 0; i < 20; i++ {
+               var target string
+               if i%2 == 0 {
+                       target = arvadostest.HelloWorldCollection
+               } else {
+                       target = arvadostest.FooBarDirCollection
+               }
+               _, err := cache.Get(arv, target, false)
+               c.Check(err, check.Equals, nil)
+       }
+       c.Check(cache.Stats().Requests, check.Equals, uint64(5+2+20))
+       c.Check(cache.Stats().CollectionHits, check.Equals, uint64(4+2+18))
+       c.Check(cache.Stats().PermissionHits, check.Equals, uint64(4+1+18))
+       c.Check(cache.Stats().PDHHits, check.Equals, uint64(4+0+18))
+       c.Check(cache.Stats().APICalls, check.Equals, uint64(1+1+2))
+}
+
+func (s *UnitSuite) TestCacheForceReloadByPDH(c *check.C) {
+       arv, err := arvadosclient.MakeArvadosClient()
+       c.Assert(err, check.Equals, nil)
+
+       cache := DefaultConfig().Cache
+
+       for _, forceReload := range []bool{false, true, false, true} {
+               _, err := cache.Get(arv, arvadostest.FooPdh, forceReload)
+               c.Check(err, check.Equals, nil)
+       }
+
+       c.Check(cache.Stats().Requests, check.Equals, uint64(4))
+       c.Check(cache.Stats().CollectionHits, check.Equals, uint64(3))
+       c.Check(cache.Stats().PermissionHits, check.Equals, uint64(1))
+       c.Check(cache.Stats().PDHHits, check.Equals, uint64(0))
+       c.Check(cache.Stats().APICalls, check.Equals, uint64(3))
+}
+
+func (s *UnitSuite) TestCacheForceReloadByUUID(c *check.C) {
+       arv, err := arvadosclient.MakeArvadosClient()
+       c.Assert(err, check.Equals, nil)
+
+       cache := DefaultConfig().Cache
+
+       for _, forceReload := range []bool{false, true, false, true} {
+               _, err := cache.Get(arv, arvadostest.FooCollection, forceReload)
+               c.Check(err, check.Equals, nil)
+       }
+
+       c.Check(cache.Stats().Requests, check.Equals, uint64(4))
+       c.Check(cache.Stats().CollectionHits, check.Equals, uint64(1))
+       c.Check(cache.Stats().PermissionHits, check.Equals, uint64(1))
+       c.Check(cache.Stats().PDHHits, check.Equals, uint64(1))
+       c.Check(cache.Stats().APICalls, check.Equals, uint64(3))
+}
index 008876488b97aa675e30541754ec4e8c814ffe8f..42c37b8eebf947bea060ef2ace9a68b1fcca67ad 100644 (file)
@@ -1,6 +1,7 @@
 package main
 
 import (
+       "encoding/json"
        "fmt"
        "html"
        "io"
@@ -64,6 +65,16 @@ func parseCollectionIDFromURL(s string) string {
 
 func (h *handler) setup() {
        h.clientPool = arvadosclient.MakeClientPool()
+       keepclient.RefreshServiceDiscoveryOnSIGHUP()
+}
+
+func (h *handler) serveStatus(w http.ResponseWriter, r *http.Request) {
+       status := struct {
+               cacheStats
+       }{
+               cacheStats: h.Config.Cache.Stats(),
+       }
+       json.NewEncoder(w).Encode(status)
 }
 
 // ServeHTTP implements http.Handler.
@@ -150,6 +161,9 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                // http://ID.collections.example/PATH...
                credentialsOK = true
                targetPath = pathParts
+       } else if r.URL.Path == "/status.json" {
+               h.serveStatus(w, r)
+               return
        } else if len(pathParts) >= 2 && strings.HasPrefix(pathParts[0], "c=") {
                // /c=ID/PATH...
                targetID = parseCollectionIDFromURL(pathParts[0][2:])
@@ -274,11 +288,17 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                targetPath = targetPath[1:]
        }
 
+       forceReload := false
+       if cc := r.Header.Get("Cache-Control"); strings.Contains(cc, "no-cache") || strings.Contains(cc, "must-revalidate") {
+               forceReload = true
+       }
+
+       var collection map[string]interface{}
        tokenResult := make(map[string]int)
-       collection := make(map[string]interface{})
        found := false
        for _, arv.ApiToken = range tokens {
-               err := arv.Get("collections", targetID, nil, &collection)
+               var err error
+               collection, err = h.Config.Cache.Get(arv, targetID, forceReload)
                if err == nil {
                        // Success
                        found = true
@@ -335,12 +355,6 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                statusCode, statusText = http.StatusInternalServerError, err.Error()
                return
        }
-       if client, ok := kc.Client.(*http.Client); ok && client.Transport != nil {
-               // Workaround for https://dev.arvados.org/issues/9005
-               if t, ok := client.Transport.(*http.Transport); ok {
-                       t.DisableKeepAlives = true
-               }
-       }
        rdr, err := kc.CollectionFileReader(collection, filename)
        if os.IsNotExist(err) {
                statusCode = http.StatusNotFound
index 57ac2190c4cfe9d3a75278cce1c38b3a282eff89..df0346ba315f420ce5aae5dd2d3a59c06e4e87fb 100644 (file)
@@ -19,7 +19,7 @@ var _ = check.Suite(&UnitSuite{})
 type UnitSuite struct{}
 
 func (s *UnitSuite) TestCORSPreflight(c *check.C) {
-       h := handler{Config: &Config{}}
+       h := handler{Config: DefaultConfig()}
        u, _ := url.Parse("http://keep-web.example/c=" + arvadostest.FooCollection + "/foo")
        req := &http.Request{
                Method:     "OPTIONS",
@@ -70,9 +70,9 @@ func (s *UnitSuite) TestInvalidUUID(c *check.C) {
                        RequestURI: u.RequestURI(),
                }
                resp := httptest.NewRecorder()
-               h := handler{Config: &Config{
-                       AnonymousTokens: []string{arvadostest.AnonymousToken},
-               }}
+               cfg := DefaultConfig()
+               cfg.AnonymousTokens = []string{arvadostest.AnonymousToken}
+               h := handler{Config: cfg}
                h.ServeHTTP(resp, req)
                c.Check(resp.Code, check.Equals, http.StatusNotFound)
        }
index 5f4cb5090468708ce02d34ec5f74d9baf80720a5..f17522cc0205f7ed766e739f7dd788d84513e924 100644 (file)
@@ -4,6 +4,7 @@ import (
        "flag"
        "log"
        "os"
+       "time"
 
        "git.curoverse.com/arvados.git/sdk/go/arvados"
        "git.curoverse.com/arvados.git/sdk/go/config"
@@ -24,6 +25,8 @@ type Config struct {
        AttachmentOnlyHost string
        TrustAllContent    bool
 
+       Cache cache
+
        // Hack to support old command line flag, which is a bool
        // meaning "get actual token from environment".
        deprecatedAllowAnonymous bool
@@ -33,6 +36,13 @@ type Config struct {
 func DefaultConfig() *Config {
        return &Config{
                Listen: ":80",
+               Cache: cache{
+                       TTL:                  arvados.Duration(5 * time.Minute),
+                       MaxCollectionEntries: 1000,
+                       MaxCollectionBytes:   100000000,
+                       MaxPermissionEntries: 1000,
+                       MaxUUIDEntries:       1000,
+               },
        }
 }
 
index 6441364e99fcc93d4da26f4c1f6fe150c740be7b..52fe459ec43ff13c422c1679017de68246a36d1d 100644 (file)
@@ -311,13 +311,13 @@ func (s *IntegrationSuite) TearDownSuite(c *check.C) {
 
 func (s *IntegrationSuite) SetUpTest(c *check.C) {
        arvadostest.ResetEnv()
-       s.testServer = &server{Config: &Config{
-               Client: arvados.Client{
-                       APIHost:  testAPIHost,
-                       Insecure: true,
-               },
-               Listen: "127.0.0.1:0",
-       }}
+       cfg := DefaultConfig()
+       cfg.Client = arvados.Client{
+               APIHost:  testAPIHost,
+               Insecure: true,
+       }
+       cfg.Listen = "127.0.0.1:0"
+       s.testServer = &server{Config: cfg}
        err := s.testServer.Start()
        c.Assert(err, check.Equals, nil)
 }
diff --git a/services/keep-web/status_test.go b/services/keep-web/status_test.go
new file mode 100644 (file)
index 0000000..e40c1d0
--- /dev/null
@@ -0,0 +1,46 @@
+package main
+
+import (
+       "encoding/json"
+       "net/http"
+       "net/http/httptest"
+       "net/url"
+
+       "git.curoverse.com/arvados.git/sdk/go/arvadostest"
+       "gopkg.in/check.v1"
+)
+
+func (s *UnitSuite) TestStatus(c *check.C) {
+       h := handler{Config: DefaultConfig()}
+       u, _ := url.Parse("http://keep-web.example/status.json")
+       req := &http.Request{
+               Method:     "GET",
+               Host:       u.Host,
+               URL:        u,
+               RequestURI: u.RequestURI(),
+       }
+       resp := httptest.NewRecorder()
+       h.ServeHTTP(resp, req)
+       c.Check(resp.Code, check.Equals, http.StatusOK)
+
+       var status map[string]interface{}
+       err := json.NewDecoder(resp.Body).Decode(&status)
+       c.Check(err, check.IsNil)
+       c.Check(status["Cache.Requests"], check.Equals, float64(0))
+}
+
+func (s *IntegrationSuite) TestNoStatusFromVHost(c *check.C) {
+       u, _ := url.Parse("http://" + arvadostest.FooCollection + "--keep-web.example/status.json")
+       req := &http.Request{
+               Method:     "GET",
+               Host:       u.Host,
+               URL:        u,
+               RequestURI: u.RequestURI(),
+               Header: http.Header{
+                       "Authorization": {"OAuth2 " + arvadostest.ActiveToken},
+               },
+       }
+       resp := httptest.NewRecorder()
+       s.testServer.Handler.ServeHTTP(resp, req)
+       c.Check(resp.Code, check.Equals, http.StatusNotFound)
+}
index a36bf584a7ac70beb25ae268a46a1bab5efda6a4..603dd4dd2c63a73cc6e5b8b536cca888ae943d93 100644 (file)
@@ -67,5 +67,25 @@ TrustAllContent:
     Serve non-public content from a single origin. Dangerous: read
     docs before using!
 
+Cache.TTL:
+
+    Maximum time to cache collection data and permission checks.
+
+Cache.MaxCollectionEntries:
+
+    Maximum number of collection cache entries.
+
+Cache.MaxCollectionBytes:
+
+    Approximate memory limit for collection cache.
+
+Cache.MaxPermissionEntries:
+
+    Maximum number of permission cache entries.
+
+Cache.MaxUUIDEntries:
+
+    Maximum number of UUID cache entries.
+
 `, exampleConfigFile)
 }
index 65f7a42cd9d737399c278d71b2b47071ad655c6c..7dfd01ad41e7fd96f74b763d6129cc1792448538 100644 (file)
@@ -104,6 +104,7 @@ func main() {
        if err != nil {
                log.Fatalf("Error setting up keep client %s", err.Error())
        }
+       keepclient.RefreshServiceDiscoveryOnSIGHUP()
 
        if cfg.PIDFile != "" {
                f, err := os.Create(cfg.PIDFile)
@@ -133,8 +134,6 @@ func main() {
        if cfg.DefaultReplicas > 0 {
                kc.Want_replicas = cfg.DefaultReplicas
        }
-       kc.Client.(*http.Client).Timeout = time.Duration(cfg.Timeout)
-       go kc.RefreshServices(5*time.Minute, 3*time.Second)
 
        listener, err = net.Listen("tcp", cfg.Listen)
        if err != nil {
@@ -157,7 +156,7 @@ func main() {
        signal.Notify(term, syscall.SIGINT)
 
        // Start serving requests.
-       router = MakeRESTRouter(!cfg.DisableGet, !cfg.DisablePut, kc)
+       router = MakeRESTRouter(!cfg.DisableGet, !cfg.DisablePut, kc, time.Duration(cfg.Timeout))
        http.Serve(listener, router)
 
        log.Println("shutting down")
@@ -241,15 +240,29 @@ type proxyHandler struct {
        http.Handler
        *keepclient.KeepClient
        *ApiTokenCache
+       timeout   time.Duration
+       transport *http.Transport
 }
 
 // MakeRESTRouter returns an http.Handler that passes GET and PUT
 // requests to the appropriate handlers.
-func MakeRESTRouter(enable_get bool, enable_put bool, kc *keepclient.KeepClient) http.Handler {
+func MakeRESTRouter(enable_get bool, enable_put bool, kc *keepclient.KeepClient, timeout time.Duration) http.Handler {
        rest := mux.NewRouter()
+
+       transport := *(http.DefaultTransport.(*http.Transport))
+       transport.DialContext = (&net.Dialer{
+               Timeout:   keepclient.DefaultConnectTimeout,
+               KeepAlive: keepclient.DefaultKeepAlive,
+               DualStack: true,
+       }).DialContext
+       transport.TLSClientConfig = arvadosclient.MakeTLSConfig(kc.Arvados.ApiInsecure)
+       transport.TLSHandshakeTimeout = keepclient.DefaultTLSHandshakeTimeout
+
        h := &proxyHandler{
                Handler:    rest,
                KeepClient: kc,
+               timeout:    timeout,
+               transport:  &transport,
                ApiTokenCache: &ApiTokenCache{
                        tokens:     make(map[string]int64),
                        expireTime: 300,
@@ -335,12 +348,11 @@ func (h *proxyHandler) Get(resp http.ResponseWriter, req *http.Request) {
                }
        }()
 
-       kc := *h.KeepClient
-       kc.Client = &proxyClient{client: kc.Client, proto: req.Proto}
+       kc := h.makeKeepClient(req)
 
        var pass bool
        var tok string
-       if pass, tok = CheckAuthorizationHeader(&kc, h.ApiTokenCache, req); !pass {
+       if pass, tok = CheckAuthorizationHeader(kc, h.ApiTokenCache, req); !pass {
                status, err = http.StatusForbidden, BadAuthorizationHeader
                return
        }
@@ -407,8 +419,7 @@ func (h *proxyHandler) Put(resp http.ResponseWriter, req *http.Request) {
        SetCorsHeaders(resp)
        resp.Header().Set("Via", "HTTP/1.1 "+viaAlias)
 
-       kc := *h.KeepClient
-       kc.Client = &proxyClient{client: kc.Client, proto: req.Proto}
+       kc := h.makeKeepClient(req)
 
        var err error
        var expectLength int64
@@ -446,7 +457,7 @@ func (h *proxyHandler) Put(resp http.ResponseWriter, req *http.Request) {
 
        var pass bool
        var tok string
-       if pass, tok = CheckAuthorizationHeader(&kc, h.ApiTokenCache, req); !pass {
+       if pass, tok = CheckAuthorizationHeader(kc, h.ApiTokenCache, req); !pass {
                err = BadAuthorizationHeader
                status = http.StatusForbidden
                return
@@ -527,9 +538,8 @@ func (h *proxyHandler) Index(resp http.ResponseWriter, req *http.Request) {
                }
        }()
 
-       kc := *h.KeepClient
-
-       ok, token := CheckAuthorizationHeader(&kc, h.ApiTokenCache, req)
+       kc := h.makeKeepClient(req)
+       ok, token := CheckAuthorizationHeader(kc, h.ApiTokenCache, req)
        if !ok {
                status, err = http.StatusForbidden, BadAuthorizationHeader
                return
@@ -566,3 +576,15 @@ func (h *proxyHandler) Index(resp http.ResponseWriter, req *http.Request) {
        status = http.StatusOK
        resp.Write([]byte("\n"))
 }
+
+func (h *proxyHandler) makeKeepClient(req *http.Request) *keepclient.KeepClient {
+       kc := *h.KeepClient
+       kc.HTTPClient = &proxyClient{
+               client: &http.Client{
+                       Timeout:   h.timeout,
+                       Transport: h.transport,
+               },
+               proto: req.Proto,
+       }
+       return &kc
+}
index 4e856262dd1827395df6c54c99a5c68cfb18432a..2c672f06339faf3cf01c186532894c9ee04096ea 100644 (file)
@@ -185,7 +185,7 @@ func (s *ServerRequiredSuite) TestPutWrongContentLength(c *C) {
        // fixes the invalid Content-Length header. In order to test
        // our server behavior, we have to call the handler directly
        // using an httptest.ResponseRecorder.
-       rtr := MakeRESTRouter(true, true, kc)
+       rtr := MakeRESTRouter(true, true, kc, 10*time.Second)
 
        type testcase struct {
                sendLength   string
index 9033de811775f776499b61f5347545dd42775cc0..d53d35da6002932e820979d03300e406d81b676f 100644 (file)
@@ -159,7 +159,6 @@ func main() {
        keepClient := &keepclient.KeepClient{
                Arvados:       &arvadosclient.ArvadosClient{},
                Want_replicas: 1,
-               Client:        &http.Client{},
        }
 
        // Initialize the pullq and worker
index 8c7a1e222ddc8905041161cee67d605334285a65..34c2f61a37a01097ec1ce257f49f5ccb9c33e246 100644 (file)
@@ -5,7 +5,6 @@ import (
        "errors"
        "io"
        "io/ioutil"
-       "net/http"
        "os"
        "strings"
        "testing"
@@ -30,25 +29,23 @@ func SetupPullWorkerIntegrationTest(t *testing.T, testData PullWorkIntegrationTe
        // start api and keep servers
        arvadostest.StartAPI()
        arvadostest.StartKeep(2, false)
+       keepclient.RefreshServiceDiscovery()
 
        // make arvadosclient
        arv, err := arvadosclient.MakeArvadosClient()
        if err != nil {
-               t.Error("Error creating arv")
+               t.Fatalf("Error creating arv: %s", err)
        }
 
        // keep client
-       keepClient = &keepclient.KeepClient{
-               Arvados:       arv,
-               Want_replicas: 1,
-               Client:        &http.Client{},
+       keepClient, err = keepclient.MakeKeepClient(arv)
+       if err != nil {
+               t.Fatalf("error creating KeepClient: %s", err)
        }
+       keepClient.Want_replicas = 1
 
        // discover keep services
        var servers []string
-       if err := keepClient.DiscoverKeepServers(); err != nil {
-               t.Error("Error discovering keep services")
-       }
        for _, host := range keepClient.LocalRoots() {
                servers = append(servers, host)
        }
index 6cf11a728075c60990c1b049c0fb916cea5cd5d5..e22e4b5cfe56e2d127d334ef593d6b5067cc3978 100644 (file)
@@ -5,15 +5,15 @@ import (
        "errors"
        "flag"
        "fmt"
-       "git.curoverse.com/arvados.git/sdk/go/arvadosclient"
-       "git.curoverse.com/arvados.git/sdk/go/keepclient"
        "io/ioutil"
        "log"
        "net/http"
        "os"
-       "regexp"
        "strings"
        "time"
+
+       "git.curoverse.com/arvados.git/sdk/go/arvadosclient"
+       "git.curoverse.com/arvados.git/sdk/go/keepclient"
 )
 
 func main() {
@@ -99,8 +99,6 @@ func loadConfig(configFile string) (config apiConfig, blobSigningKey string, err
        return
 }
 
-var matchTrue = regexp.MustCompile("^(?i:1|yes|true)$")
-
 // Read config from file
 func readConfigFromFile(filename string) (config apiConfig, blobSigningKey string, err error) {
        if !strings.Contains(filename, "/") {
@@ -130,9 +128,9 @@ func readConfigFromFile(filename string) (config apiConfig, blobSigningKey strin
                        case "ARVADOS_API_HOST":
                                config.APIHost = value
                        case "ARVADOS_API_HOST_INSECURE":
-                               config.APIHostInsecure = matchTrue.MatchString(value)
+                               config.APIHostInsecure = arvadosclient.StringBool(value)
                        case "ARVADOS_EXTERNAL_CLIENT":
-                               config.ExternalClient = matchTrue.MatchString(value)
+                               config.ExternalClient = arvadosclient.StringBool(value)
                        case "ARVADOS_BLOB_SIGNING_KEY":
                                blobSigningKey = value
                        }
@@ -153,7 +151,7 @@ func setupKeepClient(config apiConfig, keepServicesJSON string, blobSignatureTTL
                External: config.ExternalClient,
        }
 
-       // if keepServicesJSON is provided, use it to load services; else, use DiscoverKeepServers
+       // If keepServicesJSON is provided, use it instead of service discovery
        if keepServicesJSON == "" {
                kc, err = keepclient.MakeKeepClient(&arv)
                if err != nil {
index e49fe68616626275f00ba5bce21877dd969207dd..34d4f022bf8d7d4570e2465fd7d811dd3e1a2aa3 100644 (file)
@@ -12,6 +12,7 @@ import (
        "testing"
        "time"
 
+       "git.curoverse.com/arvados.git/sdk/go/arvadosclient"
        "git.curoverse.com/arvados.git/sdk/go/arvadostest"
        "git.curoverse.com/arvados.git/sdk/go/keepclient"
 
@@ -64,6 +65,7 @@ func (s *DoMainTestSuite) SetUpSuite(c *C) {
 func (s *DoMainTestSuite) SetUpTest(c *C) {
        logOutput := io.MultiWriter(&logBuffer)
        log.SetOutput(logOutput)
+       keepclient.RefreshServiceDiscovery()
 }
 
 func (s *DoMainTestSuite) TearDownTest(c *C) {
@@ -79,7 +81,7 @@ func setupKeepBlockCheckWithTTL(c *C, enforcePermissions bool, keepServicesJSON
        var config apiConfig
        config.APIHost = os.Getenv("ARVADOS_API_HOST")
        config.APIToken = arvadostest.DataManagerToken
-       config.APIHostInsecure = matchTrue.MatchString(os.Getenv("ARVADOS_API_HOST_INSECURE"))
+       config.APIHostInsecure = arvadosclient.StringBool(os.Getenv("ARVADOS_API_HOST_INSECURE"))
 
        // Start Keep servers
        arvadostest.StartKeep(2, enforcePermissions)
@@ -89,6 +91,8 @@ func setupKeepBlockCheckWithTTL(c *C, enforcePermissions bool, keepServicesJSON
        kc, ttl, err = setupKeepClient(config, keepServicesJSON, ttl)
        c.Assert(ttl, Equals, blobSignatureTTL)
        c.Check(err, IsNil)
+
+       keepclient.RefreshServiceDiscovery()
 }
 
 // Setup test data
@@ -144,9 +148,8 @@ func setupBlockHashFile(c *C, name string, blocks []string) string {
 
 func checkErrorLog(c *C, blocks []string, prefix, suffix string) {
        for _, hash := range blocks {
-               expected := prefix + `.*` + hash + `.*` + suffix
-               match, _ := regexp.MatchString(expected, logBuffer.String())
-               c.Assert(match, Equals, true)
+               expected := `(?ms).*` + prefix + `.*` + hash + `.*` + suffix + `.*`
+               c.Check(logBuffer.String(), Matches, expected)
        }
 }
 
@@ -288,7 +291,7 @@ func (s *ServerRequiredSuite) TestLoadConfig(c *C) {
 
        c.Assert(config.APIHost, Equals, os.Getenv("ARVADOS_API_HOST"))
        c.Assert(config.APIToken, Equals, arvadostest.DataManagerToken)
-       c.Assert(config.APIHostInsecure, Equals, matchTrue.MatchString(os.Getenv("ARVADOS_API_HOST_INSECURE")))
+       c.Assert(config.APIHostInsecure, Equals, arvadosclient.StringBool(os.Getenv("ARVADOS_API_HOST_INSECURE")))
        c.Assert(config.ExternalClient, Equals, false)
        c.Assert(blobSigningKey, Equals, "abcdefg")
 }
index 706664ce28a3756047afcc2f87264b01857d5244..a4684739e72ad36e33920490671b4d2c222cd799 100644 (file)
@@ -53,7 +53,13 @@ func main() {
                log.Fatal(err)
        }
        kc.Want_replicas = *Replicas
-       kc.Client.(*http.Client).Timeout = 10 * time.Minute
+
+       transport := *(http.DefaultTransport.(*http.Transport))
+       transport.TLSClientConfig = arvadosclient.MakeTLSConfig(arv.ApiInsecure)
+       kc.HTTPClient = &http.Client{
+               Timeout:   10 * time.Minute,
+               Transport: &transport,
+       }
 
        overrideServices(kc)
 
index c6e7665caa2a312c327b8a603159a7da07941450..b1513a02a7e30f3211b39bbf0a4335c8dd9cf8d9 100644 (file)
@@ -6,15 +6,15 @@ import (
        "errors"
        "flag"
        "fmt"
-       "git.curoverse.com/arvados.git/sdk/go/arvadosclient"
-       "git.curoverse.com/arvados.git/sdk/go/keepclient"
        "io/ioutil"
        "log"
        "net/http"
        "os"
-       "regexp"
        "strings"
        "time"
+
+       "git.curoverse.com/arvados.git/sdk/go/arvadosclient"
+       "git.curoverse.com/arvados.git/sdk/go/keepclient"
 )
 
 func main() {
@@ -119,8 +119,6 @@ func loadConfig(configFile string) (config apiConfig, blobSigningKey string, err
        return
 }
 
-var matchTrue = regexp.MustCompile("^(?i:1|yes|true)$")
-
 // Read config from file
 func readConfigFromFile(filename string) (config apiConfig, blobSigningKey string, err error) {
        if !strings.Contains(filename, "/") {
@@ -149,9 +147,9 @@ func readConfigFromFile(filename string) (config apiConfig, blobSigningKey strin
                case "ARVADOS_API_HOST":
                        config.APIHost = value
                case "ARVADOS_API_HOST_INSECURE":
-                       config.APIHostInsecure = matchTrue.MatchString(value)
+                       config.APIHostInsecure = arvadosclient.StringBool(value)
                case "ARVADOS_EXTERNAL_CLIENT":
-                       config.ExternalClient = matchTrue.MatchString(value)
+                       config.ExternalClient = arvadosclient.StringBool(value)
                case "ARVADOS_BLOB_SIGNING_KEY":
                        blobSigningKey = value
                }
@@ -170,7 +168,7 @@ func setupKeepClient(config apiConfig, keepServicesJSON string, isDst bool, repl
                External: config.ExternalClient,
        }
 
-       // if keepServicesJSON is provided, use it to load services; else, use DiscoverKeepServers
+       // If keepServicesJSON is provided, use it instead of service discovery
        if keepServicesJSON == "" {
                kc, err = keepclient.MakeKeepClient(&arv)
                if err != nil {
index 09609eb7498bb8dc28d95bc41892f4cca9ec8563..fec1f354c957e1ca5f106f666c632af3728d3b82 100644 (file)
@@ -4,35 +4,42 @@ import (
        "crypto/md5"
        "fmt"
        "io/ioutil"
-       "log"
        "os"
        "strings"
        "testing"
        "time"
 
+       "git.curoverse.com/arvados.git/sdk/go/arvadosclient"
        "git.curoverse.com/arvados.git/sdk/go/arvadostest"
        "git.curoverse.com/arvados.git/sdk/go/keepclient"
 
        . "gopkg.in/check.v1"
 )
 
+var kcSrc, kcDst *keepclient.KeepClient
+var srcKeepServicesJSON, dstKeepServicesJSON, blobSigningKey string
+var blobSignatureTTL = time.Duration(2*7*24) * time.Hour
+
+func resetGlobals() {
+       blobSigningKey = ""
+       srcKeepServicesJSON = ""
+       dstKeepServicesJSON = ""
+       kcSrc = nil
+       kcDst = nil
+}
+
 // Gocheck boilerplate
 func Test(t *testing.T) {
        TestingT(t)
 }
 
-// Gocheck boilerplate
 var _ = Suite(&ServerRequiredSuite{})
 var _ = Suite(&ServerNotRequiredSuite{})
 var _ = Suite(&DoMainTestSuite{})
 
-// Tests that require the Keep server running
 type ServerRequiredSuite struct{}
-type ServerNotRequiredSuite struct{}
-type DoMainTestSuite struct{}
 
 func (s *ServerRequiredSuite) SetUpSuite(c *C) {
-       // Start API server
        arvadostest.StartAPI()
 }
 
@@ -41,36 +48,32 @@ func (s *ServerRequiredSuite) TearDownSuite(c *C) {
        arvadostest.ResetEnv()
 }
 
-var initialArgs []string
-
-func (s *DoMainTestSuite) SetUpSuite(c *C) {
-       initialArgs = os.Args
-}
-
-var kcSrc, kcDst *keepclient.KeepClient
-var srcKeepServicesJSON, dstKeepServicesJSON, blobSigningKey string
-var blobSignatureTTL = time.Duration(2*7*24) * time.Hour
-
 func (s *ServerRequiredSuite) SetUpTest(c *C) {
-       // reset all variables between tests
-       blobSigningKey = ""
-       srcKeepServicesJSON = ""
-       dstKeepServicesJSON = ""
-       kcSrc = &keepclient.KeepClient{}
-       kcDst = &keepclient.KeepClient{}
+       resetGlobals()
 }
 
 func (s *ServerRequiredSuite) TearDownTest(c *C) {
        arvadostest.StopKeep(3)
 }
 
+func (s *ServerNotRequiredSuite) SetUpTest(c *C) {
+       resetGlobals()
+}
+
+type ServerNotRequiredSuite struct{}
+
+type DoMainTestSuite struct {
+       initialArgs []string
+}
+
 func (s *DoMainTestSuite) SetUpTest(c *C) {
-       args := []string{"keep-rsync"}
-       os.Args = args
+       s.initialArgs = os.Args
+       os.Args = []string{"keep-rsync"}
+       resetGlobals()
 }
 
 func (s *DoMainTestSuite) TearDownTest(c *C) {
-       os.Args = initialArgs
+       os.Args = s.initialArgs
 }
 
 var testKeepServicesJSON = "{ \"kind\":\"arvados#keepServiceList\", \"etag\":\"\", \"self_link\":\"\", \"offset\":null, \"limit\":null, \"items\":[ { \"href\":\"/keep_services/zzzzz-bi6l4-123456789012340\", \"kind\":\"arvados#keepService\", \"etag\":\"641234567890enhj7hzx432e5\", \"uuid\":\"zzzzz-bi6l4-123456789012340\", \"owner_uuid\":\"zzzzz-tpzed-123456789012345\", \"service_host\":\"keep0.zzzzz.arvadosapi.com\", \"service_port\":25107, \"service_ssl_flag\":false, \"service_type\":\"disk\", \"read_only\":false }, { \"href\":\"/keep_services/zzzzz-bi6l4-123456789012341\", \"kind\":\"arvados#keepService\", \"etag\":\"641234567890enhj7hzx432e5\", \"uuid\":\"zzzzz-bi6l4-123456789012341\", \"owner_uuid\":\"zzzzz-tpzed-123456789012345\", \"service_host\":\"keep0.zzzzz.arvadosapi.com\", \"service_port\":25108, \"service_ssl_flag\":false, \"service_type\":\"disk\", \"read_only\":false } ], \"items_available\":2 }"
@@ -83,13 +86,13 @@ func setupRsync(c *C, enforcePermissions bool, replications int) {
        var srcConfig apiConfig
        srcConfig.APIHost = os.Getenv("ARVADOS_API_HOST")
        srcConfig.APIToken = arvadostest.DataManagerToken
-       srcConfig.APIHostInsecure = matchTrue.MatchString(os.Getenv("ARVADOS_API_HOST_INSECURE"))
+       srcConfig.APIHostInsecure = arvadosclient.StringBool(os.Getenv("ARVADOS_API_HOST_INSECURE"))
 
        // dstConfig
        var dstConfig apiConfig
        dstConfig.APIHost = os.Getenv("ARVADOS_API_HOST")
        dstConfig.APIToken = arvadostest.DataManagerToken
-       dstConfig.APIHostInsecure = matchTrue.MatchString(os.Getenv("ARVADOS_API_HOST_INSECURE"))
+       dstConfig.APIHostInsecure = arvadosclient.StringBool(os.Getenv("ARVADOS_API_HOST_INSECURE"))
 
        if enforcePermissions {
                blobSigningKey = arvadostest.BlobSigningKey
@@ -97,45 +100,30 @@ func setupRsync(c *C, enforcePermissions bool, replications int) {
 
        // Start Keep servers
        arvadostest.StartKeep(3, enforcePermissions)
+       keepclient.RefreshServiceDiscovery()
 
        // setup keepclients
        var err error
        kcSrc, _, err = setupKeepClient(srcConfig, srcKeepServicesJSON, false, 0, blobSignatureTTL)
-       c.Check(err, IsNil)
+       c.Assert(err, IsNil)
 
        kcDst, _, err = setupKeepClient(dstConfig, dstKeepServicesJSON, true, replications, 0)
-       c.Check(err, IsNil)
+       c.Assert(err, IsNil)
 
-       for uuid := range kcSrc.LocalRoots() {
+       srcRoots := map[string]string{}
+       dstRoots := map[string]string{}
+       for uuid, root := range kcSrc.LocalRoots() {
                if strings.HasSuffix(uuid, "02") {
-                       delete(kcSrc.LocalRoots(), uuid)
+                       dstRoots[uuid] = root
+               } else {
+                       srcRoots[uuid] = root
                }
        }
-       for uuid := range kcSrc.GatewayRoots() {
-               if strings.HasSuffix(uuid, "02") {
-                       delete(kcSrc.GatewayRoots(), uuid)
-               }
+       if srcKeepServicesJSON == "" {
+               kcSrc.SetServiceRoots(srcRoots, srcRoots, srcRoots)
        }
-       for uuid := range kcSrc.WritableLocalRoots() {
-               if strings.HasSuffix(uuid, "02") {
-                       delete(kcSrc.WritableLocalRoots(), uuid)
-               }
-       }
-
-       for uuid := range kcDst.LocalRoots() {
-               if strings.HasSuffix(uuid, "00") || strings.HasSuffix(uuid, "01") {
-                       delete(kcDst.LocalRoots(), uuid)
-               }
-       }
-       for uuid := range kcDst.GatewayRoots() {
-               if strings.HasSuffix(uuid, "00") || strings.HasSuffix(uuid, "01") {
-                       delete(kcDst.GatewayRoots(), uuid)
-               }
-       }
-       for uuid := range kcDst.WritableLocalRoots() {
-               if strings.HasSuffix(uuid, "00") || strings.HasSuffix(uuid, "01") {
-                       delete(kcDst.WritableLocalRoots(), uuid)
-               }
+       if dstKeepServicesJSON == "" {
+               kcDst.SetServiceRoots(dstRoots, dstRoots, dstRoots)
        }
 
        if replications == 0 {
@@ -188,22 +176,8 @@ func (s *ServerRequiredSuite) TestRsyncInitializeWithKeepServicesJSON(c *C) {
 
        localRoots := kcSrc.LocalRoots()
        c.Check(localRoots, NotNil)
-
-       foundIt := false
-       for k := range localRoots {
-               if k == "zzzzz-bi6l4-123456789012340" {
-                       foundIt = true
-               }
-       }
-       c.Check(foundIt, Equals, true)
-
-       foundIt = false
-       for k := range localRoots {
-               if k == "zzzzz-bi6l4-123456789012341" {
-                       foundIt = true
-               }
-       }
-       c.Check(foundIt, Equals, true)
+       c.Check(localRoots["zzzzz-bi6l4-123456789012340"], Not(Equals), "")
+       c.Check(localRoots["zzzzz-bi6l4-123456789012341"], Not(Equals), "")
 }
 
 // Test keep-rsync initialization with default replications count
@@ -329,8 +303,8 @@ func (s *ServerRequiredSuite) TestErrorDuringRsync_FakeSrcKeepservers(c *C) {
        setupRsync(c, false, 1)
 
        err := performKeepRsync(kcSrc, kcDst, blobSignatureTTL, "", "")
-       log.Printf("Err = %v", err)
-       c.Check(strings.Contains(err.Error(), "no such host"), Equals, true)
+       c.Assert(err, NotNil)
+       c.Check(err.Error(), Matches, ".*no such host.*")
 }
 
 // Setup rsync using dstKeepServicesJSON with fake keepservers.
@@ -341,8 +315,8 @@ func (s *ServerRequiredSuite) TestErrorDuringRsync_FakeDstKeepservers(c *C) {
        setupRsync(c, false, 1)
 
        err := performKeepRsync(kcSrc, kcDst, blobSignatureTTL, "", "")
-       log.Printf("Err = %v", err)
-       c.Check(strings.Contains(err.Error(), "no such host"), Equals, true)
+       c.Assert(err, NotNil)
+       c.Check(err.Error(), Matches, ".*no such host.*")
 }
 
 // Test rsync with signature error during Get from src.
@@ -356,7 +330,8 @@ func (s *ServerRequiredSuite) TestErrorDuringRsync_ErrorGettingBlockFromSrc(c *C
        blobSigningKey = "thisisfakeblobsigningkey"
 
        err := performKeepRsync(kcSrc, kcDst, blobSignatureTTL, blobSigningKey, "")
-       c.Check(strings.Contains(err.Error(), "HTTP 403 \"Forbidden\""), Equals, true)
+       c.Assert(err, NotNil)
+       c.Check(err.Error(), Matches, ".*HTTP 403 \"Forbidden\".*")
 }
 
 // Test rsync with error during Put to src.
@@ -370,7 +345,8 @@ func (s *ServerRequiredSuite) TestErrorDuringRsync_ErrorPuttingBlockInDst(c *C)
        kcDst.Want_replicas = 2
 
        err := performKeepRsync(kcSrc, kcDst, blobSignatureTTL, blobSigningKey, "")
-       c.Check(strings.Contains(err.Error(), "Could not write sufficient replicas"), Equals, true)
+       c.Assert(err, NotNil)
+       c.Check(err.Error(), Matches, ".*Could not write sufficient replicas.*")
 }
 
 // Test loadConfig func
@@ -391,7 +367,7 @@ func (s *ServerNotRequiredSuite) TestLoadConfig(c *C) {
 
        c.Assert(srcConfig.APIHost, Equals, os.Getenv("ARVADOS_API_HOST"))
        c.Assert(srcConfig.APIToken, Equals, arvadostest.DataManagerToken)
-       c.Assert(srcConfig.APIHostInsecure, Equals, matchTrue.MatchString(os.Getenv("ARVADOS_API_HOST_INSECURE")))
+       c.Assert(srcConfig.APIHostInsecure, Equals, arvadosclient.StringBool(os.Getenv("ARVADOS_API_HOST_INSECURE")))
        c.Assert(srcConfig.ExternalClient, Equals, false)
 
        dstConfig, _, err := loadConfig(dstConfigFile)
@@ -399,7 +375,7 @@ func (s *ServerNotRequiredSuite) TestLoadConfig(c *C) {
 
        c.Assert(dstConfig.APIHost, Equals, os.Getenv("ARVADOS_API_HOST"))
        c.Assert(dstConfig.APIToken, Equals, arvadostest.DataManagerToken)
-       c.Assert(dstConfig.APIHostInsecure, Equals, matchTrue.MatchString(os.Getenv("ARVADOS_API_HOST_INSECURE")))
+       c.Assert(dstConfig.APIHostInsecure, Equals, arvadosclient.StringBool(os.Getenv("ARVADOS_API_HOST_INSECURE")))
        c.Assert(dstConfig.ExternalClient, Equals, false)
 
        c.Assert(srcBlobSigningKey, Equals, "abcdefg")
@@ -414,15 +390,15 @@ func (s *ServerNotRequiredSuite) TestLoadConfig_MissingSrcConfig(c *C) {
 // Test loadConfig func - error reading config
 func (s *ServerNotRequiredSuite) TestLoadConfig_ErrorLoadingSrcConfig(c *C) {
        _, _, err := loadConfig("no-such-config-file")
-       c.Assert(strings.Contains(err.Error(), "no such file or directory"), Equals, true)
+       c.Assert(err, NotNil)
+       c.Check(err.Error(), Matches, ".*no such file or directory.*")
 }
 
 func (s *ServerNotRequiredSuite) TestSetupKeepClient_NoBlobSignatureTTL(c *C) {
        var srcConfig apiConfig
        srcConfig.APIHost = os.Getenv("ARVADOS_API_HOST")
        srcConfig.APIToken = arvadostest.DataManagerToken
-       srcConfig.APIHostInsecure = matchTrue.MatchString(os.Getenv("ARVADOS_API_HOST_INSECURE"))
-       arvadostest.StartKeep(2, false)
+       srcConfig.APIHostInsecure = arvadosclient.StringBool(os.Getenv("ARVADOS_API_HOST_INSECURE"))
 
        _, ttl, err := setupKeepClient(srcConfig, srcKeepServicesJSON, false, 0, 0)
        c.Check(err, IsNil)
@@ -448,7 +424,7 @@ func setupConfigFile(c *C, name string) *os.File {
 
 func (s *DoMainTestSuite) Test_doMain_NoSrcConfig(c *C) {
        err := doMain()
-       c.Check(err, NotNil)
+       c.Assert(err, NotNil)
        c.Assert(err.Error(), Equals, "Error loading src configuration from file: config file not specified")
 }
 
@@ -457,7 +433,7 @@ func (s *DoMainTestSuite) Test_doMain_SrcButNoDstConfig(c *C) {
        args := []string{"-replications", "3", "-src", srcConfig.Name()}
        os.Args = append(os.Args, args...)
        err := doMain()
-       c.Check(err, NotNil)
+       c.Assert(err, NotNil)
        c.Assert(err.Error(), Equals, "Error loading dst configuration from file: config file not specified")
 }
 
@@ -465,8 +441,8 @@ func (s *DoMainTestSuite) Test_doMain_BadSrcConfig(c *C) {
        args := []string{"-src", "abcd"}
        os.Args = append(os.Args, args...)
        err := doMain()
-       c.Check(err, NotNil)
-       c.Assert(strings.HasPrefix(err.Error(), "Error loading src configuration from file: Error reading config file"), Equals, true)
+       c.Assert(err, NotNil)
+       c.Assert(err.Error(), Matches, "Error loading src configuration from file: Error reading config file.*")
 }
 
 func (s *DoMainTestSuite) Test_doMain_WithReplicationsButNoSrcConfig(c *C) {
@@ -488,6 +464,7 @@ func (s *DoMainTestSuite) Test_doMainWithSrcAndDstConfig(c *C) {
        // actual copying to dst will happen, but that's ok.
        arvadostest.StartKeep(2, false)
        defer arvadostest.StopKeep(2)
+       keepclient.RefreshServiceDiscovery()
 
        err := doMain()
        c.Check(err, IsNil)