From: Tom Clegg Date: Mon, 25 Nov 2019 20:20:21 +0000 (-0500) Subject: 15720: Merge branch 'master' into 15720-fed-user-list X-Git-Tag: 2.0.0~107^2~2 X-Git-Url: https://git.arvados.org/arvados.git/commitdiff_plain/5ee93e408c0e547dfb03b2f3d039a7715126395b?hp=233a2b6bd23a3e2054cfc0690f2bc06c0f9f7323 15720: Merge branch 'master' into 15720-fed-user-list Arvados-DCO-1.1-Signed-off-by: Tom Clegg --- diff --git a/apps/workbench/Gemfile.lock b/apps/workbench/Gemfile.lock index b4b6100f4a..f16f298bac 100644 --- a/apps/workbench/Gemfile.lock +++ b/apps/workbench/Gemfile.lock @@ -3,7 +3,7 @@ GIT revision: dd9f2403f43bcb93da5908ddde57d8c0491bb4c2 glob: sdk/ruby/arvados.gemspec specs: - arvados (1.4.1.20191019025325) + arvados (1.4.2.20191019025325) activesupport (>= 3) andand (~> 1.3, >= 1.3.3) arvados-google-api-client (>= 0.7, < 0.8.9) @@ -375,4 +375,4 @@ DEPENDENCIES uglifier (~> 2.0) BUNDLED WITH - 1.17.3 + 2.0.2 diff --git a/apps/workbench/test/controllers/projects_controller_test.rb b/apps/workbench/test/controllers/projects_controller_test.rb index dd828952be..27d7dedc91 100644 --- a/apps/workbench/test/controllers/projects_controller_test.rb +++ b/apps/workbench/test/controllers/projects_controller_test.rb @@ -151,7 +151,7 @@ class ProjectsControllerTest < ActionController::TestCase end ['', ' asc', ' desc'].each do |direction| - test "projects#show tab partial orders correctly by #{direction}" do + test "projects#show tab partial orders correctly by created_at#{direction}" do _test_tab_content_order direction end end diff --git a/build/package-build-dockerfiles/Makefile b/build/package-build-dockerfiles/Makefile index 6972415152..db53ab096d 100644 --- a/build/package-build-dockerfiles/Makefile +++ b/build/package-build-dockerfiles/Makefile @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: AGPL-3.0 -all: centos7/generated debian9/generated ubuntu1604/generated ubuntu1804/generated +all: centos7/generated debian9/generated debian10/generated ubuntu1604/generated ubuntu1804/generated centos7/generated: common-generated-all test -d centos7/generated || mkdir centos7/generated @@ -12,6 +12,11 @@ debian9/generated: common-generated-all test -d debian9/generated || mkdir debian9/generated cp -rlt debian9/generated common-generated/* +debian10/generated: common-generated-all + test -d debian10/generated || mkdir debian10/generated + cp -rlt debian10/generated common-generated/* + + ubuntu1604/generated: common-generated-all test -d ubuntu1604/generated || mkdir ubuntu1604/generated cp -rlt ubuntu1604/generated common-generated/* diff --git a/build/package-build-dockerfiles/debian10/Dockerfile b/build/package-build-dockerfiles/debian10/Dockerfile new file mode 100644 index 0000000000..bab447f538 --- /dev/null +++ b/build/package-build-dockerfiles/debian10/Dockerfile @@ -0,0 +1,38 @@ +# Copyright (C) The Arvados Authors. All rights reserved. +# +# SPDX-License-Identifier: AGPL-3.0 + +## dont use debian:10 here since the word 'buster' is used for rvm precompiled binaries +FROM debian:buster +MAINTAINER Ward Vandewege + +ENV DEBIAN_FRONTEND noninteractive + +# Install dependencies. +RUN /usr/bin/apt-get update && /usr/bin/apt-get install -q -y python2.7-dev python3 python-setuptools python3-setuptools python3-pip libcurl4-gnutls-dev curl git procps libattr1-dev libfuse-dev libgnutls28-dev libpq-dev python-pip unzip python3-venv python3-dev + +# Install virtualenv +RUN /usr/bin/pip install virtualenv + +# Install RVM +ADD generated/mpapis.asc /tmp/ +ADD generated/pkuczynski.asc /tmp/ +RUN gpg --import --no-tty /tmp/mpapis.asc && \ + gpg --import --no-tty /tmp/pkuczynski.asc && \ + curl -L https://get.rvm.io | bash -s stable && \ + /usr/local/rvm/bin/rvm install 2.5 && \ + /usr/local/rvm/bin/rvm alias create default ruby-2.5 && \ + /usr/local/rvm/bin/rvm-exec default gem install fpm --version 1.10.2 + +# Install golang binary +ADD generated/go1.12.7.linux-amd64.tar.gz /usr/local/ +RUN ln -s /usr/local/go/bin/go /usr/local/bin/ + +# Install nodejs and npm +ADD generated/node-v6.11.2-linux-x64.tar.xz /usr/local/ +RUN ln -s /usr/local/node-v6.11.2-linux-x64/bin/* /usr/local/bin/ + +RUN git clone --depth 1 git://git.curoverse.com/arvados.git /tmp/arvados && cd /tmp/arvados/services/api && /usr/local/rvm/bin/rvm-exec default bundle && cd /tmp/arvados/apps/workbench && /usr/local/rvm/bin/rvm-exec default bundle + +ENV WORKSPACE /arvados +CMD ["/usr/local/rvm/bin/rvm-exec", "default", "bash", "/jenkins/run-build-packages.sh", "--target", "debian10"] diff --git a/build/package-test-dockerfiles/Makefile b/build/package-test-dockerfiles/Makefile index c7b32968ff..1066750fe5 100644 --- a/build/package-test-dockerfiles/Makefile +++ b/build/package-test-dockerfiles/Makefile @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: AGPL-3.0 -all: centos7/generated debian9/generated ubuntu1604/generated ubuntu1804/generated +all: centos7/generated debian9/generated debian10/generated ubuntu1604/generated ubuntu1804/generated centos7/generated: common-generated-all test -d centos7/generated || mkdir centos7/generated @@ -12,6 +12,10 @@ debian9/generated: common-generated-all test -d debian9/generated || mkdir debian9/generated cp -rlt debian9/generated common-generated/* +debian10/generated: common-generated-all + test -d debian10/generated || mkdir debian10/generated + cp -rlt debian10/generated common-generated/* + ubuntu1604/generated: common-generated-all test -d ubuntu1604/generated || mkdir ubuntu1604/generated cp -rlt ubuntu1604/generated common-generated/* diff --git a/build/package-test-dockerfiles/debian10/Dockerfile b/build/package-test-dockerfiles/debian10/Dockerfile new file mode 100644 index 0000000000..3aa6fdcce1 --- /dev/null +++ b/build/package-test-dockerfiles/debian10/Dockerfile @@ -0,0 +1,26 @@ +# Copyright (C) The Arvados Authors. All rights reserved. +# +# SPDX-License-Identifier: AGPL-3.0 + +FROM debian:buster +MAINTAINER Ward Vandewege + +ENV DEBIAN_FRONTEND noninteractive + +# Install dependencies +RUN apt-get update && \ + apt-get -y install --no-install-recommends curl ca-certificates gpg procps gpg-agent + +# Install RVM +ADD generated/mpapis.asc /tmp/ +ADD generated/pkuczynski.asc /tmp/ +RUN gpg --import --no-tty /tmp/mpapis.asc && \ + gpg --import --no-tty /tmp/pkuczynski.asc && \ + curl -L https://get.rvm.io | bash -s stable && \ + /usr/local/rvm/bin/rvm install 2.5 && \ + /usr/local/rvm/bin/rvm alias create default ruby-2.5 + +# udev daemon can't start in a container, so don't try. +RUN mkdir -p /etc/udev/disabled + +RUN echo "deb file:///arvados/packages/debian10/ /" >>/etc/apt/sources.list diff --git a/build/package-testing/deb-common-test-packages.sh b/build/package-testing/deb-common-test-packages.sh index 77017ba970..32fb2009e1 100755 --- a/build/package-testing/deb-common-test-packages.sh +++ b/build/package-testing/deb-common-test-packages.sh @@ -23,7 +23,7 @@ export ARV_PACKAGES_DIR="/arvados/packages/$target" dpkg-query --show > "$ARV_PACKAGES_DIR/$1.before" -apt-get $DASHQQ_UNLESS_DEBUG update +apt-get $DASHQQ_UNLESS_DEBUG --allow-insecure-repositories update apt-get $DASHQQ_UNLESS_DEBUG -y --allow-unauthenticated install "$1" >"$STDOUT_IF_DEBUG" 2>"$STDERR_IF_DEBUG" diff --git a/build/package-testing/test-packages-debian10.sh b/build/package-testing/test-packages-debian10.sh new file mode 120000 index 0000000000..54ce94c357 --- /dev/null +++ b/build/package-testing/test-packages-debian10.sh @@ -0,0 +1 @@ +deb-common-test-packages.sh \ No newline at end of file diff --git a/build/run-build-packages-one-target.sh b/build/run-build-packages-one-target.sh index 378c9bbfa3..7f3ca3242b 100755 --- a/build/run-build-packages-one-target.sh +++ b/build/run-build-packages-one-target.sh @@ -10,7 +10,7 @@ Syntax: WORKSPACE=/path/to/arvados $(basename $0) [options] --target - Distribution to build packages for (default: debian9) + Distribution to build packages for (default: debian10) --command Build command to execute (default: use built-in Docker image command) --test-packages @@ -21,6 +21,9 @@ Syntax: Build only a specific package --only-test Test only a specific package +--force-build + Build even if the package exists upstream or if it has already been + built locally --force-test Test even if there is no new untested package --build-version @@ -51,15 +54,16 @@ if ! [[ -d "$WORKSPACE" ]]; then fi PARSEDOPTS=$(getopt --name "$0" --longoptions \ - help,debug,test-packages,target:,command:,only-test:,force-test,only-build:,build-version: \ + help,debug,test-packages,target:,command:,only-test:,force-test,only-build:,force-build,build-version: \ -- "" "$@") if [ $? -ne 0 ]; then exit 1 fi -TARGET=debian9 +TARGET=debian10 +FORCE_BUILD=0 COMMAND= -DEBUG= +DEBUG=${ARVADOS_DEBUG:-0} eval set -- "$PARSEDOPTS" while [ $# -gt 0 ]; do @@ -80,6 +84,9 @@ while [ $# -gt 0 ]; do --force-test) FORCE_TEST=true ;; + --force-build) + FORCE_BUILD=1 + ;; --only-build) ONLY_BUILD="$2"; shift ;; @@ -269,6 +276,7 @@ else --env ARVADOS_BUILDING_ITERATION="$ARVADOS_BUILDING_ITERATION" \ --env ARVADOS_DEBUG=$ARVADOS_DEBUG \ --env "ONLY_BUILD=$ONLY_BUILD" \ + --env "FORCE_BUILD=$FORCE_BUILD" \ "$IMAGE" $COMMAND then echo diff --git a/build/run-build-packages-sso.sh b/build/run-build-packages-sso.sh index e7a3aacda3..d8d9b984a0 100755 --- a/build/run-build-packages-sso.sh +++ b/build/run-build-packages-sso.sh @@ -17,7 +17,7 @@ Options: --debug Output debug information (default: false) --target - Distribution to build packages for (default: debian9) + Distribution to build packages for (default: debian10) WORKSPACE=path Path to the Arvados SSO source tree to build packages from @@ -25,7 +25,7 @@ EOF EXITCODE=0 DEBUG=${ARVADOS_DEBUG:-0} -TARGET=debian9 +TARGET=debian10 PARSEDOPTS=$(getopt --name "$0" --longoptions \ help,build-bundle-packages,debug,target: \ diff --git a/build/run-build-packages.sh b/build/run-build-packages.sh index a07b308179..d6c8f5ac64 100755 --- a/build/run-build-packages.sh +++ b/build/run-build-packages.sh @@ -19,9 +19,12 @@ Options: --debug Output debug information (default: false) --target - Distribution to build packages for (default: debian9) + Distribution to build packages for (default: debian10) --only-build Build only a specific package (or $ONLY_BUILD from environment) +--force-build + Build even if the package exists upstream or if it has already been + built locally --command Build command to execute (defaults to the run command defined in the Docker image) @@ -41,12 +44,13 @@ VENDOR="Veritas Genetics, Inc." # End of user configuration DEBUG=${ARVADOS_DEBUG:-0} +FORCE_BUILD=${FORCE_BUILD:-0} EXITCODE=0 -TARGET=debian9 +TARGET=debian10 COMMAND= PARSEDOPTS=$(getopt --name "$0" --longoptions \ - help,build-bundle-packages,debug,target:,only-build: \ + help,build-bundle-packages,debug,target:,only-build:,force-build \ -- "" "$@") if [ $? -ne 0 ]; then exit 1 @@ -66,6 +70,9 @@ while [ $# -gt 0 ]; do --only-build) ONLY_BUILD="$2"; shift ;; + --force-build) + FORCE_BUILD=1 + ;; --debug) DEBUG=1 ;; @@ -157,14 +164,6 @@ if [[ "$?" != 0 ]]; then exit 1 fi -PYTHON2_FPM_INSTALLER=(--python-easyinstall "$(find_python_program easy_install-$PYTHON2_VERSION easy_install)") -install3=$(find_python_program easy_install-$PYTHON3_VERSION easy_install3 pip-$PYTHON3_VERSION pip3) -if [[ $install3 =~ easy_ ]]; then - PYTHON3_FPM_INSTALLER=(--python-easyinstall "$install3") -else - PYTHON3_FPM_INSTALLER=(--python-pip "$install3") -fi - RUN_BUILD_PACKAGES_PATH="`dirname \"$0\"`" RUN_BUILD_PACKAGES_PATH="`( cd \"$RUN_BUILD_PACKAGES_PATH\" && pwd )`" # absolutized and normalized if [ -z "$RUN_BUILD_PACKAGES_PATH" ] ; then diff --git a/build/run-build-test-packages-one-target.sh b/build/run-build-test-packages-one-target.sh index d75e2785ec..8539ec4f0a 100755 --- a/build/run-build-test-packages-one-target.sh +++ b/build/run-build-test-packages-one-target.sh @@ -10,7 +10,7 @@ Syntax: WORKSPACE=/path/to/arvados $(basename $0) [options] --target - Distribution to build packages for (default: debian9) + Distribution to build packages for (default: debian10) --upload If the build and test steps are successful, upload the packages to a remote apt repository (default: false) @@ -50,7 +50,7 @@ if [ $? -ne 0 ]; then exit 1 fi -TARGET=debian9 +TARGET=debian10 UPLOAD=0 RC=0 DEBUG= diff --git a/build/run-library.sh b/build/run-library.sh index a4cebbc8a7..f173504c58 100755 --- a/build/run-library.sh +++ b/build/run-library.sh @@ -316,7 +316,9 @@ test_package_presence() { # sure it gets picked up by the test and/or upload steps. # Get the list of packages from the repos - if [[ "$FORMAT" == "deb" ]]; then + if [[ "$FORCE_BUILD" == "1" ]]; then + echo "Package $full_pkgname build forced with --force-build, building" + elif [[ "$FORMAT" == "deb" ]]; then declare -A dd dd[debian9]=stretch dd[debian10]=buster diff --git a/build/run-tests.sh b/build/run-tests.sh index 38005070c7..b32fe6a43f 100755 --- a/build/run-tests.sh +++ b/build/run-tests.sh @@ -520,6 +520,10 @@ setup_ruby_environment() { || fatal 'rvm gemset setup' rvm env + (bundle version | grep -q 2.0.2) || gem install bundler -v 2.0.2 + bundle="$(which bundle)" + echo "$bundle" + "$bundle" version | grep 2.0.2 || fatal 'install bundler' else # When our "bundle install"s need to install new gems to # satisfy dependencies, we want them to go where "gem install @@ -545,9 +549,14 @@ setup_ruby_environment() { echo "Will install dependencies to $(gem env gemdir)" echo "Will install arvados gems to $tmpdir_gem_home" echo "Gem search path is GEM_PATH=$GEM_PATH" + bundle="$(gem env gempath | cut -f1 -d:)/bin/bundle" + ( + export HOME=$GEMHOME + ("$bundle" version | grep -q 2.0.2) \ + || gem install --user bundler -v 2.0.2 + "$bundle" version | grep 2.0.2 + ) || fatal 'install bundler' fi - bundle config || gem install bundler \ - || fatal 'install bundler' } with_test_gemset() { @@ -665,11 +674,6 @@ Warning: python3 could not be found. Python 3 tests will be skipped. EOF fi - - if ! which bundler >/dev/null - then - gem install --user-install bundler || fatal 'Could not install bundler' - fi } retry() { @@ -881,11 +885,11 @@ bundle_install_trylocal() { ( set -e echo "(Running bundle install --local. 'could not find package' messages are OK.)" - if ! bundle install --local --no-deployment; then + if ! "$bundle" install --local --no-deployment; then echo "(Running bundle install again, without --local.)" - bundle install --no-deployment + "$bundle" install --no-deployment fi - bundle package --all + "$bundle" package --all ) } @@ -933,7 +937,8 @@ install_services/login-sync() { install_services/api() { stop_services cd "$WORKSPACE/services/api" \ - && RAILS_ENV=test bundle_install_trylocal + && RAILS_ENV=test bundle_install_trylocal \ + || return 1 rm -f config/environments/test.rb cp config/environments/test.rb.example config/environments/test.rb @@ -969,11 +974,11 @@ install_services/api() { (cd "$WORKSPACE/services/api" export RAILS_ENV=test - if bundle exec rails db:environment:set ; then - bundle exec rake db:drop + if "$bundle" exec rails db:environment:set ; then + "$bundle" exec rake db:drop fi - bundle exec rake db:setup \ - && bundle exec rake db:fixtures:load + "$bundle" exec rake db:setup \ + && "$bundle" exec rake db:fixtures:load ) } @@ -999,7 +1004,7 @@ install_apps/workbench() { cd "$WORKSPACE/apps/workbench" \ && mkdir -p tmp/cache \ && RAILS_ENV=test bundle_install_trylocal \ - && RAILS_ENV=test RAILS_GROUPS=assets bundle exec rake npm:install + && RAILS_ENV=test RAILS_GROUPS=assets "$bundle" exec rake npm:install } test_doc() { @@ -1009,7 +1014,7 @@ test_doc() { ARVADOS_API_HOST=qr1hi.arvadosapi.com # Make sure python-epydoc is installed or the next line won't # do much good! - PYTHONPATH=$WORKSPACE/sdk/python/ bundle exec rake linkchecker baseurl=file://$WORKSPACE/doc/.site/ arvados_workbench_host=https://workbench.$ARVADOS_API_HOST arvados_api_host=$ARVADOS_API_HOST + PYTHONPATH=$WORKSPACE/sdk/python/ "$bundle" exec rake linkchecker baseurl=file://$WORKSPACE/doc/.site/ arvados_workbench_host=https://workbench.$ARVADOS_API_HOST arvados_api_host=$ARVADOS_API_HOST ) } @@ -1022,12 +1027,12 @@ test_gofmt() { test_services/api() { rm -f "$WORKSPACE/services/api/git-commit.version" cd "$WORKSPACE/services/api" \ - && eval env RAILS_ENV=test ${short:+RAILS_TEST_SHORT=1} bundle exec rake test TESTOPTS=\'-v -d\' ${testargs[services/api]} + && eval env RAILS_ENV=test ${short:+RAILS_TEST_SHORT=1} "$bundle" exec rake test TESTOPTS=\'-v -d\' ${testargs[services/api]} } test_sdk/ruby() { cd "$WORKSPACE/sdk/ruby" \ - && bundle exec rake test TESTOPTS=-v ${testargs[sdk/ruby]} + && "$bundle" exec rake test TESTOPTS=-v ${testargs[sdk/ruby]} } test_sdk/R() { @@ -1040,7 +1045,7 @@ test_sdk/R() { test_sdk/cli() { cd "$WORKSPACE/sdk/cli" \ && mkdir -p /tmp/keep \ - && KEEP_LOCAL_STORE=/tmp/keep bundle exec rake test TESTOPTS=-v ${testargs[sdk/cli]} + && KEEP_LOCAL_STORE=/tmp/keep "$bundle" exec rake test TESTOPTS=-v ${testargs[sdk/cli]} } test_sdk/java-v2() { @@ -1049,7 +1054,7 @@ test_sdk/java-v2() { test_services/login-sync() { cd "$WORKSPACE/services/login-sync" \ - && bundle exec rake test TESTOPTS=-v ${testargs[services/login-sync]} + && "$bundle" exec rake test TESTOPTS=-v ${testargs[services/login-sync]} } test_services/nodemanager_integration() { @@ -1060,31 +1065,31 @@ test_services/nodemanager_integration() { test_apps/workbench_units() { local TASK="test:units" cd "$WORKSPACE/apps/workbench" \ - && eval env RAILS_ENV=test ${short:+RAILS_TEST_SHORT=1} bundle exec rake ${TASK} TESTOPTS=\'-v -d\' ${testargs[apps/workbench]} ${testargs[apps/workbench_units]} + && eval env RAILS_ENV=test ${short:+RAILS_TEST_SHORT=1} "$bundle" exec rake ${TASK} TESTOPTS=\'-v -d\' ${testargs[apps/workbench]} ${testargs[apps/workbench_units]} } test_apps/workbench_functionals() { local TASK="test:functionals" cd "$WORKSPACE/apps/workbench" \ - && eval env RAILS_ENV=test ${short:+RAILS_TEST_SHORT=1} bundle exec rake ${TASK} TESTOPTS=\'-v -d\' ${testargs[apps/workbench]} ${testargs[apps/workbench_functionals]} + && eval env RAILS_ENV=test ${short:+RAILS_TEST_SHORT=1} "$bundle" exec rake ${TASK} TESTOPTS=\'-v -d\' ${testargs[apps/workbench]} ${testargs[apps/workbench_functionals]} } test_apps/workbench_integration() { local TASK="test:integration" cd "$WORKSPACE/apps/workbench" \ - && eval env RAILS_ENV=test ${short:+RAILS_TEST_SHORT=1} bundle exec rake ${TASK} TESTOPTS=\'-v -d\' ${testargs[apps/workbench]} ${testargs[apps/workbench_integration]} + && eval env RAILS_ENV=test ${short:+RAILS_TEST_SHORT=1} "$bundle" exec rake ${TASK} TESTOPTS=\'-v -d\' ${testargs[apps/workbench]} ${testargs[apps/workbench_integration]} } test_apps/workbench_benchmark() { local TASK="test:benchmark" cd "$WORKSPACE/apps/workbench" \ - && eval env RAILS_ENV=test ${short:+RAILS_TEST_SHORT=1} bundle exec rake ${TASK} ${testargs[apps/workbench_benchmark]} + && eval env RAILS_ENV=test ${short:+RAILS_TEST_SHORT=1} "$bundle" exec rake ${TASK} ${testargs[apps/workbench_benchmark]} } test_apps/workbench_profile() { local TASK="test:profile" cd "$WORKSPACE/apps/workbench" \ - && eval env RAILS_ENV=test ${short:+RAILS_TEST_SHORT=1} bundle exec rake ${TASK} ${testargs[apps/workbench_profile]} + && eval env RAILS_ENV=test ${short:+RAILS_TEST_SHORT=1} "$bundle" exec rake ${TASK} ${testargs[apps/workbench_profile]} } install_deps() { diff --git a/doc/Rakefile b/doc/Rakefile index f1aa3bfce8..63dc16d25d 100644 --- a/doc/Rakefile +++ b/doc/Rakefile @@ -3,6 +3,12 @@ # # SPDX-License-Identifier: CC-BY-SA-3.0 +# As a convenience to the documentation writer, you can touch a file +# called 'no-sdk' in the 'doc' directory and it will suppress +# generating the documentation for the SDKs, which (the R docs +# especially) take a fair bit of time and slow down the edit-preview +# cycle. + require "rubygems" require "colorize" @@ -16,6 +22,9 @@ task :generate => [ :realclean, 'sdk/python/arvados/index.html', 'sdk/R/arvados/ end file "sdk/python/arvados/index.html" do |t| + if File.exists? "no-sdk" + next + end `which epydoc` if $? == 0 STDERR.puts `epydoc --html --parse-only -o sdk/python/arvados ../sdk/python/arvados/ 2>&1` @@ -26,6 +35,9 @@ file "sdk/python/arvados/index.html" do |t| end file "sdk/R/arvados/index.html" do |t| + if File.exists? "no-sdk" + next + end `which R` if $? == 0 tgt = Dir.pwd @@ -88,6 +100,9 @@ EOF end file "sdk/java-v2/javadoc/index.html" do |t| + if File.exists? "no-sdk" + next + end `which java` if $? == 0 `which gradle` diff --git a/doc/_config.yml b/doc/_config.yml index 404d2f6c63..bcb66fdb39 100644 --- a/doc/_config.yml +++ b/doc/_config.yml @@ -114,6 +114,7 @@ navbar: - api/methods/authorized_keys.html.textile.liquid - api/methods/groups.html.textile.liquid - api/methods/users.html.textile.liquid + - api/methods/user_agreements.html.textile.liquid - System resources: - api/methods/keep_services.html.textile.liquid - api/methods/links.html.textile.liquid @@ -155,14 +156,17 @@ navbar: - admin/upgrading.html.textile.liquid - admin/config-migration.html.textile.liquid - Users and Groups: - - install/cheat_sheet.html.textile.liquid - - admin/activation.html.textile.liquid + - admin/user-management.html.textile.liquid + - admin/reassign-ownership.html.textile.liquid + - admin/user-management-cli.html.textile.liquid + - admin/group-management.html.textile.liquid - admin/merge-remote-account.html.textile.liquid - admin/migrating-providers.html.textile.liquid - user/topics/arvados-sync-groups.html.textile.liquid - Monitoring: - - admin/health-checks.html.textile.liquid + - admin/logging.html.textile.liquid - admin/metrics.html.textile.liquid + - admin/health-checks.html.textile.liquid - admin/management-token.html.textile.liquid - Cloud: - admin/storage-classes.html.textile.liquid @@ -175,7 +179,6 @@ navbar: - admin/controlling-container-reuse.html.textile.liquid - admin/logs-table-management.html.textile.liquid - Other: - - admin/troubleshooting.html.textile.liquid - install/migrate-docker19.html.textile.liquid - admin/upgrade-crunch2.html.textile.liquid - admin/workbench2-vocabulary.html.textile.liquid diff --git a/doc/_includes/_navbar_left.liquid b/doc/_includes/_navbar_left.liquid index cba6c46e4d..d3ac2932d3 100644 --- a/doc/_includes/_navbar_left.liquid +++ b/doc/_includes/_navbar_left.liquid @@ -11,9 +11,9 @@ SPDX-License-Identifier: CC-BY-SA-3.0 {% for entry in section %}
  • {{ entry[0] }} diff --git a/doc/admin/User account states.odg b/doc/admin/User account states.odg new file mode 100644 index 0000000000..866e07aebe Binary files /dev/null and b/doc/admin/User account states.odg differ diff --git a/doc/admin/activation.html.textile.liquid b/doc/admin/activation.html.textile.liquid deleted file mode 100644 index 4a08e509c1..0000000000 --- a/doc/admin/activation.html.textile.liquid +++ /dev/null @@ -1,229 +0,0 @@ ---- -layout: default -navsection: admin -title: User activation -... - -{% comment %} -Copyright (C) The Arvados Authors. All rights reserved. - -SPDX-License-Identifier: CC-BY-SA-3.0 -{% endcomment %} - -This page describes how new users are created and activated. - -"Browser login and management of API tokens is described here.":{{site.baseurl}}/api/tokens.html - -h3. Authentication - -After completing the authentication process, a callback is made from the SSO server to the API server, providing a user record and @identity_url@ (despite the name, this is actually an Arvados user uuid). - -The API server searches for a user record with the @identity_url@ supplied by the SSO. If found, that user account will be used, unless the account has @redirect_to_user_uuid@ set, in which case it will use the user in @redirect_to_user_uuid@ instead (this is used for the "link account":{{site.baseurl}}/user/topics/link-accounts.html feature). - -Next, it searches by email address for a "pre-activated account.":#pre-activated - -If no existing user record is found, a new user object will be created. - -A federated user follows a slightly different flow, whereby a special token is presented and the API server verifies user's identity with the home cluster, however it also results in a user object (representing the remote user) being created. - -h3. User setup - -If @auto_setup_new_users@ is true, as part of creating the new user object, the user is immediately set up with: - -* @can_login@ @permission@ link going (email address → user uuid) which records @identity_url_prefix@ -* Membership in the "All users" group (can read all users, all users can see new user) -* A new git repo and @can_manage@ permission if @auto_setup_new_users_with_repository@ is true -* @can_login@ permission to a shell node if @auto_setup_new_users_with_vm_uuid@ is set to the uuid of a vm - -Otherwise, an admin must explicitly invoke "setup" on the user via workbench or the API. - -h3. User activation - -A newly created user is inactive (@is_active@ is false) by default unless @new_users_are_active@. - -An inactive user cannot create or update any object, but can read Arvados objects that the user account has permission to read. This implies that if @auto_setup_new_users@ is true, an "inactive" user who has been set up may still be able to do things, such as read things shared with "All users", clone and push to the git repository, or login to a VM. - -{% comment %} -Maybe these services should check is_active. - -I believe that when this was originally designed, being able to access git and VM required an ssh key, and an inactive user could not register an ssh key because that required creating a record. However, it is now possible to authenticate to shell VMs and http+git with just an API token. -{% endcomment %} - -At this point, there are two ways a user can be activated. - -# An admin can set the @is_active@ field directly. This runs @setup_on_activate@ which sets up oid_login_perm and group membership, but does not set repo or vm (even if if @auto_setup_new_users_with_repository@ and/or @auto_setup_new_users_with_vm_uuid@ are set). -# Self-activation using the @activate@ method of the users controller. - -h3. User agreements - -The @activate@ method of the users controller checks if the user @is_invited@ and whether the user has "signed" all the user agreements. - -@is_invited@ is true if any of these are true: -* @is_active@ is true -* @new_users_are_active@ is true -* the user account has a permission link to read the system "all users" group. - -User agreements are accessed by getting a listing on the @user_agreements@ endpoint. This returns a list of collection uuids. This is executed as a system user, so it bypasses normal read permission checks. - -The available user agreements are represented in the Links table as - -
    -{
    -  "link_class": "signature",
    -  "name": "require",
    -  "tail_uuid": "*system user uuid*",
    -  "head_uuid: "*collection uuid*"
    -}
    -
    - -The collection contains the user agreement text file. - -On workbench, it checks @is_invited@. If true, it displays the clickthrough agreements which the user can "sign". If @is_invited@ is false, the user ends up at the "inactive user" page. - -The @user_agreements/sign@ endpoint creates a Link object: - -
    -{
    -  "link_class": "signature"
    -  "name": "click",
    -  "tail_uuid": "*user uuid*",
    -  "head_uuid: "*collection uuid*"
    -}
    -
    - -This is executed as a system user, so it bypasses the restriction that inactive users cannot create objects. - -The @user_agreements/signatures@ endpoint returns the list of Link objects that represent signatures by the current user (created by @sign@). - -h3. User profile - -The user profile is checked by workbench after checking if user agreements need to be signed. The requirement to fill out the user profile is not enforced by the API server. - -h3(#pre-activated). Pre-activate user by email address - -You may create a user account for a user that has not yet logged in, and identify the user by email address. - -1. As an admin, create a user object: - -
    -{
    -  "email": "foo@example.com",
    -  "username": "barney",
    -  "is_active": true
    -}
    -
    - -2. Create a link object, where @tail_uuid@ is the user's email address, @head_uuid@ is the user object created in the previous step, and @xxxxx@ is the value of @uuid_prefix@ of the SSO server. - -
    -{
    -  "link_class": "permission",
    -  "name": "can_login",
    -  "tail_uuid": "email address",
    -  "head_uuid: "user uuid",
    -  "properties": {
    -    "identity_url_prefix": "xxxxx-tpzed-"
    -  }
    -}
    -
    - -3. When the user logs in the first time, the email address will be recognized and the user will be associated with the linked user object. - -h3. Pre-activate federated user - -1. As admin, create a user object with the @uuid@ of the federated user (this is the user's uuid on their home cluster): - -
    -{
    -  "uuid": "home1-tpzed-000000000000000",
    -  "email": "foo@example.com",
    -  "username": "barney",
    -  "is_active": true
    -}
    -
    - -2. When the user logs in, they will be associated with the existing user object. - -h3. Auto-activate federated users from trusted clusters - -In the API server config, configure @auto_activate_users_from@ with a list of one or more five-character cluster ids. A federated user from one of the listed clusters which @is_active@ on the home cluster will be automatically set up and activated on this cluster. - -h3(#deactivating_users). Deactivating users - -Setting @is_active@ is not sufficient to lock out a user. The user can call @activate@ to become active again. Instead, use @unsetup@: - -* Delete oid_login_perms -* Delete git repository permission links -* Delete VM login permission links -* Remove from "All users" group -* Delete any "signatures" -* Clear preferences / profile -* Mark as inactive - -{% comment %} -Does not revoke @is_admin@, so you can't unsetup an admin unless you turn admin off first. - -"inactive" does not prevent user from reading things they previously had access to. - -Does not revoke API tokens. -{% endcomment %} - -h3. Activation flows - -h4. Private instance - -Policy: users must be manually approved. - -
    -auto_setup_new_users: false
    -new_users_are_active: false
    -
    - -# User is created. Not set up. @is_active@ is false. -# Workbench checks @is_invited@ and finds it is false. User gets "inactive user" page. -# Admin goes to user page and clicks either "setup user" or manually @is_active@ to true. -# Clicking "setup user" sets up the user. This includes adding the user to "All users" which qualifies the user as @is_invited@. -# On refreshing workbench, the user is still inactive, but is able to self-activate after signing clickthrough agreements (if any). -# Alternately, directly setting @is_active@ to true also sets up the user, but workbench won't display clickthrough agreements (because the user is already active). - -h4. Federated instance - -Policy: users from other clusters in the federation are activated, users from outside the federation must be manually approved - -
    -auto_setup_new_users: false
    -new_users_are_active: false
    -auto_activate_users_from: [home1]
    -
    - -# Federated user arrives claiming to be from cluster 'home1' -# API server authenticates user as being from cluster 'home1' -# Because 'home1' is in @auto_activate_users_from@ the user is set up and activated. -# User can immediately start using workbench. - -h4. Open instance - -Policy: anybody who shows up and signs the agreements is activated. - -
    -auto_setup_new_users: true
    -new_users_are_active: false
    -
    - -# User is created and auto-setup. At this point, @is_active@ is false, but user has been added to "All users" group. -# Workbench checks @is_invited@ and finds it is true, because the user is a member of "All users" group. -# Workbench presents user with list of user agreements, user reads and clicks "sign" for each one. -# Workbench tries to activate user. -# User is activated. - -h4. Developer instance - -Policy: avoid wasting developer's time during development/testing - -
    -auto_setup_new_users: true
    -new_users_are_active: true
    -
    - -# User is created, immediately auto-setup, and auto-activated. -# User can immediately start using workbench. diff --git a/doc/admin/activation.html.textile.liquid b/doc/admin/activation.html.textile.liquid new file mode 120000 index 0000000000..5e599a6b53 --- /dev/null +++ b/doc/admin/activation.html.textile.liquid @@ -0,0 +1 @@ +user-management.html.textile.liquid \ No newline at end of file diff --git a/doc/admin/group-management.html.textile.liquid b/doc/admin/group-management.html.textile.liquid new file mode 100644 index 0000000000..127b91423a --- /dev/null +++ b/doc/admin/group-management.html.textile.liquid @@ -0,0 +1,104 @@ +--- +layout: default +navsection: admin +title: Group management +... + +{% comment %} +Copyright (C) The Arvados Authors. All rights reserved. + +SPDX-License-Identifier: CC-BY-SA-3.0 +{% endcomment %} + +This page describes how to manage groups at the command line. You should be familiar with the "permission system":{{site.baseurl}}/api/permission-model.html . + +h2. Create a group + +User groups are entries in the "groups" table with @"group_class": "role"@. + +
    +arv group create --group '{"name": "My new group", "group_class": "role"}'
    +
    + +h2. Add a user to a group + +There are two separate permissions associated with group membership. The first link grants the user @can_manage@ permission to manage things that the group can manage. The second link grants permission for other users of the group to see that this user is part of the group. + +
    +arv link create --link '{
    +  "link_class": "permission",
    +  "name": "can_manage",
    +  "tail_uuid": "the_user_uuid",
    +  "head_uuid": "the_group_uuid"}'
    +
    +arv link create --link '{
    +  "link_class": "permission",
    +  "name": "can_read",
    +  "tail_uuid": "the_group_uuid",
    +  "head_uuid": "the_user_uuid"}'
    +
    + +A user can also be given read-only access to a group. In that case, the first link should be created with @can_read@ instead of @can_manage@. + +h2. List groups + +
    +arv group list --filters '[["group_class", "=", "role"]]'
    +
    + +h2. List members of a group + +Use the command "jq":https://stedolan.github.io/jq/ to extract the tail_uuid of each permission link which has the user uuid. + +
    +arv link list --filters '[["link_class", "=", "permission"],
    +  ["head_uuid", "=", "the_group_uuid"]]' | jq .items[].tail_uuid
    +
    + +h2. Share a project with a group + +This will give all members of the group @can_manage@ access. + +
    +arv link create --link '{
    +  "link_class": "permission",
    +  "name": "can_manage",
    +  "tail_uuid": "the_group_uuid",
    +  "head_uuid": "the_project_uuid"}'
    +
    + +A project can also be shared read-only. In that case, the first link should be created with @can_read@ instead of @can_manage@. + +h2. List things shared with the group + +Use the command "jq":https://stedolan.github.io/jq/ to extract the head_uuid of each permission link which has the object uuid. + +
    +arv link list --filters '[["link_class", "=", "permission"],
    +  ["tail_uuid", "=", "the_group_uuid"]]' | jq .items[].head_uuid
    +
    + +h2. Stop sharing a project with a group + +This will remove access for members of the group. + +The first step is to find the permission link objects. The second step is to delete them. + +
    +arv --format=uuid link list --filters '[["link_class", "=", "permission"],
    +  ["tail_uuid", "=", "the_group_uuid"], ["head_uuid", "=", "the_project_uuid"]]'
    +
    +arv link delete --uuid each_link_uuid
    +
    + +h2. Remove user from a group + +The first step is to find the permission link objects. The second step is to delete them. + +
    +arv --format=uuid link list --filters '[["link_class", "=", "permission"],
    +  ["tail_uuid", "in", ["the_user_uuid", "the_group_uuid"]],
    +  ["head_uuid", "in", ["the_user_uuid", "the_group_uuid"]]'
    +
    +arv link delete --uuid each_link_uuid
    +
    diff --git a/doc/admin/logging.html.textile.liquid b/doc/admin/logging.html.textile.liquid new file mode 100644 index 0000000000..45dc11d75c --- /dev/null +++ b/doc/admin/logging.html.textile.liquid @@ -0,0 +1,78 @@ +--- +layout: default +navsection: admin +title: Logging +... + +{% comment %} +Copyright (C) The Arvados Authors. All rights reserved. + +SPDX-License-Identifier: CC-BY-SA-3.0 +{% endcomment %} + +Most Arvados services write JSON-format structured logs to stderr, which can be parsed by any operational tools that support JSON. + +h2. Request ids + +Using a distributed system with several services working together sometimes makes it difficult to find the root cause of errors, as one single client request usually means several different requests to more than one service. + +To deal with this difficulty, Arvados creates a request ID that gets carried over different services as the requests take place. This ID has a specific format and it's comprised of the prefix "@req-@" followed by 20 random alphanumeric characters: + +
    req-frdyrcgdh4rau1ajiq5q
    + +This ID gets propagated via an HTTP @X-Request-Id@ header, and gets logged on every service. + +h3. API Server error reporting and logging + +In addition to providing the request ID on every HTTP response, the API Server adds it to every error message so that all clients show enough information to the user to be able to track a particular issue. As an example, let's suppose that we get the following error when trying to create a collection using the CLI tools: + +
    +$ arv collection create --collection '{}'
    +Error: # (req-ku5ct9ehw0y71f1c5p79)
    +
    + +The API Server logs every request in JSON format on the @production.log@ (usually under @/var/www/arvados-api/current/log/@ when installing from packages) file, so we can retrieve more information about this by using @grep@ and @jq@ tools: + +
    +# grep req-ku5ct9ehw0y71f1c5p79 /var/www/arvados-api/current/log/production.log | jq .
    +{
    +  "method": "POST",
    +  "path": "/arvados/v1/collections",
    +  "format": "json",
    +  "controller": "Arvados::V1::CollectionsController",
    +  "action": "create",
    +  "status": 422,
    +  "duration": 1.52,
    +  "view": 0.25,
    +  "db": 0,
    +  "request_id": "req-ku5ct9ehw0y71f1c5p79",
    +  "client_ipaddr": "127.0.0.1",
    +  "client_auth": "zzzzz-gj3su-jllemyj9v3s5emu",
    +  "exception": "#",
    +  "exception_backtrace": "/var/www/arvados-api/current/app/controllers/arvados/v1/collections_controller.rb:43:in `create'\n/var/lib/gems/ruby/2.3.0/gems/actionpack-5.0.7.2/lib/action_controller/metal/basic_implicit_render.rb:4:in `send_action'\n ...[snipped]",
    +  "params": {
    +    "collection": "{}",
    +    "_profile": "true",
    +    "cluster_id": "",
    +    "collection_given": "true",
    +    "ensure_unique_name": "false",
    +    "help": "false"
    +  },
    +  "@timestamp": "2019-07-15T16:40:41.726634182Z",
    +  "@version": "1",
    +  "message": "[422] POST /arvados/v1/collections (Arvados::V1::CollectionsController#create)"
    +}
    +
    + +When logging a request that produced an error, the API Server adds @exception@ and @exception_backtrace@ keys to the JSON log. The latter includes the complete error stack trace as a string, and can be displayed in a more readable form like so: + +
    +# grep req-ku5ct9ehw0y71f1c5p79 /var/www/arvados-api/current/log/production.log | jq -r .exception_backtrace
    +/var/www/arvados-api/current/app/controllers/arvados/v1/collections_controller.rb:43:in `create'
    +/var/lib/gems/ruby/2.3.0/gems/actionpack-5.0.7.2/lib/action_controller/metal/basic_implicit_render.rb:4:in `send_action'
    +/var/lib/gems/ruby/2.3.0/gems/actionpack-5.0.7.2/lib/abstract_controller/base.rb:188:in `process_action'
    +/var/lib/gems/ruby/2.3.0/gems/actionpack-5.0.7.2/lib/action_controller/metal/rendering.rb:30:in `process_action'
    +/var/lib/gems/ruby/2.3.0/gems/actionpack-5.0.7.2/lib/abstract_controller/callbacks.rb:20:in `block in process_action'
    +/var/lib/gems/ruby/2.3.0/gems/activesupport-5.0.7.2/lib/active_support/callbacks.rb:126:in `call'
    +...
    +
    diff --git a/doc/admin/migrating-providers.html.textile.liquid b/doc/admin/migrating-providers.html.textile.liquid index 9231dc2926..6dd0d866e7 100644 --- a/doc/admin/migrating-providers.html.textile.liquid +++ b/doc/admin/migrating-providers.html.textile.liquid @@ -1,7 +1,7 @@ --- layout: default navsection: admin -title: "Migrating account providers" +title: Changing upstream login providers ... {% comment %} Copyright (C) The Arvados Authors. All rights reserved. @@ -9,11 +9,11 @@ Copyright (C) The Arvados Authors. All rights reserved. SPDX-License-Identifier: CC-BY-SA-3.0 {% endcomment %} -This page describes how to enable users to use more than one provider to log into the same Arvados account. This can be used to migrate account providers, for example, from LDAP to Google. In order to do this, users must be able to log into both the "old" and "new" providers. +This page describes how to enable users to use more than one upstream identity provider to log into the same Arvados account. This can be used to migrate account providers, for example, from LDAP to Google. In order to do this, users must be able to log into both the "old" and "new" providers. -h2. Configure multiple providers in SSO +h2. Configure multiple or alternate provider in SSO -In @application.yml@ for the SSO server, enable both @google_oauth2@ and @ldap@ providers: +In @application.yml@ for the SSO server, you can enable both @google_oauth2@ and @ldap@ providers:
     production:
    @@ -32,9 +32,13 @@ production:
     
     Restart the SSO server after changing the configuration.
     
    +h2. Matching on email address
    +
    +If the new account provider supplies an email address (primary or alternate) that matches an existing user account, the user will be logged into that account.  No further migration is necessary, and the old provider can be removed from the SSO configuration.
    +
     h2. Link accounts
     
    -Instruct users to go through the process of "linking accounts":{{site.baseurl}}/user/topics/link-accounts.html
    +If the new provider cannot provide matching email addresses, users will have to migrate manually by "linking accounts":{{site.baseurl}}/user/topics/link-accounts.html
     
     After linking accounts, users can use the new provider to access their existing Arvados account.
     
    diff --git a/doc/admin/reassign-ownership.html.textile.liquid b/doc/admin/reassign-ownership.html.textile.liquid
    new file mode 100644
    index 0000000000..9c33e18256
    --- /dev/null
    +++ b/doc/admin/reassign-ownership.html.textile.liquid
    @@ -0,0 +1,51 @@
    +---
    +layout: default
    +navsection: admin
    +title: "Reassign user data ownership"
    +...
    +{% comment %}
    +Copyright (C) The Arvados Authors. All rights reserved.
    +
    +SPDX-License-Identifier: CC-BY-SA-3.0
    +{% endcomment %}
    +
    +If a user leaves an organization and stops using their Arvados account, it may be desirable to reassign the data owned by that user to another user to maintain easy access.
    +
    +This is currently a command line based, admin-only feature.
    +
    +h3. Step 1: Determine user uuids
    +
    +User uuids can be determined by browsing workbench or using @arv user list@ at the command line.
    +
    +The "old user" is the user that is leaving the organization.
    +
    +The "new user" is the user that will gain ownership of the old user's data.  This includes collections, projects, container requests, workflows, and git repositories owned by the old user.  It also transfers any permissions granted to the old user, to the new user.
    +
    +In the example below, @x1u39-tpzed-3kz0nwtjehhl0u4@ is the old user and @x1u39-tpzed-fr97h9t4m5jffxs@ is the new user.
    +
    +h3. Step 2: Create a project
    +
    +Create a project owned by the new user that will hold the data from the old user.
    +
    +
    +$ arv --format=uuid group create --group '{"group_class": "project", "name": "Data from old user", "owner_uuid": "x1u39-tpzed-fr97h9t4m5jffxs"}'
    +x1u39-j7d0g-mczqiguhil13083
    +
    + +h3. Step 3: Reassign data from the old user to the new user and project + +The @user merge@ method reassigns data from the old user to the new user. + +
    +$ arv user merge --old-user-uuid=x1u39-tpzed-3kz0nwtjehhl0u4 \
    +  --new-user-uuid=x1u39-tpzed-fr97h9t4m5jffxs \
    +  --new-owner-uuid=x1u39-j7d0g-mczqiguhil13083
    +
    + +After reassigning data, use @unsetup@ to deactivate the old user's account. + +
    +$ arv user unsetup --uuid=x1u39-tpzed-3kz0nwtjehhl0u4
    +
    + +Note that authorization credentials (API tokens, ssh keys) are *not* transferred to the new user, as this would potentially give the old user access to the new user's account. diff --git a/doc/admin/troubleshooting.html.textile.liquid b/doc/admin/troubleshooting.html.textile.liquid deleted file mode 100644 index 66c75f344d..0000000000 --- a/doc/admin/troubleshooting.html.textile.liquid +++ /dev/null @@ -1,74 +0,0 @@ ---- -layout: default -navsection: admin -title: Troubleshooting -... - -{% comment %} -Copyright (C) The Arvados Authors. All rights reserved. - -SPDX-License-Identifier: CC-BY-SA-3.0 -{% endcomment %} - -Using a distributed system with several services working together sometimes makes it difficult to find the root cause of errors, as one single client request usually means several different requests to more than one service. - -To deal with this difficulty, Arvados creates a request ID that gets carried over different services as the requests take place. This ID has a specific format and it's comprised of the prefix "@req-@" followed by 20 random alphanumeric characters: - -
    req-frdyrcgdh4rau1ajiq5q
    - -This ID gets propagated via an HTTP @X-Request-Id@ header, and gets logged on every service. - -h3. API Server error reporting and logging - -In addition to providing the request ID on every HTTP response, the API Server adds it to every error message so that all clients show enough information to the user to be able to track a particular issue. As an example, let's suppose that we get the following error when trying to create a collection using the CLI tools: - -
    -$ arv collection create --collection '{}'
    -Error: # (req-ku5ct9ehw0y71f1c5p79)
    -
    - -The API Server logs every request in JSON format on the @production.log@ (usually under @/var/www/arvados-api/current/log/@ when installing from packages) file, so we can retrieve more information about this by using @grep@ and @jq@ tools: - -
    -# grep req-ku5ct9ehw0y71f1c5p79 /var/www/arvados-api/current/log/production.log | jq .
    -{
    -  "method": "POST",
    -  "path": "/arvados/v1/collections",
    -  "format": "json",
    -  "controller": "Arvados::V1::CollectionsController",
    -  "action": "create",
    -  "status": 422,
    -  "duration": 1.52,
    -  "view": 0.25,
    -  "db": 0,
    -  "request_id": "req-ku5ct9ehw0y71f1c5p79",
    -  "client_ipaddr": "127.0.0.1",
    -  "client_auth": "zzzzz-gj3su-jllemyj9v3s5emu",
    -  "exception": "#",
    -  "exception_backtrace": "/var/www/arvados-api/current/app/controllers/arvados/v1/collections_controller.rb:43:in `create'\n/var/lib/gems/ruby/2.3.0/gems/actionpack-5.0.7.2/lib/action_controller/metal/basic_implicit_render.rb:4:in `send_action'\n ...[snipped]",
    -  "params": {
    -    "collection": "{}",
    -    "_profile": "true",
    -    "cluster_id": "",
    -    "collection_given": "true",
    -    "ensure_unique_name": "false",
    -    "help": "false"
    -  },
    -  "@timestamp": "2019-07-15T16:40:41.726634182Z",
    -  "@version": "1",
    -  "message": "[422] POST /arvados/v1/collections (Arvados::V1::CollectionsController#create)"
    -}
    -
    - -When logging a request that produced an error, the API Server adds @exception@ and @exception_backtrace@ keys to the JSON log. The latter includes the complete error stack trace as a string, and can be displayed in a more readable form like so: - -
    -# grep req-ku5ct9ehw0y71f1c5p79 /var/www/arvados-api/current/log/production.log | jq -r .exception_backtrace
    -/var/www/arvados-api/current/app/controllers/arvados/v1/collections_controller.rb:43:in `create'
    -/var/lib/gems/ruby/2.3.0/gems/actionpack-5.0.7.2/lib/action_controller/metal/basic_implicit_render.rb:4:in `send_action'
    -/var/lib/gems/ruby/2.3.0/gems/actionpack-5.0.7.2/lib/abstract_controller/base.rb:188:in `process_action'
    -/var/lib/gems/ruby/2.3.0/gems/actionpack-5.0.7.2/lib/action_controller/metal/rendering.rb:30:in `process_action'
    -/var/lib/gems/ruby/2.3.0/gems/actionpack-5.0.7.2/lib/abstract_controller/callbacks.rb:20:in `block in process_action'
    -/var/lib/gems/ruby/2.3.0/gems/activesupport-5.0.7.2/lib/active_support/callbacks.rb:126:in `call'
    -...
    -
    \ No newline at end of file diff --git a/doc/admin/troubleshooting.html.textile.liquid b/doc/admin/troubleshooting.html.textile.liquid new file mode 120000 index 0000000000..88f52eafab --- /dev/null +++ b/doc/admin/troubleshooting.html.textile.liquid @@ -0,0 +1 @@ +logging.html.textile.liquid \ No newline at end of file diff --git a/doc/admin/user-management-cli.html.textile.liquid b/doc/admin/user-management-cli.html.textile.liquid new file mode 100644 index 0000000000..33969ea8f8 --- /dev/null +++ b/doc/admin/user-management-cli.html.textile.liquid @@ -0,0 +1,88 @@ +--- +layout: default +navsection: admin +title: User management at the CLI +... +{% comment %} +Copyright (C) The Arvados Authors. All rights reserved. + +SPDX-License-Identifier: CC-BY-SA-3.0 +{% endcomment %} + +Initial setup + +
    +ARVADOS_API_HOST={{ site.arvados_api_host }}
    +ARVADOS_API_TOKEN=1234567890qwertyuiopasdfghjklzxcvbnm1234567890zzzz
    +
    + +In these examples, @x1u39-tpzed-3kz0nwtjehhl0u4@ is the sample user account. Replace with the uuid of the user you wish to manipulate. + +See "user management":{{site.baseurl}}/admin/activation.html for an overview of how to use these commands. + +h3. Setup a user + +This creates a default git repository and VM login. Enables user to self-activate using Workbench. + +
    +arv user setup --uuid x1u39-tpzed-3kz0nwtjehhl0u4
    +
    + +h3. Deactivate user + +
    +arv user unsetup --uuid x1u39-tpzed-3kz0nwtjehhl0u4
    +
    + +When deactivating a user, you may also want to "reassign ownership of their data":{{site.baseurl}}/admin/reassign-ownership.html . + +h3. Directly activate user + +
    +arv user update --uuid "x1u39-tpzed-3kz0nwtjehhl0u4" --user '{"is_active":true}'
    +
    + +Note this bypasses user agreements checks, and does not set up the user with a default git repository or VM login. + + +h2. Permissions + +h3. VM login + +Give @$user_uuid@ permission to log in to @$vm_uuid@ as @$target_username@ + +
    +user_uuid=xxxxxxxchangeme
    +vm_uuid=xxxxxxxchangeme
    +target_username=xxxxxxxchangeme
    +
    +read -rd $'\000' newlink <
    +
    +h3. Git repository
    +
    +Give @$user_uuid@ permission to commit to @$repo_uuid@ as @$repo_username@
    +
    +
    +user_uuid=xxxxxxxchangeme
    +repo_uuid=xxxxxxxchangeme
    +repo_username=xxxxxxxchangeme
    +
    +read -rd $'\000' newlink <
    diff --git a/doc/admin/user-management.html.textile.liquid b/doc/admin/user-management.html.textile.liquid
    new file mode 100644
    index 0000000000..3de1c66a0d
    --- /dev/null
    +++ b/doc/admin/user-management.html.textile.liquid
    @@ -0,0 +1,185 @@
    +---
    +layout: default
    +navsection: admin
    +title: User management
    +...
    +
    +{% comment %}
    +Copyright (C) The Arvados Authors. All rights reserved.
    +
    +SPDX-License-Identifier: CC-BY-SA-3.0
    +{% endcomment %}
    +
    +{% comment %}
    +TODO: Link to relevant workbench documentation when it gets written
    +{% endcomment %}
    +
    +This page describes how user accounts are created, set up and activated.
    +
    +h2. Authentication
    +
    +"Browser login and management of API tokens is described here.":{{site.baseurl}}/api/tokens.html
    +
    +After completing the log in and authentication process, the API server receives a user record from the upstream identity provider (Google, LDAP, etc) consisting of the user's name, primary email address, alternate email addresses, and optional unique provider identifier (@identity_url@).
    +
    +If a provider identifier is given, the API server searches for a matching user record.
    +
    +If a provider identifier is not given, no match is found, it next searches by primary email and then alternate email address.  This enables "provider migration":migrating-providers.html and a "pre-activated accounts.":#pre-activated
    +
    +If no user account is found, a new user account is created with the information from the identity provider.
    +
    +If a user account has been "linked":{{site.baseurl}}/user/topics/link-accounts.html or "migrated":merge-remote-account.html the API server may follow internal redirects (@redirect_to_user_uuid@) to select the linked or migrated user account.
    +
    +h3. Federated Authentication
    +
    +A federated user follows a slightly different flow.  The client presents a token issued by the remote cluster.  The local API server contacts the remote cluster to verify the user's identity.  This results in a user object (representing the remote user) being created on the local cluster.  If the user cannot be verified, the token will be rejected.  If the user is inactive on the remote cluster, a user record will be created, but it will also be inactive.
    +
    +h2. User activation
    +
    +This section describes the different user account states.
    +
    +!(side){{site.baseurl}}/images/user-account-states.svg!
    +
    +notextile. 
    + +# A new user record is not set up, and not active. An inactive user cannot create or update any object, but can read Arvados objects that the user account has permission to read (such as publicly available items readable by the "anonymous" user). +# Using Workbench or the "command line":{{site.baseurl}}/install/cheat_sheet.html , the admin invokes @setup@ on the user. The setup method adds the user to the "All users" group. +- If "Users.AutoSetupNewUsers":config.html is true, this happens automatically during user creation, so in that case new users start at step (3). +- If "Users.AutoSetupNewUsersWithRepository":config.html is true, a new git repo is created for the user. +- If "Users.AutoSetupNewUsersWithVmUUID":config.html is set, the user is given login permission to the specified shell node +# User is set up, but still not yet active. The browser presents "user agreements":#user_agreements (if any) and then invokes the user @activate@ method on the user's behalf. +# The user @activate@ method checks that all "user agreements":#user_agreements are signed. If so, or there are no user agreements, the user is activated. +# The user is active. User has normal access to the system. +# From steps (1) and (3), an admin user can directly update the @is_active@ flag. This bypasses enforcement that user agreements are signed. +If the user was not yet set up (still in step (1)), it adds the user to the "All users", but bypasses creating default git repository and assigning default VM access. +# An existing user can have their access revoked using @unsetup@ and "ownership reassigned":reassign-ownership.html . +Unsetup removes the user from the "All users" group and makes them inactive, preventing them from re-activating themselves. +"Ownership reassignment":reassign-ownership.html moves any objects or permission from the old user to a new user and deletes any credentials for the old user. + +notextile.
    + +User management can be performed through the web using Workbench or the command line. See "user management at the CLI":{{site.baseurl}}/install/cheat_sheet.html for specific examples. + +h2(#user_agreements). User agreements and self-activation + +The @activate@ method of the users controller checks if the user account is part of the "All Users" group and whether the user has "signed" all the user agreements. + +User agreements are accessed through the "user_agreements API":{{site.baseurl}}/api/methods/user_agreements.html . This returns a list of collection records. + +The user agreements that users are required to sign should be added to the @links@ table this way: + +
    +$ arv link create --link '{
    +  "link_class": "signature",
    +  "name": "require",
    +  "tail_uuid": "*system user uuid*",
    +  "head_uuid: "*collection uuid*"
    +}'
    +
    + +The collection should contain a single HTML file with the user agreement text. + +Workbench displays the clickthrough agreements which the user can "sign". + +The @user_agreements/sign@ endpoint creates a Link object: + +
    +{
    +  "link_class": "signature"
    +  "name": "click",
    +  "tail_uuid": "*user uuid*",
    +  "head_uuid: "*collection uuid*"
    +}
    +
    + +The @user_agreements/signatures@ endpoint returns the list of Link objects that represent signatures by the current user (created by @sign@). + +h2. User profile + +The fields making up the user profile are described in @Workbench.UserProfileFormFields@ . See "Configuration reference":config.html . + +The user profile is checked by workbench after checking if user agreements need to be signed. The values entered are stored in the @properties@ field on the user object. Unlike user agreements, the requirement to fill out the user profile is not enforced by the API server. + +h2(#pre-activated). Pre-setup user by email address + +You may create a user account for a user that has not yet logged in, and identify the user by email address. + +1. As an admin, create a user object: + +
    +$ arv --format=uuid user create --user '{"email": "foo@example.com", "username": "foo"}'
    +clsr1-tpzed-1234567890abcdf
    +$ arv user setup --uuid clsr1-tpzed-1234567890abcdf
    +
    + +2. When the user logs in the first time, the email address will be recognized and the user will be associated with the existing user object. + +h2. Pre-activate federated user + +1. As admin, create a user object with the @uuid@ of the federated user (this is the user's uuid on their home cluster, called @clsr2@ in this example): + +
    +$ arv user create --user '{"uuid": "clsr2-tpzed-1234567890abcdf", "email": "foo@example.com", "username": "foo", "is_active": true}'
    +
    + +2. When the user logs in, they will be associated with the existing user object. + +h2. Auto-setup federated users from trusted clusters + +By setting @ActivateUsers: true@ for each federated cluster in @RemoteClusters@, a federated user from one of the listed clusters will be automatically set up and activated on this cluster. See configuration example in "Federated instance":#federated . + +h2. Activation flows + +h3. Private instance + +Policy: users must be manually set up by the admin. + +Here is the configuration for this policy. This is also the default if not provided. +(However, be aware that developer/demo builds such as "arvbox":{{site.baseurl}}/install/arvbox.html are configured with the "Open instance" policy described below.) + +
    +Users:
    +  AutoSetupNewUsers: false
    +
    + +# User is created. Not set up. @is_active@ is false. +# Workbench checks @is_invited@ and finds it is false. User gets "inactive user" page. +# Admin goes to user page and clicks "setup user" or sets @is_active@ to true. +# On refreshing workbench, the user is able to self-activate after signing clickthrough agreements (if any). +# Alternately, directly setting @is_active@ to true also sets up the user, but skips clickthrough agreements (because the user is already active). + +h3(#federated). Federated instance + +Policy: users from other clusters in the federation are activated, users from outside the federation must be manually approved. + +Here is the configuration for this policy and an example remote cluster @clsr2@. + +
    +Users:
    +  AutoSetupNewUsers: false
    +RemoteClusters:
    +  clsr2:
    +    ActivateUsers: true
    +
    + +# Federated user arrives claiming to be from cluster 'clsr2' +# API server authenticates user as being from cluster 'clsr2' +# Because 'clsr2' has @ActivateUsers@ the user is set up and activated. +# User can immediately start using Workbench. + +h3. Open instance + +Policy: anybody who shows up and signs the agreements is activated. + +
    +Users:
    +  AutoSetupNewUsers: true
    +
    + +"Set up user agreements":#user_agreements by creating "signature" "require" links as described earlier. + +# User is created and auto-setup. At this point, @is_active@ is false, but user has been added to "All users" group. +# Workbench checks @is_invited@ and finds it is true, because the user is a member of "All users" group. +# Workbench presents user with list of user agreements, user reads and clicks "sign" for each one. +# Workbench tries to activate user. +# User is activated. diff --git a/doc/api/methods.html.textile.liquid b/doc/api/methods.html.textile.liquid index 0e01b3c6dd..175463c325 100644 --- a/doc/api/methods.html.textile.liquid +++ b/doc/api/methods.html.textile.liquid @@ -96,7 +96,7 @@ table(table table-bordered table-condensed). |1|operator|string|Comparison operator|@>@, @>=@, @like@, @not in@| |2|operand|string, array, or null|Value to compare with the resource attribute|@"d00220fb%"@, @"1234"@, @["foo","bar"]@, @nil@| -The following operators are available. +The following operators are available.[1] table(table table-bordered table-condensed). |_. Operator|_. Operand type|_. Description|_. Example| @@ -107,6 +107,7 @@ table(table table-bordered table-condensed). |@is_a@|string|Arvados object type|@["head_uuid","is_a","arvados#collection"]@| |@exists@|string|Test if a subproperty is present.|@["properties","exists","my_subproperty"]@| +Note: h4(#substringsearchfilter). Filtering using substring search @@ -168,3 +169,5 @@ table(table table-bordered table-condensed). |_. Argument |_. Type |_. Description |_. Location | {background:#ccffcc}.|uuid|string|The UUID of the resource in question.|path|| |{resource_type}|object||query|| + +fn1^. NOTE: The filter operator for full-text search (@@) which previously worked (but was undocumented) is deprecated and will be removed in a future release. diff --git a/doc/api/methods/container_requests.html.textile.liquid b/doc/api/methods/container_requests.html.textile.liquid index b9a21fc0a0..cd566f5ce4 100644 --- a/doc/api/methods/container_requests.html.textile.liquid +++ b/doc/api/methods/container_requests.html.textile.liquid @@ -141,11 +141,11 @@ table(table table-bordered table-condensed). h3. list -List container_requests. +List container requests. See "common resource list method.":{{site.baseurl}}/api/methods.html#index -See the create method documentation for more information about container request-specific filters. +The @filters@ argument can also filter on attributes of the container referenced by @container_uuid@. For example, @[["container.state", "=", "Running"]]@ will match any container request whose container is running now. h3. update diff --git a/doc/api/methods/containers.html.textile.liquid b/doc/api/methods/containers.html.textile.liquid index 5ec95cee62..8a7ebc36e5 100644 --- a/doc/api/methods/containers.html.textile.liquid +++ b/doc/api/methods/containers.html.textile.liquid @@ -136,8 +136,6 @@ List containers. See "common resource list method.":{{site.baseurl}}/api/methods.html#index -See the create method documentation for more information about Container-specific filters. - h3. update Update attributes of an existing Container. diff --git a/doc/api/methods/user_agreements.html.textile.liquid b/doc/api/methods/user_agreements.html.textile.liquid new file mode 100644 index 0000000000..9be5cb783e --- /dev/null +++ b/doc/api/methods/user_agreements.html.textile.liquid @@ -0,0 +1,44 @@ +--- +layout: default +navsection: api +navmenu: API Methods +title: "user_agreements" + +... +{% comment %} +Copyright (C) The Arvados Authors. All rights reserved. + +SPDX-License-Identifier: CC-BY-SA-3.0 +{% endcomment %} + +API endpoint base: @https://{{ site.arvados_api_host }}/arvados/v1/user_agreements@ + +h2. Resource + +This provides an API for inactive users to sign clickthrough agreements prior to being activated. + +h2. Methods + +Required arguments are displayed in %{background:#ccffcc}green%. + +h3. list + +List user agreements. This is a list of collections which contain HTML files with the text of the clickthrough agreement(s) which can be rendered by Workbench. + +table(table table-bordered table-condensed). +|_. Argument |_. Type |_. Description |_. Location |_. Example | + +h3. signatures + +List user agreements that have already been signed. These are recorded as link objects of @{"link_class": "signature", "name": "click"}@. + +table(table table-bordered table-condensed). +|_. Argument |_. Type |_. Description |_. Location |_. Example | + +h3. sign + +Sign a user agreement. + +table(table table-bordered table-condensed). +|_. Argument |_. Type |_. Description |_. Location |_. Example | +{background:#ccffcc}.|uuid|string|The UUID of the user agreement collection.|path|| diff --git a/doc/api/methods/users.html.textile.liquid b/doc/api/methods/users.html.textile.liquid index 098c2ca118..4c33f2afe8 100644 --- a/doc/api/methods/users.html.textile.liquid +++ b/doc/api/methods/users.html.textile.liquid @@ -110,7 +110,7 @@ Arguments: table(table table-bordered table-condensed). |_. Argument |_. Type |_. Description |_. Location |_. Example | {background:#ccffcc}.|uuid|string|The UUID of the User in question.|path|| -|user|object||query|| +|user|object|The new attributes.|query|| h3(#update_uuid). update_uuid @@ -124,3 +124,33 @@ table(table table-bordered table-condensed). |_. Argument |_. Type |_. Description |_. Location |_. Example | {background:#ccffcc}.|uuid|string|The current UUID of the user in question.|path|@zzzzz-tpzed-12345abcde12345@| {background:#ccffcc}.|new_uuid|string|The desired new UUID. It is an error to use a UUID belonging to an existing user.|query|@zzzzz-tpzed-abcde12345abcde@| + +h3. setup + +Set up a user. Adds the user to the "All users" group. Enables the user to invoke @activate@. See "user management":{{site.baseurl}}/admin/activation.html for details. + +Arguments: + +table(table table-bordered table-condensed). +|_. Argument |_. Type |_. Description |_. Location |_. Example | +{background:#ccffcc}.|uuid|string|The UUID of the User in question.|query|| + +h3. activate + +Check that a user has is set up and has signed all the user agreements. If so, activate the user. Users can invoke this for themselves. See "user agreements":{{site.baseurl}}/admin/activation.html#user_agreements for details. + +Arguments: + +table(table table-bordered table-condensed). +|_. Argument |_. Type |_. Description |_. Location |_. Example | +{background:#ccffcc}.|uuid|string|The UUID of the User in question.|query|| + +h3. unsetup + +Remove the user from the "All users" group and deactivate the user. See "user management":{{site.baseurl}}/admin/activation.html for details. + +Arguments: + +table(table table-bordered table-condensed). +|_. Argument |_. Type |_. Description |_. Location |_. Example | +{background:#ccffcc}.|uuid|string|The UUID of the User in question.|path|| diff --git a/doc/css/images.css b/doc/css/images.css index 73a1119f36..50c59f947e 100644 --- a/doc/css/images.css +++ b/doc/css/images.css @@ -13,3 +13,8 @@ img.screenshot { margin-left: 2em; margin-bottom: 2em; } + +img.side { + float: left; + width: 50%; +} diff --git a/doc/images/user-account-states.svg b/doc/images/user-account-states.svg new file mode 100644 index 0000000000..2fc965d97e --- /dev/null +++ b/doc/images/user-account-states.svg @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1. Created(inactive) + + + + + + + + 3. Set up(inactive) + + + + + + + + 5. Active + + + + + + + + + + + + + + + + + + + + + + + + + + + User logs via Google/LDAP etc + + + + + + + + + + + + + 2. setup + + + + + + 4. activate + + + + + + + + + + + + + 6. update “is_active” to “true” + + + + + + 7. unsetup + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/doc/install/cheat_sheet.html.textile.liquid b/doc/install/cheat_sheet.html.textile.liquid deleted file mode 100644 index 562b76ddf0..0000000000 --- a/doc/install/cheat_sheet.html.textile.liquid +++ /dev/null @@ -1,75 +0,0 @@ ---- -layout: default -navsection: admin -title: User management at the CLI -... -{% comment %} -Copyright (C) The Arvados Authors. All rights reserved. - -SPDX-License-Identifier: CC-BY-SA-3.0 -{% endcomment %} - -h3. Workbench: user management - -As an Admin user, use the gear icon on the top right to visit the Users page. From there, use the 'Add new user' button to create a new user. Alternatively, visit an existing user with the 'Show' button next to the user's name. Then use the 'Admin' tab and click the 'Setup' button to activate the user, and create a virtual machine login as well as git repository for them. - -h3. CLI setup - -
    -ARVADOS_API_HOST={{ site.arvados_api_host }}
    -ARVADOS_API_TOKEN=1234567890qwertyuiopasdfghjklzxcvbnm1234567890zzzz
    -
    - -h3. CLI: Create VM - -
    -arv virtual_machine create --virtual-machine '{"hostname":"xxxxxxxchangeme.example.com"}'
    -
    - -h3. CLI: Activate user - -
    -user_uuid=xxxxxxxchangeme
    -
    -arv user update --uuid "$user_uuid" --user '{"is_active":true}'
    -
    - -h3. User → VM - -Give @$user_uuid@ permission to log in to @$vm_uuid@ as @$target_username@ - -
    -user_uuid=xxxxxxxchangeme
    -vm_uuid=xxxxxxxchangeme
    -target_username=xxxxxxxchangeme
    -
    -read -rd $'\000' newlink <
    -
    -h3. CLI: User → repo
    -
    -Give @$user_uuid@ permission to commit to @$repo_uuid@ as @$repo_username@
    -
    -
    -user_uuid=xxxxxxxchangeme
    -repo_uuid=xxxxxxxchangeme
    -repo_username=xxxxxxxchangeme
    -
    -read -rd $'\000' newlink <
    diff --git a/doc/install/cheat_sheet.html.textile.liquid b/doc/install/cheat_sheet.html.textile.liquid
    new file mode 120000
    index 0000000000..7917e28b3f
    --- /dev/null
    +++ b/doc/install/cheat_sheet.html.textile.liquid
    @@ -0,0 +1 @@
    +../admin/user-management-cli.html.textile.liquid
    \ No newline at end of file
    diff --git a/lib/config/config.default.yml b/lib/config/config.default.yml
    index 81c36b9bfb..98e6bd3720 100644
    --- a/lib/config/config.default.yml
    +++ b/lib/config/config.default.yml
    @@ -275,6 +275,12 @@ Clusters:
           # in the directory where your API server is running.
           AnonymousUserToken: ""
     
    +      # If a new user has an alternate email address (local@domain)
    +      # with the domain given here, its local part becomes the new
    +      # user's default username. Otherwise, the user's primary email
    +      # address is used.
    +      PreferDomainForUsername: ""
    +
         AuditLogs:
           # Time to keep audit logs, in seconds. (An audit log is a row added
           # to the "logs" table in the PostgreSQL database each time an
    @@ -519,7 +525,7 @@ Clusters:
     
           # The cluster ID to delegate the user database.  When set,
           # logins on this cluster will be redirected to the login cluster
    -      # (login cluster must appear in RemoteHosts with Proxy: true)
    +      # (login cluster must appear in RemoteClusters with Proxy: true)
           LoginCluster: ""
     
           # How long a cached token belonging to a remote cluster will
    diff --git a/lib/config/export.go b/lib/config/export.go
    index 7adacab4c8..413ff9578c 100644
    --- a/lib/config/export.go
    +++ b/lib/config/export.go
    @@ -167,6 +167,7 @@ var whitelist = map[string]bool{
     	"Users.NewInactiveUserNotificationRecipients":  false,
     	"Users.NewUserNotificationRecipients":          false,
     	"Users.NewUsersAreActive":                      false,
    +	"Users.PreferDomainForUsername":                false,
     	"Users.UserNotifierEmailFrom":                  false,
     	"Users.UserProfileNotificationAddress":         false,
     	"Volumes":                                      true,
    diff --git a/lib/config/generated_config.go b/lib/config/generated_config.go
    index 68dea169f8..ece3a627fd 100644
    --- a/lib/config/generated_config.go
    +++ b/lib/config/generated_config.go
    @@ -281,6 +281,12 @@ Clusters:
           # in the directory where your API server is running.
           AnonymousUserToken: ""
     
    +      # If a new user has an alternate email address (local@domain)
    +      # with the domain given here, its local part becomes the new
    +      # user's default username. Otherwise, the user's primary email
    +      # address is used.
    +      PreferDomainForUsername: ""
    +
         AuditLogs:
           # Time to keep audit logs, in seconds. (An audit log is a row added
           # to the "logs" table in the PostgreSQL database each time an
    @@ -525,7 +531,7 @@ Clusters:
     
           # The cluster ID to delegate the user database.  When set,
           # logins on this cluster will be redirected to the login cluster
    -      # (login cluster must appear in RemoteHosts with Proxy: true)
    +      # (login cluster must appear in RemoteClusters with Proxy: true)
           LoginCluster: ""
     
           # How long a cached token belonging to a remote cluster will
    diff --git a/lib/controller/federation/conn.go b/lib/controller/federation/conn.go
    index 1d8fa7e462..887102f8e5 100644
    --- a/lib/controller/federation/conn.go
    +++ b/lib/controller/federation/conn.go
    @@ -198,10 +198,13 @@ func (conn *Conn) Login(ctx context.Context, options arvados.LoginOptions) (arva
     		if err != nil {
     			return arvados.LoginResponse{}, fmt.Errorf("internal error getting redirect target: %s", err)
     		}
    -		target.RawQuery = url.Values{
    +		params := url.Values{
     			"return_to": []string{options.ReturnTo},
    -			"remote":    []string{options.Remote},
    -		}.Encode()
    +		}
    +		if options.Remote != "" {
    +			params.Set("remote", options.Remote)
    +		}
    +		target.RawQuery = params.Encode()
     		return arvados.LoginResponse{
     			RedirectLocation: target.String(),
     		}, nil
    diff --git a/lib/controller/federation/generated.go b/lib/controller/federation/generated.go
    index 961cd5a401..0a66644985 100755
    --- a/lib/controller/federation/generated.go
    +++ b/lib/controller/federation/generated.go
    @@ -8,6 +8,7 @@ import (
     	"context"
     	"sort"
     	"sync"
    +	"sync/atomic"
     
     	"git.curoverse.com/arvados.git/sdk/go/arvados"
     )
    @@ -19,6 +20,8 @@ import (
     func (conn *Conn) generated_ContainerList(ctx context.Context, options arvados.ListOptions) (arvados.ContainerList, error) {
     	var mtx sync.Mutex
     	var merged arvados.ContainerList
    +	var needSort atomic.Value
    +	needSort.Store(false)
     	err := conn.splitListRequest(ctx, options, func(ctx context.Context, _ string, backend arvados.API, options arvados.ListOptions) ([]string, error) {
     		cl, err := backend.ContainerList(ctx, options)
     		if err != nil {
    @@ -28,8 +31,9 @@ func (conn *Conn) generated_ContainerList(ctx context.Context, options arvados.L
     		defer mtx.Unlock()
     		if len(merged.Items) == 0 {
     			merged = cl
    -		} else {
    +		} else if len(cl.Items) > 0 {
     			merged.Items = append(merged.Items, cl.Items...)
    +			needSort.Store(true)
     		}
     		uuids := make([]string, 0, len(cl.Items))
     		for _, item := range cl.Items {
    @@ -37,13 +41,27 @@ func (conn *Conn) generated_ContainerList(ctx context.Context, options arvados.L
     		}
     		return uuids, nil
     	})
    -	sort.Slice(merged.Items, func(i, j int) bool { return merged.Items[i].UUID < merged.Items[j].UUID })
    +	if needSort.Load().(bool) {
    +		// Apply the default/implied order, "modified_at desc"
    +		sort.Slice(merged.Items, func(i, j int) bool {
    +			mi, mj := merged.Items[i].ModifiedAt, merged.Items[j].ModifiedAt
    +			return mj.Before(mi)
    +		})
    +	}
    +	if merged.Items == nil {
    +		// Return empty results as [], not null
    +		// (https://github.com/golang/go/issues/27589 might be
    +		// a better solution in the future)
    +		merged.Items = []arvados.Container{}
    +	}
     	return merged, err
     }
     
     func (conn *Conn) generated_SpecimenList(ctx context.Context, options arvados.ListOptions) (arvados.SpecimenList, error) {
     	var mtx sync.Mutex
     	var merged arvados.SpecimenList
    +	var needSort atomic.Value
    +	needSort.Store(false)
     	err := conn.splitListRequest(ctx, options, func(ctx context.Context, _ string, backend arvados.API, options arvados.ListOptions) ([]string, error) {
     		cl, err := backend.SpecimenList(ctx, options)
     		if err != nil {
    @@ -53,8 +71,9 @@ func (conn *Conn) generated_SpecimenList(ctx context.Context, options arvados.Li
     		defer mtx.Unlock()
     		if len(merged.Items) == 0 {
     			merged = cl
    -		} else {
    +		} else if len(cl.Items) > 0 {
     			merged.Items = append(merged.Items, cl.Items...)
    +			needSort.Store(true)
     		}
     		uuids := make([]string, 0, len(cl.Items))
     		for _, item := range cl.Items {
    @@ -62,7 +81,19 @@ func (conn *Conn) generated_SpecimenList(ctx context.Context, options arvados.Li
     		}
     		return uuids, nil
     	})
    -	sort.Slice(merged.Items, func(i, j int) bool { return merged.Items[i].UUID < merged.Items[j].UUID })
    +	if needSort.Load().(bool) {
    +		// Apply the default/implied order, "modified_at desc"
    +		sort.Slice(merged.Items, func(i, j int) bool {
    +			mi, mj := merged.Items[i].ModifiedAt, merged.Items[j].ModifiedAt
    +			return mj.Before(mi)
    +		})
    +	}
    +	if merged.Items == nil {
    +		// Return empty results as [], not null
    +		// (https://github.com/golang/go/issues/27589 might be
    +		// a better solution in the future)
    +		merged.Items = []arvados.Specimen{}
    +	}
     	return merged, err
     }
     
    diff --git a/lib/controller/federation/list.go b/lib/controller/federation/list.go
    index 7178d7b0af..26b6b254e8 100644
    --- a/lib/controller/federation/list.go
    +++ b/lib/controller/federation/list.go
    @@ -10,6 +10,7 @@ import (
     	"net/http"
     	"sort"
     	"sync"
    +	"sync/atomic"
     
     	"git.curoverse.com/arvados.git/sdk/go/arvados"
     	"git.curoverse.com/arvados.git/sdk/go/httpserver"
    @@ -23,6 +24,8 @@ import (
     func (conn *Conn) generated_CollectionList(ctx context.Context, options arvados.ListOptions) (arvados.CollectionList, error) {
     	var mtx sync.Mutex
     	var merged arvados.CollectionList
    +	var needSort atomic.Value
    +	needSort.Store(false)
     	err := conn.splitListRequest(ctx, options, func(ctx context.Context, _ string, backend arvados.API, options arvados.ListOptions) ([]string, error) {
     		cl, err := backend.CollectionList(ctx, options)
     		if err != nil {
    @@ -32,8 +35,9 @@ func (conn *Conn) generated_CollectionList(ctx context.Context, options arvados.
     		defer mtx.Unlock()
     		if len(merged.Items) == 0 {
     			merged = cl
    -		} else {
    +		} else if len(cl.Items) > 0 {
     			merged.Items = append(merged.Items, cl.Items...)
    +			needSort.Store(true)
     		}
     		uuids := make([]string, 0, len(cl.Items))
     		for _, item := range cl.Items {
    @@ -41,7 +45,19 @@ func (conn *Conn) generated_CollectionList(ctx context.Context, options arvados.
     		}
     		return uuids, nil
     	})
    -	sort.Slice(merged.Items, func(i, j int) bool { return merged.Items[i].UUID < merged.Items[j].UUID })
    +	if needSort.Load().(bool) {
    +		// Apply the default/implied order, "modified_at desc"
    +		sort.Slice(merged.Items, func(i, j int) bool {
    +			mi, mj := merged.Items[i].ModifiedAt, merged.Items[j].ModifiedAt
    +			return mj.Before(mi)
    +		})
    +	}
    +	if merged.Items == nil {
    +		// Return empty results as [], not null
    +		// (https://github.com/golang/go/issues/27589 might be
    +		// a better solution in the future)
    +		merged.Items = []arvados.Collection{}
    +	}
     	return merged, err
     }
     
    diff --git a/lib/controller/federation/list_test.go b/lib/controller/federation/list_test.go
    index 5a630a9450..a9c4f588f1 100644
    --- a/lib/controller/federation/list_test.go
    +++ b/lib/controller/federation/list_test.go
    @@ -8,6 +8,7 @@ import (
     	"context"
     	"fmt"
     	"net/http"
    +	"sort"
     
     	"git.curoverse.com/arvados.git/sdk/go/arvados"
     	"git.curoverse.com/arvados.git/sdk/go/arvadostest"
    @@ -365,10 +366,13 @@ func (s *CollectionListSuite) test(c *check.C, trial listTrial) {
     		c.Logf("returned error string is %q", err)
     	} else {
     		c.Check(err, check.IsNil)
    -		var expectItems []arvados.Collection
    +		expectItems := []arvados.Collection{}
     		for _, uuid := range trial.expectUUIDs {
     			expectItems = append(expectItems, arvados.Collection{UUID: uuid})
     		}
    +		// expectItems is sorted by UUID, so sort resp.Items
    +		// by UUID before checking DeepEquals.
    +		sort.Slice(resp.Items, func(i, j int) bool { return resp.Items[i].UUID < resp.Items[j].UUID })
     		c.Check(resp, check.DeepEquals, arvados.CollectionList{
     			Items: expectItems,
     		})
    diff --git a/lib/controller/federation/login_test.go b/lib/controller/federation/login_test.go
    index f83f5fb935..8ec2bd5a49 100644
    --- a/lib/controller/federation/login_test.go
    +++ b/lib/controller/federation/login_test.go
    @@ -32,7 +32,9 @@ func (s *LoginSuite) TestDeferToLoginCluster(c *check.C) {
     		c.Check(err, check.IsNil)
     		c.Check(target.Host, check.Equals, s.cluster.RemoteClusters["zhome"].Host)
     		c.Check(target.Scheme, check.Equals, "http")
    -		c.Check(target.Query().Get("remote"), check.Equals, remote)
     		c.Check(target.Query().Get("return_to"), check.Equals, returnTo)
    +		c.Check(target.Query().Get("remote"), check.Equals, remote)
    +		_, remotePresent := target.Query()["remote"]
    +		c.Check(remotePresent, check.Equals, remote != "")
     	}
     }
    diff --git a/lib/controller/localdb/login.go b/lib/controller/localdb/login.go
    index 13ae366eb4..dc634e8d8f 100644
    --- a/lib/controller/localdb/login.go
    +++ b/lib/controller/localdb/login.go
    @@ -207,6 +207,9 @@ func (ctrl *googleLoginController) getAuthInfo(ctx context.Context, cluster *arv
     	for ae := range altEmails {
     		if ae != ret.Email {
     			ret.AlternateEmails = append(ret.AlternateEmails, ae)
    +			if i := strings.Index(ae, "@"); i > 0 && strings.ToLower(ae[i+1:]) == strings.ToLower(cluster.Users.PreferDomainForUsername) {
    +				ret.Username = strings.SplitN(ae[:i], "+", 2)[0]
    +			}
     		}
     	}
     	return &ret, nil
    diff --git a/lib/controller/localdb/login_test.go b/lib/controller/localdb/login_test.go
    index c5b9ee0683..d409a21a99 100644
    --- a/lib/controller/localdb/login_test.go
    +++ b/lib/controller/localdb/login_test.go
    @@ -148,6 +148,7 @@ func (s *LoginSuite) SetUpTest(c *check.C) {
     	s.cluster, err = cfg.GetCluster("")
     	s.cluster.Login.GoogleClientID = "test%client$id"
     	s.cluster.Login.GoogleClientSecret = "test#client/secret"
    +	s.cluster.Users.PreferDomainForUsername = "PreferDomainForUsername.example.com"
     	c.Assert(err, check.IsNil)
     
     	s.localdb = NewConn(s.cluster)
    @@ -364,6 +365,10 @@ func (s *LoginSuite) TestGoogleLogin_AlternateEmailAddresses_Primary(c *check.C)
     				"metadata": map[string]interface{}{"verified": true},
     				"value":    "joe.smith@alternate.example.com",
     			},
    +			{
    +				"metadata": map[string]interface{}{"verified": true},
    +				"value":    "jsmith+123@preferdomainforusername.example.com",
    +			},
     		},
     	}
     	state := s.startLogin(c)
    @@ -373,7 +378,8 @@ func (s *LoginSuite) TestGoogleLogin_AlternateEmailAddresses_Primary(c *check.C)
     	})
     	authinfo := s.getCallbackAuthInfo(c)
     	c.Check(authinfo.Email, check.Equals, "joe.smith@primary.example.com")
    -	c.Check(authinfo.AlternateEmails, check.DeepEquals, []string{"joe.smith@alternate.example.com"})
    +	c.Check(authinfo.AlternateEmails, check.DeepEquals, []string{"joe.smith@alternate.example.com", "jsmith+123@preferdomainforusername.example.com"})
    +	c.Check(authinfo.Username, check.Equals, "jsmith")
     }
     
     func (s *LoginSuite) TestGoogleLogin_NoPrimaryEmailAddress(c *check.C) {
    @@ -400,6 +406,7 @@ func (s *LoginSuite) TestGoogleLogin_NoPrimaryEmailAddress(c *check.C) {
     	authinfo := s.getCallbackAuthInfo(c)
     	c.Check(authinfo.Email, check.Equals, "joe.smith@work.example.com") // first verified email in People response
     	c.Check(authinfo.AlternateEmails, check.DeepEquals, []string{"joe.smith@home.example.com"})
    +	c.Check(authinfo.Username, check.Equals, "")
     }
     
     func (s *LoginSuite) getCallbackAuthInfo(c *check.C) (authinfo rpc.UserSessionAuthInfo) {
    diff --git a/lib/controller/router/router_test.go b/lib/controller/router/router_test.go
    index 6a9fd311ba..b1bc9bce32 100644
    --- a/lib/controller/router/router_test.go
    +++ b/lib/controller/router/router_test.go
    @@ -225,6 +225,13 @@ func (s *RouterIntegrationSuite) TestContainerList(c *check.C) {
     	c.Check(rr.Code, check.Equals, http.StatusOK)
     	c.Check(jresp["items_available"], check.FitsTypeOf, float64(0))
     	c.Check(jresp["items_available"].(float64) > 2, check.Equals, true)
    +	c.Check(jresp["items"], check.NotNil)
    +	c.Check(jresp["items"], check.HasLen, 0)
    +
    +	_, rr, jresp = doRequest(c, s.rtr, token, "GET", `/arvados/v1/containers?filters=[["uuid","in",[]]]`, nil, nil)
    +	c.Check(rr.Code, check.Equals, http.StatusOK)
    +	c.Check(jresp["items_available"], check.Equals, float64(0))
    +	c.Check(jresp["items"], check.NotNil)
     	c.Check(jresp["items"], check.HasLen, 0)
     
     	_, rr, jresp = doRequest(c, s.rtr, token, "GET", `/arvados/v1/containers?limit=2&select=["uuid","command"]`, nil, nil)
    diff --git a/lib/controller/rpc/conn.go b/lib/controller/rpc/conn.go
    index 66523e5ac3..f4bc1733ea 100644
    --- a/lib/controller/rpc/conn.go
    +++ b/lib/controller/rpc/conn.go
    @@ -393,6 +393,7 @@ type UserSessionAuthInfo struct {
     	AlternateEmails []string `json:"alternate_emails"`
     	FirstName       string   `json:"first_name"`
     	LastName        string   `json:"last_name"`
    +	Username        string   `json:"username"`
     }
     
     type UserSessionCreateOptions struct {
    diff --git a/lib/dispatchcloud/container/queue.go b/lib/dispatchcloud/container/queue.go
    index 50e73189ef..f999ee80ad 100644
    --- a/lib/dispatchcloud/container/queue.go
    +++ b/lib/dispatchcloud/container/queue.go
    @@ -133,7 +133,7 @@ func (cq *Queue) Forget(uuid string) {
     	cq.mtx.Lock()
     	defer cq.mtx.Unlock()
     	ctr := cq.current[uuid].Container
    -	if ctr.State == arvados.ContainerStateComplete || ctr.State == arvados.ContainerStateCancelled {
    +	if ctr.State == arvados.ContainerStateComplete || ctr.State == arvados.ContainerStateCancelled || (ctr.State == arvados.ContainerStateQueued && ctr.Priority == 0) {
     		cq.delEnt(uuid, ctr.State)
     	}
     }
    diff --git a/lib/dispatchcloud/scheduler/sync.go b/lib/dispatchcloud/scheduler/sync.go
    index 205ee50187..e306db00ce 100644
    --- a/lib/dispatchcloud/scheduler/sync.go
    +++ b/lib/dispatchcloud/scheduler/sync.go
    @@ -51,7 +51,7 @@ func (sch *Scheduler) sync() {
     				sch.logger.WithFields(logrus.Fields{
     					"ContainerUUID": uuid,
     					"State":         ent.Container.State,
    -				}).Info("container finished")
    +				}).Info("container finished -- dropping from queue")
     				sch.queue.Forget(uuid)
     			}
     		case arvados.ContainerStateQueued:
    @@ -66,7 +66,7 @@ func (sch *Scheduler) sync() {
     					"ContainerUUID": uuid,
     					"State":         ent.Container.State,
     					"Priority":      ent.Container.Priority,
    -				}).Info("container on hold")
    +				}).Info("container on hold -- dropping from queue")
     				sch.queue.Forget(uuid)
     			}
     		case arvados.ContainerStateLocked:
    diff --git a/sdk/cwl/tests/federation/arvbox-make-federation.cwl b/sdk/cwl/tests/federation/arvbox-make-federation.cwl
    index 81b542057c..5872dbef5a 100644
    --- a/sdk/cwl/tests/federation/arvbox-make-federation.cwl
    +++ b/sdk/cwl/tests/federation/arvbox-make-federation.cwl
    @@ -27,6 +27,12 @@ inputs:
         default:
           class: File
           location: ../../../../tools/arvbox/bin/arvbox
    +  branch:
    +    type: string
    +    default: master
    +  logincluster:
    +    type: boolean
    +    default: false
     outputs:
       arvados_api_token:
         type: string
    @@ -64,6 +70,7 @@ steps:
           container_name: containers
           arvbox_data: mkdir/arvbox_data
           arvbox_bin: arvbox
    +      branch: branch
         out: [cluster_id, container_host, arvbox_data_out, superuser_token]
         scatter: [container_name, arvbox_data]
         scatterMethod: dotproduct
    @@ -76,6 +83,7 @@ steps:
           cluster_hosts: start/container_host
           arvbox_data: start/arvbox_data_out
           arvbox_bin: arvbox
    +      logincluster: logincluster
         out: []
         scatter: [container_name, this_cluster_id, arvbox_data]
         scatterMethod: dotproduct
    diff --git a/sdk/cwl/tests/federation/arvbox/fed-config.cwl b/sdk/cwl/tests/federation/arvbox/fed-config.cwl
    index 76523a56be..37936df635 100644
    --- a/sdk/cwl/tests/federation/arvbox/fed-config.cwl
    +++ b/sdk/cwl/tests/federation/arvbox/fed-config.cwl
    @@ -14,6 +14,9 @@ inputs:
       cluster_hosts: string[]
       arvbox_data: Directory
       arvbox_bin: File
    +  logincluster:
    +    type: boolean
    +    default: false
     outputs:
       arvbox_data_out:
         type: Directory
    @@ -39,6 +42,9 @@ requirements:
               }
               var r = {"Clusters": {}};
               r["Clusters"][inputs.this_cluster_id] = {"RemoteClusters": remoteClusters};
    +          if (r["Clusters"][inputs.this_cluster_id]) {
    +            r["Clusters"][inputs.this_cluster_id]["Login"] = {"LoginCluster": inputs.cluster_ids[0]};
    +          }
               return JSON.stringify(r);
               }
           - entryname: application.yml.override
    diff --git a/sdk/cwl/tests/federation/arvbox/start.cwl b/sdk/cwl/tests/federation/arvbox/start.cwl
    index a0b3e1864b..d26a6b28ec 100644
    --- a/sdk/cwl/tests/federation/arvbox/start.cwl
    +++ b/sdk/cwl/tests/federation/arvbox/start.cwl
    @@ -11,6 +11,9 @@ inputs:
       container_name: string
       arvbox_data: Directory
       arvbox_bin: File
    +  branch:
    +    type: string
    +    default: master
     outputs:
       cluster_id:
         type: string
    @@ -68,6 +71,24 @@ arguments:
       - shellQuote: false
         valueFrom: |
           set -ex
    -      $(inputs.arvbox_bin.path) start dev
    +      mkdir -p $ARVBOX_DATA
    +      if ! test -d $ARVBOX_DATA/arvados ; then
    +        cd $ARVBOX_DATA
    +        git clone https://github.com/curoverse/arvados.git
    +      fi
    +      cd $ARVBOX_DATA/arvados
    +      gitver=`git rev-parse HEAD`
    +      git fetch
    +      git checkout -f $(inputs.branch)
    +      git pull
    +      pulled=`git rev-parse HEAD`
    +      git --no-pager log -n1 $pulled
    +
    +      cd $(runtime.outdir)
    +      if test "$gitver" = "$pulled" ; then
    +        $(inputs.arvbox_bin.path) start dev
    +      else
    +        $(inputs.arvbox_bin.path) restart dev
    +      fi
           $(inputs.arvbox_bin.path) status > status.txt
           $(inputs.arvbox_bin.path) cat /var/lib/arvados/superuser_token > superuser_token.txt
    diff --git a/sdk/go/arvados/collection.go b/sdk/go/arvados/collection.go
    index 5b919bea74..e8b0f9cc98 100644
    --- a/sdk/go/arvados/collection.go
    +++ b/sdk/go/arvados/collection.go
    @@ -22,15 +22,17 @@ type Collection struct {
     	ManifestText              string                 `json:"manifest_text"`
     	UnsignedManifestText      string                 `json:"unsigned_manifest_text"`
     	Name                      string                 `json:"name"`
    -	CreatedAt                 *time.Time             `json:"created_at"`
    -	ModifiedAt                *time.Time             `json:"modified_at"`
    +	CreatedAt                 time.Time              `json:"created_at"`
    +	ModifiedAt                time.Time              `json:"modified_at"`
    +	ModifiedByClientUUID      string                 `json:"modified_by_client_uuid"`
    +	ModifiedByUserUUID        string                 `json:"modified_by_user_uuid"`
     	PortableDataHash          string                 `json:"portable_data_hash"`
     	ReplicationConfirmed      *int                   `json:"replication_confirmed"`
     	ReplicationConfirmedAt    *time.Time             `json:"replication_confirmed_at"`
     	ReplicationDesired        *int                   `json:"replication_desired"`
     	StorageClassesDesired     []string               `json:"storage_classes_desired"`
     	StorageClassesConfirmed   []string               `json:"storage_classes_confirmed"`
    -	StorageClassesConfirmedAt *time.Time             `json:"storage_classes_confirmed_at"`
    +	StorageClassesConfirmedAt time.Time              `json:"storage_classes_confirmed_at"`
     	DeleteAt                  *time.Time             `json:"delete_at"`
     	IsTrashed                 bool                   `json:"is_trashed"`
     	Properties                map[string]interface{} `json:"properties"`
    diff --git a/sdk/go/arvados/config.go b/sdk/go/arvados/config.go
    index 805efb7db2..72128a9dcd 100644
    --- a/sdk/go/arvados/config.go
    +++ b/sdk/go/arvados/config.go
    @@ -174,6 +174,7 @@ type Cluster struct {
     		NewUsersAreActive                     bool
     		UserNotifierEmailFrom                 string
     		UserProfileNotificationAddress        string
    +		PreferDomainForUsername               string
     	}
     	Volumes   map[string]Volume
     	Workbench struct {
    diff --git a/sdk/go/arvados/container.go b/sdk/go/arvados/container.go
    index fb095481bb..1d3b0962f7 100644
    --- a/sdk/go/arvados/container.go
    +++ b/sdk/go/arvados/container.go
    @@ -10,6 +10,9 @@ import "time"
     type Container struct {
     	UUID                 string                 `json:"uuid"`
     	CreatedAt            time.Time              `json:"created_at"`
    +	ModifiedByClientUUID string                 `json:"modified_by_client_uuid"`
    +	ModifiedByUserUUID   string                 `json:"modified_by_user_uuid"`
    +	ModifiedAt           time.Time              `json:"modified_at"`
     	Command              []string               `json:"command"`
     	ContainerImage       string                 `json:"container_image"`
     	Cwd                  string                 `json:"cwd"`
    diff --git a/sdk/go/arvados/fs_collection.go b/sdk/go/arvados/fs_collection.go
    index b3e6aa96e4..40c8908024 100644
    --- a/sdk/go/arvados/fs_collection.go
    +++ b/sdk/go/arvados/fs_collection.go
    @@ -49,11 +49,9 @@ type collectionFileSystem struct {
     
     // FileSystem returns a CollectionFileSystem for the collection.
     func (c *Collection) FileSystem(client apiClient, kc keepClient) (CollectionFileSystem, error) {
    -	var modTime time.Time
    -	if c.ModifiedAt == nil {
    +	modTime := c.ModifiedAt
    +	if modTime.IsZero() {
     		modTime = time.Now()
    -	} else {
    -		modTime = *c.ModifiedAt
     	}
     	fs := &collectionFileSystem{
     		uuid: c.UUID,
    diff --git a/sdk/go/arvados/fs_deferred.go b/sdk/go/arvados/fs_deferred.go
    index a84f64fe7e..439eaec7c2 100644
    --- a/sdk/go/arvados/fs_deferred.go
    +++ b/sdk/go/arvados/fs_deferred.go
    @@ -12,10 +12,8 @@ import (
     )
     
     func deferredCollectionFS(fs FileSystem, parent inode, coll Collection) inode {
    -	var modTime time.Time
    -	if coll.ModifiedAt != nil {
    -		modTime = *coll.ModifiedAt
    -	} else {
    +	modTime := coll.ModifiedAt
    +	if modTime.IsZero() {
     		modTime = time.Now()
     	}
     	placeholder := &treenode{
    diff --git a/sdk/go/arvados/specimen.go b/sdk/go/arvados/specimen.go
    index e320ca2c33..b561fb20ae 100644
    --- a/sdk/go/arvados/specimen.go
    +++ b/sdk/go/arvados/specimen.go
    @@ -7,12 +7,13 @@ package arvados
     import "time"
     
     type Specimen struct {
    -	UUID       string                 `json:"uuid"`
    -	OwnerUUID  string                 `json:"owner_uuid"`
    -	CreatedAt  time.Time              `json:"created_at"`
    -	ModifiedAt time.Time              `json:"modified_at"`
    -	UpdatedAt  time.Time              `json:"updated_at"`
    -	Properties map[string]interface{} `json:"properties"`
    +	UUID                 string                 `json:"uuid"`
    +	OwnerUUID            string                 `json:"owner_uuid"`
    +	CreatedAt            time.Time              `json:"created_at"`
    +	ModifiedAt           time.Time              `json:"modified_at"`
    +	ModifiedByClientUUID string                 `json:"modified_by_client_uuid"`
    +	ModifiedByUserUUID   string                 `json:"modified_by_user_uuid"`
    +	Properties           map[string]interface{} `json:"properties"`
     }
     
     type SpecimenList struct {
    diff --git a/sdk/python/arvados/commands/federation_migrate.py b/sdk/python/arvados/commands/federation_migrate.py
    index 885d6fda03..e74d6215c7 100755
    --- a/sdk/python/arvados/commands/federation_migrate.py
    +++ b/sdk/python/arvados/commands/federation_migrate.py
    @@ -197,14 +197,17 @@ def choose_new_user(args, by_email, email, userhome, username, old_user_uuid, cl
                 return None
             print("(%s) No user listed with same email to migrate %s to %s, will create new user with username '%s'" % (email, old_user_uuid, userhome, username))
             if not args.dry_run:
    +            oldhomecluster = old_user_uuid[0:5]
    +            oldhomearv = clusters[oldhomecluster]
                 newhomecluster = userhome[0:5]
                 homearv = clusters[userhome]
                 user = None
                 try:
    +                olduser = oldhomearv.users().get(uuid=old_user_uuid).execute()
                     conflicts = homearv.users().list(filters=[["username", "=", username]]).execute()
                     if conflicts["items"]:
                         homearv.users().update(uuid=conflicts["items"][0]["uuid"], body={"user": {"username": username+"migrate"}}).execute()
    -                user = homearv.users().create(body={"user": {"email": email, "username": username}}).execute()
    +                user = homearv.users().create(body={"user": {"email": email, "username": username, "is_active": olduser["is_active"]}}).execute()
                 except arvados.errors.ApiError as e:
                     print("(%s) Could not create user: %s" % (email, str(e)))
                     return None
    diff --git a/sdk/python/tests/fed-migrate/CWLFile b/sdk/python/tests/fed-migrate/CWLFile
    new file mode 100644
    index 0000000000..18b2ed8779
    --- /dev/null
    +++ b/sdk/python/tests/fed-migrate/CWLFile
    @@ -0,0 +1,28 @@
    +cwlVersion: v1.0
    +class: Workflow
    +requirements:
    +  ScatterFeatureRequirement: {}
    +inputs:
    +  exfiles:
    +    type: string[]
    +    default:
    +      - fed-migrate.cwlex
    +      - run-test.cwlex
    +  dir:
    +    type: Directory
    +    default:
    +      class: Directory
    +      location: .
    +outputs:
    +  out:
    +    type: File[]
    +    outputSource: step1/converted
    +
    +steps:
    +  step1:
    +    in:
    +      inpdir: dir
    +      inpfile: exfiles
    +    out: [converted]
    +    scatter: inpfile
    +    run: cwlex.cwl
    diff --git a/sdk/python/tests/fed-migrate/arvbox-make-federation.cwl b/sdk/python/tests/fed-migrate/arvbox-make-federation.cwl
    index 5057d4cb18..0aa6f177aa 100644
    --- a/sdk/python/tests/fed-migrate/arvbox-make-federation.cwl
    +++ b/sdk/python/tests/fed-migrate/arvbox-make-federation.cwl
    @@ -3,8 +3,12 @@ class: Workflow
     $namespaces:
       arv: "http://arvados.org/cwl#"
       cwltool: "http://commonwl.org/cwltool#"
    +
     inputs:
       arvbox_base: Directory
    +  branch:
    +    type: string
    +    default: master
     outputs:
       arvados_api_hosts:
         type: string[]
    @@ -21,13 +25,21 @@ outputs:
       arvbox_bin:
         type: File
         outputSource: start/arvbox_bin
    +  refspec:
    +    type: string
    +    outputSource: branch
     requirements:
       SubworkflowFeatureRequirement: {}
    +  ScatterFeatureRequirement: {}
    +  StepInputExpressionRequirement: {}
       cwltool:LoadListingRequirement:
         loadListing: no_listing
     steps:
       start:
         in:
           arvbox_base: arvbox_base
    +      branch: branch
    +      logincluster:
    +        default: true
         out: [arvados_api_hosts, arvados_cluster_ids, arvado_api_host_insecure, superuser_tokens, arvbox_containers, arvbox_bin]
         run: ../../../cwl/tests/federation/arvbox-make-federation.cwl
    diff --git a/sdk/python/tests/fed-migrate/check.py b/sdk/python/tests/fed-migrate/check.py
    index 8f494be2fb..85d2d31f23 100644
    --- a/sdk/python/tests/fed-migrate/check.py
    +++ b/sdk/python/tests/fed-migrate/check.py
    @@ -8,6 +8,10 @@ apiA = arvados.api(host=j["arvados_api_hosts"][0], token=j["superuser_tokens"][0
     apiB = arvados.api(host=j["arvados_api_hosts"][1], token=j["superuser_tokens"][1], insecure=True)
     apiC = arvados.api(host=j["arvados_api_hosts"][2], token=j["superuser_tokens"][2], insecure=True)
     
    +###
    +### Check users on API server "A" (the LoginCluster) ###
    +###
    +
     users = apiA.users().list().execute()
     
     assert len(users["items"]) == 11
    @@ -22,6 +26,15 @@ for i in range(1, 10):
                 by_username[u["username"]] = u["uuid"]
         assert found
     
    +# Should be active
    +for i in (1, 2, 3, 4, 5, 6, 7, 8):
    +    found = False
    +    for u in users["items"]:
    +        if u["username"] == ("case%d" % i) and u["email"] == ("case%d@test" % i) and u["is_active"] is True:
    +            found = True
    +    assert found, "Not found case%i" % i
    +
    +# case9 should not be active
     found = False
     for u in users["items"]:
         if (u["username"] == "case9" and u["email"] == "case9@test" and
    @@ -29,23 +42,40 @@ for u in users["items"]:
             found = True
     assert found
     
    +
    +###
    +### Check users on API server "B" (federation member) ###
    +###
     users = apiB.users().list().execute()
     assert len(users["items"]) == 11
     
    -for i in range(2, 10):
    +for i in range(2, 9):
         found = False
         for u in users["items"]:
    -        if u["username"] == ("case%d" % i) and u["email"] == ("case%d@test" % i) and u["uuid"] == by_username[u["username"]]:
    +        if (u["username"] == ("case%d" % i) and u["email"] == ("case%d@test" % i) and
    +            u["uuid"] == by_username[u["username"]] and u["is_active"] is True):
                 found = True
    -    assert found
    +    assert found, "Not found case%i" % i
    +
    +found = False
    +for u in users["items"]:
    +    if (u["username"] == "case9" and u["email"] == "case9@test" and
    +        u["uuid"] == by_username[u["username"]] and u["is_active"] is False):
    +        found = True
    +assert found
    +
     
    +###
    +### Check users on API server "C" (federation member) ###
    +###
     users = apiC.users().list().execute()
     assert len(users["items"]) == 8
     
     for i in (2, 4, 6, 7, 8):
         found = False
         for u in users["items"]:
    -        if u["username"] == ("case%d" % i) and u["email"] == ("case%d@test" % i) and u["uuid"] == by_username[u["username"]]:
    +        if (u["username"] == ("case%d" % i) and u["email"] == ("case%d@test" % i) and
    +            u["uuid"] == by_username[u["username"]] and u["is_active"] is True):
                 found = True
         assert found
     
    @@ -54,7 +84,8 @@ for i in (2, 4, 6, 7, 8):
     for i in (3, 5, 9):
         found = False
         for u in users["items"]:
    -        if u["username"] == ("case%d" % i) and u["email"] == ("case%d@test" % i) and u["uuid"] == by_username[u["username"]]:
    +        if (u["username"] == ("case%d" % i) and u["email"] == ("case%d@test" % i) and
    +            u["uuid"] == by_username[u["username"]] and u["is_active"] is True):
                 found = True
         assert not found
     
    diff --git a/sdk/python/tests/fed-migrate/cwlex.cwl b/sdk/python/tests/fed-migrate/cwlex.cwl
    new file mode 100644
    index 0000000000..1e72fedb5a
    --- /dev/null
    +++ b/sdk/python/tests/fed-migrate/cwlex.cwl
    @@ -0,0 +1,41 @@
    +#!/usr/bin/env cwl-runner
    +arguments:
    +  - cwlex
    +  - '$(inputs.inp ? inputs.inp.path : inputs.inpdir.path+''/''+inputs.inpfile)'
    +class: CommandLineTool
    +cwlVersion: v1.0
    +id: '#main'
    +inputs:
    +  - id: inp
    +    type:
    +      - 'null'
    +      - File
    +  - id: inpdir
    +    type:
    +      - 'null'
    +      - Directory
    +  - id: inpfile
    +    type:
    +      - 'null'
    +      - string
    +  - id: outname
    +    type:
    +      - 'null'
    +      - string
    +outputs:
    +  - id: converted
    +    outputBinding:
    +      glob: $(outname(inputs))
    +    type: File
    +requirements:
    +  - class: DockerRequirement
    +    dockerPull: commonworkflowlanguage/cwlex
    +  - class: InlineJavascriptRequirement
    +    expressionLib:
    +      - |
    +
    +        function outname(inputs) {
    +          return inputs.outname ? inputs.outname : (inputs.inp ? inputs.inp.nameroot+'.cwl' : inputs.inpfile.replace(/(.*).cwlex/, '$1.cwl'));
    +        }
    +stdout: $(outname(inputs))
    +
    diff --git a/sdk/python/tests/fed-migrate/fed-migrate.cwl b/sdk/python/tests/fed-migrate/fed-migrate.cwl
    index 1c8fcca59e..19c2b58ef7 100644
    --- a/sdk/python/tests/fed-migrate/fed-migrate.cwl
    +++ b/sdk/python/tests/fed-migrate/fed-migrate.cwl
    @@ -337,7 +337,7 @@ $graph:
             type: string
           - id: arvbox_bin
             type: File
    -      - default: 15531-logincluster-migrate
    +      - default: master
             id: refspec
             type: string
         outputs:
    @@ -407,73 +407,15 @@ $graph:
                   type: string
               outputs:
                 - id: supertok
    -              outputSource: superuser_tok_3/superuser_token
    +              outputSource: superuser_tok_2/superuser_token
                   type: string
               requirements:
    -            - class: EnvVarRequirement
    -              envDef:
    -                ARVBOX_CONTAINER: $(inputs.container)
    +            InlineJavascriptRequirement: {}
               steps:
                 - id: main_2_embed_1
    -              in:
    -                cluster_id:
    -                  source: cluster_id
    -                container:
    -                  source: container
    -                logincluster:
    -                  source: logincluster
    -                set_login:
    -                  default:
    -                    class: File
    -                    location: set_login.py
    -              out:
    -                - c
    -              run:
    -                arguments:
    -                  - sh
    -                  - _script
    -                class: CommandLineTool
    -                id: main_2_embed_1_embed
    -                inputs:
    -                  - id: container
    -                    type: string
    -                  - id: cluster_id
    -                    type: string
    -                  - id: logincluster
    -                    type: string
    -                  - id: set_login
    -                    type: File
    -                outputs:
    -                  - id: c
    -                    outputBinding:
    -                      outputEval: $(inputs.container)
    -                    type: string
    -                requirements:
    -                  InitialWorkDirRequirement:
    -                    listing:
    -                      - entry: >
    -                          set -x
    -
    -                          docker cp
    -                          $(inputs.container):/var/lib/arvados/cluster_config.yml.override
    -                          .
    -
    -                          chmod +w cluster_config.yml.override
    -
    -                          python $(inputs.set_login.path)
    -                          cluster_config.yml.override $(inputs.cluster_id)
    -                          $(inputs.logincluster)
    -
    -                          docker cp cluster_config.yml.override
    -                          $(inputs.container):/var/lib/arvados
    -                        entryname: _script
    -                  InlineJavascriptRequirement: {}
    -            - id: main_2_embed_2
                   in:
                     arvbox_bin:
                       source: arvbox_bin
    -                c:
    -                  source: main_2_embed_1/c
                     container:
                       source: container
                     host:
    @@ -487,7 +429,7 @@ $graph:
                       - sh
                       - _script
                     class: CommandLineTool
    -                id: main_2_embed_2_embed
    +                id: main_2_embed_1_embed
                     inputs:
                       - id: container
                         type: string
    @@ -495,21 +437,21 @@ $graph:
                         type: string
                       - id: arvbox_bin
                         type: File
    -                  - id: c
    -                    type: string
                       - id: refspec
                         type: string
                     outputs:
                       - id: d
                         outputBinding:
    -                      outputEval: $(inputs.c)
    +                      outputEval: $(inputs.container)
                         type: string
                     requirements:
                       InitialWorkDirRequirement:
                         listing:
    -                      - entry: >
    +                      - entry: >+
                               set -xe
     
    +                          export ARVBOX_CONTAINER="$(inputs.container)"
    +
                               $(inputs.arvbox_bin.path) pipe </dev/null ; do sleep 3 ; done
     
    -                          export ARVADOS_API_HOST=$(inputs.host)
    -
    -                          export ARVADOS_API_TOKEN=\$($(inputs.arvbox_bin.path)
    -                          cat /var/lib/arvados/superuser_token)
    -
    -                          export ARVADOS_API_HOST_INSECURE=1
     
                               ARVADOS_VIRTUAL_MACHINE_UUID=\$($(inputs.arvbox_bin.path)
                               cat /var/lib/arvados/vm-uuid)
     
    -                          while ! python -c "import arvados ;
    -                          arvados.api().virtual_machines().get(uuid='$ARVADOS_VIRTUAL_MACHINE_UUID').execute()"
    -                          2>/dev/null ; do sleep 3; done
    +                          ARVADOS_API_TOKEN=\$($(inputs.arvbox_bin.path) cat
    +                          /var/lib/arvados/superuser_token)
    +
    +                          while ! curl --fail --insecure --silent -H
    +                          "Authorization: Bearer $ARVADOS_API_TOKEN"
    +                          https://$(inputs.host)/arvados/v1/virtual_machines/$ARVADOS_VIRTUAL_MACHINE_UUID
    +                          >/dev/null ; do sleep 3 ; done
    +
                             entryname: _script
                       InlineJavascriptRequirement: {}
    -            - id: superuser_tok_3
    +            - id: superuser_tok_2
                   in:
                     container:
                       source: container
                     d:
    -                  source: main_2_embed_2/d
    +                  source: main_2_embed_1/d
                   out:
                     - superuser_token
                   run: '#superuser_tok'
    diff --git a/sdk/python/tests/fed-migrate/fed-migrate.cwlex b/sdk/python/tests/fed-migrate/fed-migrate.cwlex
    index 22bc95a833..e0beaa91d6 100644
    --- a/sdk/python/tests/fed-migrate/fed-migrate.cwlex
    +++ b/sdk/python/tests/fed-migrate/fed-migrate.cwlex
    @@ -8,7 +8,7 @@ def workflow main(
       arvbox_containers string[],
       fed_migrate="arv-federation-migrate",
       arvbox_bin File,
    -  refspec="15531-logincluster-migrate"
    +  refspec="master"
     ) {
     
       logincluster = run expr (arvados_cluster_ids) string (inputs.arvados_cluster_ids[0])
    @@ -18,27 +18,10 @@ def workflow main(
     	  arvados_api_hosts as host
         do run workflow(logincluster, arvbox_bin, refspec)
       {
    -    requirements {
    -      EnvVarRequirement {
    -        envDef: {
    -          ARVBOX_CONTAINER: "$(inputs.container)"
    -        }
    -      }
    -    }
    -
    -    run tool(container, cluster_id, logincluster, set_login = File("set_login.py")) {
    -sh <<<
    -set -x
    -docker cp $(inputs.container):/var/lib/arvados/cluster_config.yml.override .
    -chmod +w cluster_config.yml.override
    -python $(inputs.set_login.path) cluster_config.yml.override $(inputs.cluster_id) $(inputs.logincluster)
    -docker cp cluster_config.yml.override $(inputs.container):/var/lib/arvados
    ->>>
    -      return container as c
    -    }
    -    run tool(container, host, arvbox_bin, c, refspec) {
    +    run tool(container, host, arvbox_bin, refspec) {
     sh <<<
     set -xe
    +export ARVBOX_CONTAINER="$(inputs.container)"
     $(inputs.arvbox_bin.path) pipe </dev/null ; do sleep 3 ; done
    -export ARVADOS_API_HOST=$(inputs.host)
    -export ARVADOS_API_TOKEN=\$($(inputs.arvbox_bin.path) cat /var/lib/arvados/superuser_token)
    -export ARVADOS_API_HOST_INSECURE=1
    +
     ARVADOS_VIRTUAL_MACHINE_UUID=\$($(inputs.arvbox_bin.path) cat /var/lib/arvados/vm-uuid)
    -while ! python -c "import arvados ; arvados.api().virtual_machines().get(uuid='$ARVADOS_VIRTUAL_MACHINE_UUID').execute()" 2>/dev/null ; do sleep 3; done
    +ARVADOS_API_TOKEN=\$($(inputs.arvbox_bin.path) cat /var/lib/arvados/superuser_token)
    +while ! curl --fail --insecure --silent -H "Authorization: Bearer $ARVADOS_API_TOKEN" https://$(inputs.host)/arvados/v1/virtual_machines/$ARVADOS_VIRTUAL_MACHINE_UUID >/dev/null ; do sleep 3 ; done
    +
     >>>
    -      return c as d
    +      return container as d
         }
         supertok = superuser_tok(container, d)
         return supertok
    diff --git a/sdk/python/tests/fed-migrate/set_login.py b/sdk/python/tests/fed-migrate/set_login.py
    deleted file mode 100644
    index 2900af1823..0000000000
    --- a/sdk/python/tests/fed-migrate/set_login.py
    +++ /dev/null
    @@ -1,10 +0,0 @@
    -import json
    -import sys
    -
    -f = open(sys.argv[1], "r+")
    -j = json.load(f)
    -j["Clusters"][sys.argv[2]]["Login"] = {"LoginCluster": sys.argv[3]}
    -for r in j["Clusters"][sys.argv[2]]["RemoteClusters"]:
    -    j["Clusters"][sys.argv[2]]["RemoteClusters"][r]["Insecure"] = True
    -f.seek(0)
    -json.dump(j, f)
    diff --git a/services/api/Gemfile.lock b/services/api/Gemfile.lock
    index 5ebdff0ca7..c4bd33fda6 100644
    --- a/services/api/Gemfile.lock
    +++ b/services/api/Gemfile.lock
    @@ -3,7 +3,7 @@ GIT
       revision: dd9f2403f43bcb93da5908ddde57d8c0491bb4c2
       glob: sdk/*/*.gemspec
       specs:
    -    arvados (1.4.1.20191019025325)
    +    arvados (1.4.2.20191019025325)
           activesupport (>= 3)
           andand (~> 1.3, >= 1.3.3)
           arvados-google-api-client (>= 0.7, < 0.8.9)
    @@ -11,7 +11,7 @@ GIT
           i18n (~> 0)
           json (>= 1.7.7, < 3)
           jwt (>= 0.1.5, < 2)
    -    arvados-cli (1.4.1.20191017145711)
    +    arvados-cli (1.4.2.20191017145711)
           activesupport (>= 3.2.13, < 5.1)
           andand (~> 1.3, >= 1.3.3)
           arvados (>= 1.4.1.20190320201707)
    @@ -334,4 +334,4 @@ DEPENDENCIES
       uglifier (~> 2.0)
     
     BUNDLED WITH
    -   1.17.3
    +   2.0.2
    diff --git a/services/api/app/models/arvados_model.rb b/services/api/app/models/arvados_model.rb
    index f933126933..946c4262e3 100644
    --- a/services/api/app/models/arvados_model.rb
    +++ b/services/api/app/models/arvados_model.rb
    @@ -457,6 +457,9 @@ class ArvadosModel < ApplicationRecord
         if not ft[:cond_out].any?
           return query
         end
    +    ft[:joins].each do |t|
    +      query = query.joins(t)
    +    end
         query.where('(' + ft[:cond_out].join(') AND (') + ')',
                               *ft[:param_out])
       end
    diff --git a/services/api/app/models/user.rb b/services/api/app/models/user.rb
    index 7a3a854b3a..a49aa6f56a 100644
    --- a/services/api/app/models/user.rb
    +++ b/services/api/app/models/user.rb
    @@ -435,7 +435,7 @@ class User < ArvadosModel
                                   :is_admin => false,
                                   :is_active => Rails.configuration.Users.NewUsersAreActive)
     
    -      primary_user.set_initial_username(requested: info['username']) if info['username']
    +      primary_user.set_initial_username(requested: info['username']) if info['username'] && !info['username'].blank?
           primary_user.identity_url = info['identity_url'] if identity_url
         end
     
    diff --git a/services/api/lib/record_filters.rb b/services/api/lib/record_filters.rb
    index c8f024291c..994e850310 100644
    --- a/services/api/lib/record_filters.rb
    +++ b/services/api/lib/record_filters.rb
    @@ -21,12 +21,14 @@ module RecordFilters
       # Output:
       # Hash with two keys:
       # :cond_out  array of SQL fragments for each filter expression
    -  # :param_out  array of values for parameter substitution in cond_out
    +  # :param_out array of values for parameter substitution in cond_out
    +  # :joins     array of joins: either [] or ["JOIN containers ON ..."]
       def record_filters filters, model_class
         conds_out = []
         param_out = []
    +    joins = []
     
    -    ar_table_name = model_class.table_name
    +    model_table_name = model_class.table_name
         filters.each do |filter|
           attrs_in, operator, operand = filter
           if attrs_in == 'any' && operator != '@@'
    @@ -71,78 +73,91 @@ module RecordFilters
           attrs.each do |attr|
             subproperty = attr.split(".", 2)
     
    -        col = model_class.columns.select { |c| c.name == subproperty[0] }.first
    +        if subproperty.length == 2 && subproperty[0] == 'container' && model_table_name == "container_requests"
    +          # attr is "tablename.colname" -- e.g., ["container.state", "=", "Complete"]
    +          joins = ["JOIN containers ON container_requests.container_uuid = containers.uuid"]
    +          attr_model_class = Container
    +          attr_table_name = "containers"
    +          subproperty = subproperty[1].split(".", 2)
    +        else
    +          attr_model_class = model_class
    +          attr_table_name = model_table_name
    +        end
    +
    +        attr = subproperty[0]
    +        proppath = subproperty[1]
    +        col = attr_model_class.columns.select { |c| c.name == attr }.first
     
    -        if subproperty.length == 2
    +        if proppath
               if col.nil? or col.type != :jsonb
    -            raise ArgumentError.new("Invalid attribute '#{subproperty[0]}' for subproperty filter")
    +            raise ArgumentError.new("Invalid attribute '#{attr}' for subproperty filter")
               end
     
    -          if subproperty[1][0] == "<" and subproperty[1][-1] == ">"
    -            subproperty[1] = subproperty[1][1..-2]
    +          if proppath[0] == "<" and proppath[-1] == ">"
    +            proppath = proppath[1..-2]
               end
     
               # jsonb search
               case operator.downcase
               when '=', '!='
                 not_in = if operator.downcase == "!=" then "NOT " else "" end
    -            cond_out << "#{not_in}(#{ar_table_name}.#{subproperty[0]} @> ?::jsonb)"
    -            param_out << SafeJSON.dump({subproperty[1] => operand})
    +            cond_out << "#{not_in}(#{attr_table_name}.#{attr} @> ?::jsonb)"
    +            param_out << SafeJSON.dump({proppath => operand})
               when 'in'
                 if operand.is_a? Array
                   operand.each do |opr|
    -                cond_out << "#{ar_table_name}.#{subproperty[0]} @> ?::jsonb"
    -                param_out << SafeJSON.dump({subproperty[1] => opr})
    +                cond_out << "#{attr_table_name}.#{attr} @> ?::jsonb"
    +                param_out << SafeJSON.dump({proppath => opr})
                   end
                 else
                   raise ArgumentError.new("Invalid operand type '#{operand.class}' "\
                                           "for '#{operator}' operator in filters")
                 end
               when '<', '<=', '>', '>='
    -            cond_out << "#{ar_table_name}.#{subproperty[0]}->? #{operator} ?::jsonb"
    -            param_out << subproperty[1]
    +            cond_out << "#{attr_table_name}.#{attr}->? #{operator} ?::jsonb"
    +            param_out << proppath
                 param_out << SafeJSON.dump(operand)
               when 'like', 'ilike'
    -            cond_out << "#{ar_table_name}.#{subproperty[0]}->>? #{operator} ?"
    -            param_out << subproperty[1]
    +            cond_out << "#{attr_table_name}.#{attr}->>? #{operator} ?"
    +            param_out << proppath
                 param_out << operand
               when 'not in'
                 if operand.is_a? Array
    -              cond_out << "#{ar_table_name}.#{subproperty[0]}->>? NOT IN (?) OR #{ar_table_name}.#{subproperty[0]}->>? IS NULL"
    -              param_out << subproperty[1]
    +              cond_out << "#{attr_table_name}.#{attr}->>? NOT IN (?) OR #{attr_table_name}.#{attr}->>? IS NULL"
    +              param_out << proppath
                   param_out << operand
    -              param_out << subproperty[1]
    +              param_out << proppath
                 else
                   raise ArgumentError.new("Invalid operand type '#{operand.class}' "\
                                           "for '#{operator}' operator in filters")
                 end
               when 'exists'
                 if operand == true
    -              cond_out << "jsonb_exists(#{ar_table_name}.#{subproperty[0]}, ?)"
    +              cond_out << "jsonb_exists(#{attr_table_name}.#{attr}, ?)"
                 elsif operand == false
    -              cond_out << "(NOT jsonb_exists(#{ar_table_name}.#{subproperty[0]}, ?)) OR #{ar_table_name}.#{subproperty[0]} is NULL"
    +              cond_out << "(NOT jsonb_exists(#{attr_table_name}.#{attr}, ?)) OR #{attr_table_name}.#{attr} is NULL"
                 else
                   raise ArgumentError.new("Invalid operand '#{operand}' for '#{operator}' must be true or false")
                 end
    -            param_out << subproperty[1]
    +            param_out << proppath
               else
                 raise ArgumentError.new("Invalid operator for subproperty search '#{operator}'")
               end
             elsif operator.downcase == "exists"
               if col.type != :jsonb
    -            raise ArgumentError.new("Invalid attribute '#{subproperty[0]}' for operator '#{operator}' in filter")
    +            raise ArgumentError.new("Invalid attribute '#{attr}' for operator '#{operator}' in filter")
               end
     
    -          cond_out << "jsonb_exists(#{ar_table_name}.#{subproperty[0]}, ?)"
    +          cond_out << "jsonb_exists(#{attr_table_name}.#{attr}, ?)"
               param_out << operand
             else
    -          if !model_class.searchable_columns(operator).index subproperty[0]
    -            raise ArgumentError.new("Invalid attribute '#{subproperty[0]}' in filter")
    +          if !attr_model_class.searchable_columns(operator).index attr
    +            raise ArgumentError.new("Invalid attribute '#{attr}' in filter")
               end
     
               case operator.downcase
               when '=', '<', '<=', '>', '>=', '!=', 'like', 'ilike'
    -            attr_type = model_class.attribute_column(attr).type
    +            attr_type = attr_model_class.attribute_column(attr).type
                 operator = '<>' if operator == '!='
                 if operand.is_a? String
                   if attr_type == :boolean
    @@ -162,9 +177,9 @@ module RecordFilters
                   end
                   if operator == '<>'
                     # explicitly allow NULL
    -                cond_out << "#{ar_table_name}.#{attr} #{operator} ? OR #{ar_table_name}.#{attr} IS NULL"
    +                cond_out << "#{attr_table_name}.#{attr} #{operator} ? OR #{attr_table_name}.#{attr} IS NULL"
                   else
    -                cond_out << "#{ar_table_name}.#{attr} #{operator} ?"
    +                cond_out << "#{attr_table_name}.#{attr} #{operator} ?"
                   end
                   if (# any operator that operates on value rather than
                     # representation:
    @@ -173,15 +188,15 @@ module RecordFilters
                   end
                   param_out << operand
                 elsif operand.nil? and operator == '='
    -              cond_out << "#{ar_table_name}.#{attr} is null"
    +              cond_out << "#{attr_table_name}.#{attr} is null"
                 elsif operand.nil? and operator == '<>'
    -              cond_out << "#{ar_table_name}.#{attr} is not null"
    +              cond_out << "#{attr_table_name}.#{attr} is not null"
                 elsif (attr_type == :boolean) and ['=', '<>'].include?(operator) and
                      [true, false].include?(operand)
    -              cond_out << "#{ar_table_name}.#{attr} #{operator} ?"
    +              cond_out << "#{attr_table_name}.#{attr} #{operator} ?"
                   param_out << operand
                 elsif (attr_type == :integer)
    -              cond_out << "#{ar_table_name}.#{attr} #{operator} ?"
    +              cond_out << "#{attr_table_name}.#{attr} #{operator} ?"
                   param_out << operand
                 else
                   raise ArgumentError.new("Invalid operand type '#{operand.class}' "\
    @@ -189,11 +204,11 @@ module RecordFilters
                 end
               when 'in', 'not in'
                 if operand.is_a? Array
    -              cond_out << "#{ar_table_name}.#{attr} #{operator} (?)"
    +              cond_out << "#{attr_table_name}.#{attr} #{operator} (?)"
                   param_out << operand
                   if operator == 'not in' and not operand.include?(nil)
                     # explicitly allow NULL
    -                cond_out[-1] = "(#{cond_out[-1]} OR #{ar_table_name}.#{attr} IS NULL)"
    +                cond_out[-1] = "(#{cond_out[-1]} OR #{attr_table_name}.#{attr} IS NULL)"
                   end
                 else
                   raise ArgumentError.new("Invalid operand type '#{operand.class}' "\
    @@ -206,14 +221,14 @@ module RecordFilters
                   cl = ArvadosModel::kind_class op
                   if cl
                     if attr == 'uuid'
    -                  if model_class.uuid_prefix == cl.uuid_prefix
    +                  if attr_model_class.uuid_prefix == cl.uuid_prefix
                         cond << "1=1"
                       else
                         cond << "1=0"
                       end
                     else
                       # Use a substring query to support remote uuids
    -                  cond << "substring(#{ar_table_name}.#{attr}, 7, 5) = ?"
    +                  cond << "substring(#{attr_table_name}.#{attr}, 7, 5) = ?"
                       param_out << cl.uuid_prefix
                     end
                   else
    @@ -229,7 +244,7 @@ module RecordFilters
           conds_out << cond_out.join(' OR ') if cond_out.any?
         end
     
    -    {:cond_out => conds_out, :param_out => param_out}
    +    {:cond_out => conds_out, :param_out => param_out, :joins => joins}
       end
     
     end
    diff --git a/services/api/test/functional/arvados/v1/container_requests_controller_test.rb b/services/api/test/functional/arvados/v1/container_requests_controller_test.rb
    index e77e2ed3c6..95c477f411 100644
    --- a/services/api/test/functional/arvados/v1/container_requests_controller_test.rb
    +++ b/services/api/test/functional/arvados/v1/container_requests_controller_test.rb
    @@ -98,4 +98,48 @@ class Arvados::V1::ContainerRequestsControllerTest < ActionController::TestCase
         assert_equal api_client_authorizations(:spectator).token, req.runtime_token
       end
     
    +  %w(Running Complete).each do |state|
    +    test "filter on container.state = #{state}" do
    +      authorize_with :active
    +      get :index, params: {
    +            filters: [['container.state', '=', state]],
    +          }
    +      assert_response :success
    +      assert_operator json_response['items'].length, :>, 0
    +      json_response['items'].each do |cr|
    +        assert_equal state, Container.find_by_uuid(cr['container_uuid']).state
    +      end
    +    end
    +  end
    +
    +  test "filter on container success" do
    +    authorize_with :active
    +    get :index, params: {
    +          filters: [
    +            ['container.state', '=', 'Complete'],
    +            ['container.exit_code', '=', '0'],
    +          ],
    +        }
    +    assert_response :success
    +    assert_operator json_response['items'].length, :>, 0
    +    json_response['items'].each do |cr|
    +      assert_equal 'Complete', Container.find_by_uuid(cr['container_uuid']).state
    +      assert_equal 0, Container.find_by_uuid(cr['container_uuid']).exit_code
    +    end
    +  end
    +
    +  test "filter on container subproperty runtime_status[foo] = bar" do
    +    ctr = containers(:running)
    +    act_as_system_user do
    +      ctr.update_attributes!(runtime_status: {foo: 'bar'})
    +    end
    +    authorize_with :active
    +    get :index, params: {
    +          filters: [
    +            ['container.runtime_status.foo', '=', 'bar'],
    +          ],
    +        }
    +    assert_response :success
    +    assert_equal [ctr.uuid], json_response['items'].collect { |cr| cr['container_uuid'] }.uniq
    +  end
     end
    diff --git a/services/api/test/unit/permission_test.rb b/services/api/test/unit/permission_test.rb
    index 275d2a651b..18d2fbbcb5 100644
    --- a/services/api/test/unit/permission_test.rb
    +++ b/services/api/test/unit/permission_test.rb
    @@ -287,6 +287,12 @@ class PermissionTest < ActiveSupport::TestCase
         a = create :active_user, first_name: "A"
         b = create :active_user, first_name: "B"
         other = create :active_user, first_name: "OTHER"
    +
    +    assert_empty(User.readable_by(b).where(uuid: a.uuid),
    +                     "#{b.first_name} should not be able to see 'a' in the user list")
    +    assert_empty(User.readable_by(a).where(uuid: b.uuid),
    +                     "#{a.first_name} should not be able to see 'b' in the user list")
    +
         act_as_system_user do
           g = create :group
           [a,b].each do |u|
    @@ -296,6 +302,12 @@ class PermissionTest < ActiveSupport::TestCase
                    name: 'can_read', head_uuid: u.uuid, tail_uuid: g.uuid)
           end
         end
    +
    +    assert_not_empty(User.readable_by(b).where(uuid: a.uuid),
    +                     "#{b.first_name} should be able to see 'a' in the user list")
    +    assert_not_empty(User.readable_by(a).where(uuid: b.uuid),
    +                     "#{a.first_name} should be able to see 'b' in the user list")
    +
         a_specimen = act_as_user a do
           Specimen.create!
         end
    diff --git a/services/keep-balance/collection.go b/services/keep-balance/collection.go
    index 534928bc82..73d129e9cd 100644
    --- a/services/keep-balance/collection.go
    +++ b/services/keep-balance/collection.go
    @@ -80,7 +80,7 @@ func EachCollection(c *arvados.Client, pageSize int, f func(arvados.Collection)
     			return err
     		}
     		for _, coll := range page.Items {
    -			if last.ModifiedAt != nil && *last.ModifiedAt == *coll.ModifiedAt && last.UUID >= coll.UUID {
    +			if last.ModifiedAt == coll.ModifiedAt && last.UUID >= coll.UUID {
     				continue
     			}
     			callCount++
    @@ -92,9 +92,9 @@ func EachCollection(c *arvados.Client, pageSize int, f func(arvados.Collection)
     		}
     		if len(page.Items) == 0 && !gettingExactTimestamp {
     			break
    -		} else if last.ModifiedAt == nil {
    +		} else if last.ModifiedAt.IsZero() {
     			return fmt.Errorf("BUG: Last collection on the page (%s) has no modified_at timestamp; cannot make progress", last.UUID)
    -		} else if len(page.Items) > 0 && *last.ModifiedAt == filterTime {
    +		} else if len(page.Items) > 0 && last.ModifiedAt == filterTime {
     			// If we requested time>=X and never got a
     			// time>X then we might not have received all
     			// items with time==X yet. Switch to
    @@ -135,7 +135,7 @@ func EachCollection(c *arvados.Client, pageSize int, f func(arvados.Collection)
     			// avoiding that would add overhead in the
     			// overwhelmingly common cases, so we don't
     			// bother.
    -			filterTime = *last.ModifiedAt
    +			filterTime = last.ModifiedAt
     			params.Filters = []arvados.Filter{{
     				Attr:     "modified_at",
     				Operator: ">=",
    diff --git a/services/keep-balance/collection_test.go b/services/keep-balance/collection_test.go
    index a2200e1db9..e6925c4afd 100644
    --- a/services/keep-balance/collection_test.go
    +++ b/services/keep-balance/collection_test.go
    @@ -30,7 +30,7 @@ func (s *integrationSuite) TestIdenticalTimestamps(c *check.C) {
     			var lastMod time.Time
     			sawUUID := make(map[string]bool)
     			err := EachCollection(s.client, pageSize, func(c arvados.Collection) error {
    -				if c.ModifiedAt == nil {
    +				if c.ModifiedAt.IsZero() {
     					return nil
     				}
     				if sawUUID[c.UUID] {
    @@ -39,14 +39,14 @@ func (s *integrationSuite) TestIdenticalTimestamps(c *check.C) {
     				}
     				got[trial] = append(got[trial], c.UUID)
     				sawUUID[c.UUID] = true
    -				if lastMod == *c.ModifiedAt {
    +				if lastMod == c.ModifiedAt {
     					streak++
     					if streak > longestStreak {
     						longestStreak = streak
     					}
     				} else {
     					streak = 0
    -					lastMod = *c.ModifiedAt
    +					lastMod = c.ModifiedAt
     				}
     				return nil
     			}, nil)
    diff --git a/services/login-sync/Gemfile.lock b/services/login-sync/Gemfile.lock
    index 5f163e87c3..44c3b36ee4 100644
    --- a/services/login-sync/Gemfile.lock
    +++ b/services/login-sync/Gemfile.lock
    @@ -85,4 +85,4 @@ DEPENDENCIES
       rake
     
     BUNDLED WITH
    -   1.17.3
    +   2.0.2
    diff --git a/tools/arvbox/lib/arvbox/docker/cluster-config.sh b/tools/arvbox/lib/arvbox/docker/cluster-config.sh
    index 8fd088a137..ed4795d1cc 100755
    --- a/tools/arvbox/lib/arvbox/docker/cluster-config.sh
    +++ b/tools/arvbox/lib/arvbox/docker/cluster-config.sh
    @@ -147,7 +147,6 @@ Clusters:
           AutoSetupNewUsers: true
           AutoSetupNewUsersWithVmUUID: $vm_uuid
           AutoSetupNewUsersWithRepository: true
    -      AnonymousUserToken: $(cat /var/lib/arvados/superuser_token)
         Workbench:
           SecretKeyBase: $workbench_secret_key_base
           ArvadosDocsite: http://$localip:${services[doc]}/
    diff --git a/tools/arvbox/lib/arvbox/docker/go-setup.sh b/tools/arvbox/lib/arvbox/docker/go-setup.sh
    index 15a11b0580..e09cf7ae61 100644
    --- a/tools/arvbox/lib/arvbox/docker/go-setup.sh
    +++ b/tools/arvbox/lib/arvbox/docker/go-setup.sh
    @@ -9,9 +9,9 @@ mkdir -p $GOPATH
     cd /usr/src/arvados
     if [[ $UID = 0 ]] ; then
         /usr/local/lib/arvbox/runsu.sh flock /var/lib/gopath/gopath.lock go mod download
    -    /usr/local/lib/arvbox/runsu.sh flock /var/lib/gopath/gopath.lock go get ./cmd/arvados-server
    +    /usr/local/lib/arvbox/runsu.sh flock /var/lib/gopath/gopath.lock go get git.curoverse.com/arvados.git/cmd/arvados-server
     else
         flock /var/lib/gopath/gopath.lock go mod download
    -    flock /var/lib/gopath/gopath.lock go get ./cmd/arvados-server
    +    flock /var/lib/gopath/gopath.lock go get git.curoverse.com/arvados.git/cmd/arvados-server
     fi
     install $GOPATH/bin/arvados-server /usr/local/bin
    diff --git a/tools/arvbox/lib/arvbox/docker/service/doc/run-service b/tools/arvbox/lib/arvbox/docker/service/doc/run-service
    index ea66cfd7a2..66a4a28ec5 100755
    --- a/tools/arvbox/lib/arvbox/docker/service/doc/run-service
    +++ b/tools/arvbox/lib/arvbox/docker/service/doc/run-service
    @@ -8,11 +8,16 @@ set -ex -o pipefail
     
     . /usr/local/lib/arvbox/common.sh
     
    +
     cd /usr/src/arvados/doc
     run_bundler --without=development
     
    -cd /usr/src/arvados/sdk/R
    -R --quiet --vanilla --file=install_deps.R
    +# Generating the R docs is expensive, so for development if the file
    +# "no-sdk" exists then skip the R stuff.
    +if [[ ! -f /usr/src/arvados/doc/no-sdk ]] ; then
    +    cd /usr/src/arvados/sdk/R
    +    R --quiet --vanilla --file=install_deps.R
    +fi
     
     if test "$1" = "--only-deps" ; then
         exit