From e5f86150076114c1e2066111c17c88f51fc1e609 Mon Sep 17 00:00:00 2001 From: Zachary Collins Date: Thu, 28 May 2020 16:56:36 -0700 Subject: [PATCH 01/17] First pass at a docker setup --- .gitignore | 3 + Makefile | 71 +++++++++++++++++++ docker-compose.yml | 62 ++++++++++++++++ docker/app/Dockerfile.development | 48 +++++++++++++ docker/app/docker-entrypoint.sh | 26 +++++++ docker/app/puma.sh | 9 +++ docker/db/Dockerfile | 3 + docker/db/README.md | 3 + .../init-user-db.sh | 11 +++ package.json | 2 +- spec/assimilate_spec.rb | 2 +- 11 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 Makefile create mode 100644 docker-compose.yml create mode 100644 docker/app/Dockerfile.development create mode 100755 docker/app/docker-entrypoint.sh create mode 100755 docker/app/puma.sh create mode 100644 docker/db/Dockerfile create mode 100644 docker/db/README.md create mode 100755 docker/db/docker-entrypoint-initdb.d/init-user-db.sh diff --git a/.gitignore b/.gitignore index 4f590b08..20496abb 100644 --- a/.gitignore +++ b/.gitignore @@ -15,9 +15,12 @@ public/ /source /coverage spec/examples.txt +/.bundle +/.idea *.eot *.ttf *.woff *.woff2 *.swp + diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..e8720a95 --- /dev/null +++ b/Makefile @@ -0,0 +1,71 @@ +# SHELL := /bin/bash +DB_PORT = $(shell docker inspect --format='{{(index (index .NetworkSettings.Ports "5432/tcp") 0).HostPort}}' db_1) + +help: ## Display help text + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) /dev/null | \ + sed 's/^[^:]*://' | sort | \ + awk -F':.*?## ' '{printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + +.PHONY: lint help +.DEFAULT_GOAL := help + +### Files ### +vendor/bundle: Gemfile Gemfile.lock docker/app/Dockerfile + @ $(MAKE) bundle + @ touch vendor/bundle + +tmp/docker-build-mark: $(wildcard docker/**/*) docker-compose.yml + docker-compose rm -f db + docker-compose pull db + docker-compose build app + @ touch tmp/docker-build-mark + +### RAILS_ENV=development mode commands ### +.PHONY: up +up: ## Starts up the database, worker, and webservers of metis in the background. + @ docker-compose up -d + +.PHONY: down +down: ## Ends background metis processes + @ docker-compose down + +.PHONY: ps +ps: ## Lists status of running metis processes + @ docker-compose ps + +.PHONY: bundle +bundle: ## Executes a bundle install inside of the metis app context. + docker-compose run --rm app bundle install + +.PHONY: build +build: ## Rebuilds the metis docker environment. Does not clear volumes or databases, just rebuilds code components. + @ docker-compose build + +.PHONY: console +console: ## Starts an irb console inside of the metis app context. + docker-compose run --rm app bundle exec irb + +.PHONY: migrate +migrate: ## Executes dev and test migrations inside of the metis app context. + @ docker-compose run --rm app ./bin/metis migrate + @ docker-compose run -e METIS_ENV=test --rm app ./bin/metis migrate + +.PHONY: test +test: ## Execute (all) rspec tests inside of the metis app context. + @ docker-compose run -e METIS_ENV=test --rm app bundle exec rspec + +.PHONY: bash +bash: ## Start a bash shell inside of the app context. + @docker-compose exec app bash + +.PHONY: db-port +db-port: ## Print the db port associated with the app. + @ echo $(DB_PORT) + +.PHONY: psql +psql: ## Start a psql shell conntected to the metis development db + @ PGPASSWORD=password psql -h localhost -p $(DB_PORT) -U developer -d metis_development + +.PHONY: logs +logs: ## Follow logs of running containers + docker-compose logs -f diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..7febe883 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,62 @@ +version: "3.4" +volumes: + app-gems: + driver: local + db-data: + driver: local + node-modules: + driver: local + data-dir: + driver: local + log-dir: + driver: local + tmp-dir: + driver: local + public-dir: + driver: local + +x-base: &app_base + build: + context: . + dockerfile: docker/app/Dockerfile.development + volumes: + - .:/app:cached + - app-gems:/app/vendor/bundle:rw + - node-modules:/app/node_modules:rw + - data-dir:/app/data:rw + - log-dir:/app/log:rw + - tmp-dir:/app/tmp:rw + - public-dir:/app/public:rw + environment: &app_environment + COVERAGE: 'true' + DATABASE_HOST: 'db' + METIS_ENV: 'development' + +services: + app: + <<: *app_base + ports: + - 3000:3000 + command: ./docker/app/puma.sh + + webpack: + <<: *app_base + environment: + <<: *app_environment + RUN_NPM_INSTALL: 'true' + SKIP_RUBY_SETUP: 'true' + command: npm run webpack + + db: + build: + context: . + dockerfile: docker/db/Dockerfile + volumes: + - db-data:/var/lib/postgresql/data + environment: + POSTGRES_DB: metis_development + POSTGRES_PASSWORD: password + POSTGRES_USER: developer + ports: + - 5432 + container_name: db_1 diff --git a/docker/app/Dockerfile.development b/docker/app/Dockerfile.development new file mode 100644 index 00000000..565b97b6 --- /dev/null +++ b/docker/app/Dockerfile.development @@ -0,0 +1,48 @@ +FROM ruby:2.5.7 +WORKDIR /app + +ENV RUBY_VERSION 2.5.7 +ENV BUNDLER_VERSION 2.1.4 +ENV DOCKERIZE_VERSION 0.5.0 +ENV DOCKERIZE_URL https://github.com/jwilder/dockerize/releases/download/v${DOCKERIZE_VERSION}/dockerize-linux-amd64-v${DOCKERIZE_VERSION}.tar.gz +ENV NODE_VERSION 8.12.0 +ENV SERVICE_NAME metis + +# add postgres 10 repo for debian stretch +RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ stretch-pgdg main" >> /etc/apt/sources.list.d/pgdg.list +RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - + +RUN apt-get update \ + && apt-get install -y \ + curl \ + libopenblas-base \ + libcurl4-openssl-dev \ + libssl-dev \ + parallel \ + postgresql-client-10 \ + --no-install-recommends &&\ + rm -rf /var/lib/apt/lists/* + +# Install nodejs +RUN curl https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.gz | tar --strip-components=1 -xzC /usr/local/ \ + && npm install -g yarn + +RUN curl -o /tmp/dockerize.tgz -L $DOCKERIZE_URL && ( cd /usr/bin && tar xzf /tmp/dockerize.tgz ) + +# We want the bundle installed into the default location (/vendor/bundle) +ENV BUNDLE_PATH="/app/vendor/bundle/$RUBY_VERSION" +ENV BUNDLE_BIN="$BUNDLE_PATH/bin" +ENV BUNDLE_SILENCE_ROOT_WARNING=0 +ENV BUNDLE_APP_CONFIG="/app/.bundle" +# We also the GEM_PATH to be this BUNDLE_PATH so that ides (Rubymine) will find gems easier. +ENV GEM_HOME="/app/vendor/bundle/$RUBY_VERSION" +ENV GEM_PATH="/app/vendor/bundle/$RUBY_VERSION" +RUN gem install puma +RUN gem install bundler -v "=$BUNDLER_VERSION" + +# bash improvements for developer environment +RUN git clone --depth=1 https://github.com/Bash-it/bash-it.git ~/.bash_it && \ + bash ~/.bash_it/install.sh --silent && \ + echo "export SCM_CHECK=false" >> ~/.bashrc + +ENTRYPOINT ["/app/docker/app/docker-entrypoint.sh"] diff --git a/docker/app/docker-entrypoint.sh b/docker/app/docker-entrypoint.sh new file mode 100755 index 00000000..79a5d065 --- /dev/null +++ b/docker/app/docker-entrypoint.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -e + +: "${RANDOM_MAX:=500}" +[ -n "$DEBUG" ] && echo "Running: $@" +export PATH="/app/vendor/bundle/$RUBY_VERSION/bin:$PATH" + +if [ -z "$SKIP_RUBY_SETUP" ]; then + bundle check || bundle install -j "$(nproc)" + mkdir -p tmp/pids + rm -f tmp/pids/*.pid + if [ -z "$SKIP_DB_WAIT" ]; then + dockerize -wait tcp://db:5432 -timeout 60s + fi +else + while ! bundle check >/dev/null 2>&1; do + echo "Awaiting for make bundle on host..." + sleep 5 + done +fi + +if [ -n "$RUN_NPM_INSTALL" ]; then + npm install --unsafe-perm +fi + +exec "$@" diff --git a/docker/app/puma.sh b/docker/app/puma.sh new file mode 100755 index 00000000..15f0b68f --- /dev/null +++ b/docker/app/puma.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +export METIS_ENV=development + +exec puma --bind tcp://0.0.0.0:3000 \ + --threads 3:16 \ + --redirect-append \ + --pidfile tmp/pids/puma.pid \ + --environment development \ No newline at end of file diff --git a/docker/db/Dockerfile b/docker/db/Dockerfile new file mode 100644 index 00000000..3853d9c2 --- /dev/null +++ b/docker/db/Dockerfile @@ -0,0 +1,3 @@ +FROM postgres:10 + +COPY docker/db/docker-entrypoint-initdb.d/* /docker-entrypoint-initdb.d/ diff --git a/docker/db/README.md b/docker/db/README.md new file mode 100644 index 00000000..403f631f --- /dev/null +++ b/docker/db/README.md @@ -0,0 +1,3 @@ +# Metis Database Setup + +See [postgresl docker docs](https://hub.docker.com/_/postgres/) for further details. \ No newline at end of file diff --git a/docker/db/docker-entrypoint-initdb.d/init-user-db.sh b/docker/db/docker-entrypoint-initdb.d/init-user-db.sh new file mode 100755 index 00000000..840e4934 --- /dev/null +++ b/docker/db/docker-entrypoint-initdb.d/init-user-db.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -e + + +# Add any other roles / databases here +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + CREATE DATABASE metis_test; + CREATE DATABASE metis_development; + GRANT ALL PRIVILEGES ON DATABASE metis_test TO $POSTGRES_USER; + GRANT ALL PRIVILEGES ON DATABASE metis_development TO $POSTGRES_USER; +EOSQL diff --git a/package.json b/package.json index e1ec4dab..9d359676 100644 --- a/package.json +++ b/package.json @@ -44,4 +44,4 @@ "sass-loader": "^6.0.6", "webpack": "^3.12.0" } -} \ No newline at end of file +} diff --git a/spec/assimilate_spec.rb b/spec/assimilate_spec.rb index 492ed7dd..f8eba301 100644 --- a/spec/assimilate_spec.rb +++ b/spec/assimilate_spec.rb @@ -77,7 +77,7 @@ stubs.create_folder('athena', 'files', 'blueprints') # we try to assimilate the blueprints folder - @cmd.execute('athena', 'files', '/', 'spec/stubs/blueprints') + @cmd.execute('athena', 'files', '/', 'spec/data/stubs/blueprints') expect(Metis::File.count).to eq(0) expect(Metis::Folder.count).to eq(1) From 557c55b12e39c89a3bf127b4a988f8fc018ab991 Mon Sep 17 00:00:00 2001 From: cjshaw <4930129+cjshawMIT@users.noreply.github.com> Date: Fri, 29 May 2020 13:15:51 -0400 Subject: [PATCH 02/17] Add `db` as a service depencency for this app --- docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 7febe883..337d69b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,8 @@ services: ports: - 3000:3000 command: ./docker/app/puma.sh + depends_on: + - db webpack: <<: *app_base From cf6cd3a9b23e84c9ae1a1e3e4c817a13d505742d Mon Sep 17 00:00:00 2001 From: Zachary Collins Date: Mon, 1 Jun 2020 21:09:37 -0700 Subject: [PATCH 03/17] Adding metis_ prefix, forcing migrate on first up. --- Makefile | 20 ++++++++++---------- docker-compose.yml | 14 +++++++------- docker/app/docker-entrypoint.sh | 4 ++++ 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/Makefile b/Makefile index e8720a95..88020c01 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # SHELL := /bin/bash -DB_PORT = $(shell docker inspect --format='{{(index (index .NetworkSettings.Ports "5432/tcp") 0).HostPort}}' db_1) +DB_PORT = $(shell docker inspect --format='{{(index (index .NetworkSettings.Ports "5432/tcp") 0).HostPort}}' metis_db_1) help: ## Display help text @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) /dev/null | \ @@ -15,9 +15,9 @@ vendor/bundle: Gemfile Gemfile.lock docker/app/Dockerfile @ touch vendor/bundle tmp/docker-build-mark: $(wildcard docker/**/*) docker-compose.yml - docker-compose rm -f db - docker-compose pull db - docker-compose build app + docker-compose rm -f metis_db + docker-compose pull metis_db + docker-compose build metis_app @ touch tmp/docker-build-mark ### RAILS_ENV=development mode commands ### @@ -35,7 +35,7 @@ ps: ## Lists status of running metis processes .PHONY: bundle bundle: ## Executes a bundle install inside of the metis app context. - docker-compose run --rm app bundle install + docker-compose run --rm metis_app bundle install .PHONY: build build: ## Rebuilds the metis docker environment. Does not clear volumes or databases, just rebuilds code components. @@ -43,20 +43,20 @@ build: ## Rebuilds the metis docker environment. Does not clear volumes or data .PHONY: console console: ## Starts an irb console inside of the metis app context. - docker-compose run --rm app bundle exec irb + docker-compose run --rm metis_app bundle exec irb .PHONY: migrate migrate: ## Executes dev and test migrations inside of the metis app context. - @ docker-compose run --rm app ./bin/metis migrate - @ docker-compose run -e METIS_ENV=test --rm app ./bin/metis migrate + @ docker-compose run --rm metis_app ./bin/metis migrate + @ docker-compose run -e METIS_ENV=test --rm metis_app ./bin/metis migrate .PHONY: test test: ## Execute (all) rspec tests inside of the metis app context. - @ docker-compose run -e METIS_ENV=test --rm app bundle exec rspec + @ docker-compose run -e METIS_ENV=test --rm metis_app bundle exec rspec .PHONY: bash bash: ## Start a bash shell inside of the app context. - @docker-compose exec app bash + @docker-compose exec metis_app bash .PHONY: db-port db-port: ## Print the db port associated with the app. diff --git a/docker-compose.yml b/docker-compose.yml index 7febe883..4f0403c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ volumes: public-dir: driver: local -x-base: &app_base +x-metis_base: &metis_base build: context: . dockerfile: docker/app/Dockerfile.development @@ -33,21 +33,21 @@ x-base: &app_base METIS_ENV: 'development' services: - app: - <<: *app_base + metis_app: + <<: *metis_base ports: - 3000:3000 command: ./docker/app/puma.sh - webpack: - <<: *app_base + metis_webpack: + <<: *metis_base environment: <<: *app_environment RUN_NPM_INSTALL: 'true' SKIP_RUBY_SETUP: 'true' command: npm run webpack - db: + metis_db: build: context: . dockerfile: docker/db/Dockerfile @@ -59,4 +59,4 @@ services: POSTGRES_USER: developer ports: - 5432 - container_name: db_1 + container_name: metis_db_1 diff --git a/docker/app/docker-entrypoint.sh b/docker/app/docker-entrypoint.sh index 79a5d065..6b47a554 100755 --- a/docker/app/docker-entrypoint.sh +++ b/docker/app/docker-entrypoint.sh @@ -12,6 +12,10 @@ if [ -z "$SKIP_RUBY_SETUP" ]; then if [ -z "$SKIP_DB_WAIT" ]; then dockerize -wait tcp://db:5432 -timeout 60s fi + if ! [ -e tmp/db-migrated ]; then + ./bin/metis migrate + touch tmp/db-migrated + fi else while ! bundle check >/dev/null 2>&1; do echo "Awaiting for make bundle on host..." From eaa404632b9680416880be3c357354114358903d Mon Sep 17 00:00:00 2001 From: Zachary Collins Date: Mon, 1 Jun 2020 21:24:23 -0700 Subject: [PATCH 04/17] Adding additional documentation. --- Makefile | 7 ++-- README.md | 48 +++++++++++++++++++++++ config.yml.template | 68 +++++++++++++++++++++++++++++++++ docker-compose.yml | 2 +- docker/app/docker-entrypoint.sh | 2 +- 5 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 config.yml.template diff --git a/Makefile b/Makefile index 88020c01..2dd9d7fb 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,6 @@ help: ## Display help text .PHONY: lint help .DEFAULT_GOAL := help -### Files ### vendor/bundle: Gemfile Gemfile.lock docker/app/Dockerfile @ $(MAKE) bundle @ touch vendor/bundle @@ -20,9 +19,11 @@ tmp/docker-build-mark: $(wildcard docker/**/*) docker-compose.yml docker-compose build metis_app @ touch tmp/docker-build-mark -### RAILS_ENV=development mode commands ### +config.yml: + cp config.yml.template config.yml + .PHONY: up -up: ## Starts up the database, worker, and webservers of metis in the background. +up: config.yml ## Starts up the database, worker, and webservers of metis in the background. @ docker-compose up -d .PHONY: down diff --git a/README.md b/README.md index f5ef1ef2..6acadde7 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,54 @@ binary files in folder hierarchies and access them via HTTP API. The underlying object storage uses an ordinary filesystem (i.e., files in Metis are stored on disk as files) +## + +### Docker + +#### Usage + +A `Makefile` is present to simplify usage of docker via docker-compose. +You will need to ensure that +1. `docker` is installed and running its daemon. `docker ps` should succeed. (Your user may need to belong to the `docker` group) +2. `docker-compose` is installed and available on your path. `docker-compose --help` should succeed. +3. Port 3000 is available on your host machine to bind to IFF you are accessing it via `https://localhost:3000` + +`Makefile` is used over other task managers because virtually every modern system can get `gnumake` atleast fairly easily, +without having to install other software outside the containers. + +`make help` lists all commands for using the docker system. +* `make up` brings up the database, webpack, and ruby server +* `make down` brings it all down +* `make migrate` runs migrations against the database +* `make logs` shows logs of all running containers +* `make bash` opens a bash shell in the context of the ruby server +* `make psql` connects a to the metis container database + +#### Files + +The main files related to running metis in development are: + +* config.yml(.template) - Contains runtime configuration for your environment. +The config.yml.template provides a default set of configuration that will work +with the docker setup. On first `make up` it will be copied to `config.yml`. Note +that `config.yml` itself is not checked in, so you can configure as you need locally. +* docker-compose.yml - defines the services that will run together +for the metis project specifically. Edit this to modify any environment +variables, or docker setup, specific to metis's development environment. +* docker/app/Dockerfile - a dockerfile that sets up and runs the metis web app. Configures +ruby, installs bundler, loads all files from the docker/app/* into the container. +* docker/app/docker-entrypoint.sh - A script that is run before any command inside +the container. Ensures gems are loaded up to date, waits for the db to be available, +performs potential npm installation, and then runs the command. Used both by the +webserver and the webpack bundle. +* docker/app/puma.sh - the development puma server command. +* docker/db/Dockerfile - The relatively simple docker file that brings up the postgres db +for metis. +* docker/db/docker-entrypoint-initdb.d - a directory containing scripts that will be +run to initialize the database container, mostly just initializes the actual databases +and creates roles. + + ## Organization ### Projects diff --git a/config.yml.template b/config.yml.template new file mode 100644 index 00000000..0785aaed --- /dev/null +++ b/config.yml.template @@ -0,0 +1,68 @@ +# Base development config.yml file. +# Assumes these network aliases: +# db -- metis reachable address pointing to the postgres instance +# janus.development.local -- HOST reachable address that will receive https redirects +# Committing changes to this file won't effect production, +# but it will effect all developer's environments, so let the team know +# via slack when changes are expected here. +--- +:test: + :log_file: /dev/null + :secret_key: + :upload_expiration: 60 + :download_expiration: 60 + :metis_uid_name: METIS_TEST_UID + :token_domain: example.org + :token_life: 31536000 + + :data_path: ./spec/data + :backup: + :directory: 'metis-test-athena' + :credentials: + :aws_access_key_id: 'SOMEID' + :aws_secret_access_key: 'someKey' + :hmac_keys: + :metis: haggis + :db: + :adapter: postgres + :host: db + :database: metis_test + :user: developer + :password: password + :search_path: [ public ] + +:development: + :log_file: log/error.log + :auth_redirect: https://janus.etna-development.local + :token_name: JANUS_DEV_TOKEN + :token_algo: RS256 + :metis_uid_name: METIS_DEV_UID + :token_domain: etna-development.local + :token_life: 31536000 + :upload_expiration: 60 + :download_expiration: 86400 + :hmac_keys: + :metis: 35e7c8775406612c431b654663fed668 + :data_path: ./data + :rsa_public: | + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6pLkfi2RXd3jHWKh9W37 + k7Nw5aZxIQRCf9b5b0uUIqyk4ODH1Pd4s/hhYq/9c+HT2304NkiqRw4cVOHMgk8N + O+a+7F+HgspJZopyEBCJvBloBoPKrpkZv0FeVHYwiDM2kf3GFDzTPBdUSpYJimtn + HF29DdEoj6O5xL9uGW97QZ34JYacIeG7GmVGjYnCvA87S0miDUxvGlu40g+VJhND + mLXMzWa5vTxyGb4cEQSL6rryVxmBlonG4J41K7A04/7tftKvWrqsCWAfSCusNs8w + CgX77PmgC3zcOpKDZW0LVb2x7qhp67Fz1EMnRbV1vJY5L6U4FlUnu59/WiTvCpwu + NQIDAQAB + -----END PUBLIC KEY----- + :db: + :adapter: postgres + :host: metis_db + :database: metis_development + :user: developer + :password: password + :search_path: [ public ] + :backup: + :directory: 'metis-dev-ucsf-immunoprofiler-ipi' + :credentials: + :aws_access_key_id: + :aws_secret_access_key: diff --git a/docker-compose.yml b/docker-compose.yml index 8093c502..e5df96b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,7 +39,7 @@ services: - 3000:3000 command: ./docker/app/puma.sh depends_on: - - db + - metis_db metis_webpack: <<: *metis_base diff --git a/docker/app/docker-entrypoint.sh b/docker/app/docker-entrypoint.sh index 6b47a554..9059e2fb 100755 --- a/docker/app/docker-entrypoint.sh +++ b/docker/app/docker-entrypoint.sh @@ -10,7 +10,7 @@ if [ -z "$SKIP_RUBY_SETUP" ]; then mkdir -p tmp/pids rm -f tmp/pids/*.pid if [ -z "$SKIP_DB_WAIT" ]; then - dockerize -wait tcp://db:5432 -timeout 60s + dockerize -wait tcp://metis_db:5432 -timeout 60s fi if ! [ -e tmp/db-migrated ]; then ./bin/metis migrate From 217f3d68a654dc48765a1fca598cf8c88d5b0863 Mon Sep 17 00:00:00 2001 From: Zachary Collins Date: Mon, 8 Jun 2020 09:14:33 -0700 Subject: [PATCH 05/17] Fixing implicit GEM_PATH issue for gems installation --- docker/app/Dockerfile.development | 10 ++++++---- docker/app/docker-entrypoint.sh | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docker/app/Dockerfile.development b/docker/app/Dockerfile.development index 565b97b6..3eca0ea6 100644 --- a/docker/app/Dockerfile.development +++ b/docker/app/Dockerfile.development @@ -34,11 +34,13 @@ ENV BUNDLE_PATH="/app/vendor/bundle/$RUBY_VERSION" ENV BUNDLE_BIN="$BUNDLE_PATH/bin" ENV BUNDLE_SILENCE_ROOT_WARNING=0 ENV BUNDLE_APP_CONFIG="/app/.bundle" -# We also the GEM_PATH to be this BUNDLE_PATH so that ides (Rubymine) will find gems easier. -ENV GEM_HOME="/app/vendor/bundle/$RUBY_VERSION" -ENV GEM_PATH="/app/vendor/bundle/$RUBY_VERSION" + RUN gem install puma -RUN gem install bundler -v "=$BUNDLER_VERSION" +RUN gem install bundler --default -v "=$BUNDLER_VERSION" + +# We also the GEM_PATH to be this BUNDLE_PATH so that ides (Rubymine) will find gems easier. +ENV GEM_HOME="$BUNDLE_PATH" +ENV GEM_PATH="/root/.gem/ruby/2.5.0:/usr/local/lib/ruby/gems/2.5.0:/usr/local/bundle:$BUNDLE_PATH" # bash improvements for developer environment RUN git clone --depth=1 https://github.com/Bash-it/bash-it.git ~/.bash_it && \ diff --git a/docker/app/docker-entrypoint.sh b/docker/app/docker-entrypoint.sh index 9059e2fb..82fee28a 100755 --- a/docker/app/docker-entrypoint.sh +++ b/docker/app/docker-entrypoint.sh @@ -3,7 +3,7 @@ set -e : "${RANDOM_MAX:=500}" [ -n "$DEBUG" ] && echo "Running: $@" -export PATH="/app/vendor/bundle/$RUBY_VERSION/bin:$PATH" +export PATH="/app/node_modules/.bin:/app/vendor/bundle/$RUBY_VERSION/bin:$PATH" if [ -z "$SKIP_RUBY_SETUP" ]; then bundle check || bundle install -j "$(nproc)" From d9b7d2cd32faf4b25852603f12bed4a6eaabc90a Mon Sep 17 00:00:00 2001 From: Zachary Collins Date: Mon, 8 Jun 2020 11:35:47 -0700 Subject: [PATCH 06/17] Move auto migration inside the SKIP_DB_WAIT block --- docker/app/docker-entrypoint.sh | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docker/app/docker-entrypoint.sh b/docker/app/docker-entrypoint.sh index 82fee28a..7f3a52f9 100755 --- a/docker/app/docker-entrypoint.sh +++ b/docker/app/docker-entrypoint.sh @@ -11,10 +11,9 @@ if [ -z "$SKIP_RUBY_SETUP" ]; then rm -f tmp/pids/*.pid if [ -z "$SKIP_DB_WAIT" ]; then dockerize -wait tcp://metis_db:5432 -timeout 60s - fi - if ! [ -e tmp/db-migrated ]; then - ./bin/metis migrate - touch tmp/db-migrated + if ! [ -e tmp/.migrated ]; then + ./bin/janus migrate + touch tmp/.migrated fi else while ! bundle check >/dev/null 2>&1; do From 354f33cbddac444a4c687e1918cd4f63880095ea Mon Sep 17 00:00:00 2001 From: Zachary Collins Date: Mon, 8 Jun 2020 11:52:53 -0700 Subject: [PATCH 07/17] Missing fi --- docker/app/docker-entrypoint.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/app/docker-entrypoint.sh b/docker/app/docker-entrypoint.sh index 7f3a52f9..fdf5608e 100755 --- a/docker/app/docker-entrypoint.sh +++ b/docker/app/docker-entrypoint.sh @@ -14,6 +14,7 @@ if [ -z "$SKIP_RUBY_SETUP" ]; then if ! [ -e tmp/.migrated ]; then ./bin/janus migrate touch tmp/.migrated + fi fi else while ! bundle check >/dev/null 2>&1; do From 411814c5405d830ecf091ea17342aa165885c440 Mon Sep 17 00:00:00 2001 From: Zachary Collins Date: Mon, 8 Jun 2020 12:22:33 -0700 Subject: [PATCH 08/17] Another gem fix --- docker/app/Dockerfile.development | 1 + docker/app/docker-entrypoint.sh | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/app/Dockerfile.development b/docker/app/Dockerfile.development index 3eca0ea6..8484e178 100644 --- a/docker/app/Dockerfile.development +++ b/docker/app/Dockerfile.development @@ -37,6 +37,7 @@ ENV BUNDLE_APP_CONFIG="/app/.bundle" RUN gem install puma RUN gem install bundler --default -v "=$BUNDLER_VERSION" +RUN gem update --system # We also the GEM_PATH to be this BUNDLE_PATH so that ides (Rubymine) will find gems easier. ENV GEM_HOME="$BUNDLE_PATH" diff --git a/docker/app/docker-entrypoint.sh b/docker/app/docker-entrypoint.sh index fdf5608e..b5fbe0fb 100755 --- a/docker/app/docker-entrypoint.sh +++ b/docker/app/docker-entrypoint.sh @@ -12,7 +12,7 @@ if [ -z "$SKIP_RUBY_SETUP" ]; then if [ -z "$SKIP_DB_WAIT" ]; then dockerize -wait tcp://metis_db:5432 -timeout 60s if ! [ -e tmp/.migrated ]; then - ./bin/janus migrate + ./bin/metis migrate touch tmp/.migrated fi fi From 752a143fec0761e6a88d26a030582472eac53281 Mon Sep 17 00:00:00 2001 From: Zachary Collins Date: Mon, 8 Jun 2020 17:14:34 -0700 Subject: [PATCH 09/17] bash and console now implicitly infer the docker container to be used. --- Makefile | 6 +++--- config.yml.template | 7 ------- docker/db/docker-entrypoint-initdb.d/init-user-db.sh | 4 ++-- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/Makefile b/Makefile index 2dd9d7fb..dcef9c3d 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ up: config.yml ## Starts up the database, worker, and webservers of metis in the .PHONY: down down: ## Ends background metis processes - @ docker-compose down + @ docker-compose down --remove-orphans .PHONY: ps ps: ## Lists status of running metis processes @@ -44,7 +44,7 @@ build: ## Rebuilds the metis docker environment. Does not clear volumes or data .PHONY: console console: ## Starts an irb console inside of the metis app context. - docker-compose run --rm metis_app bundle exec irb + docker exec -ti "$$(docker ps --format '{{.Names}}' | grep metis_app)" bundle exec irb .PHONY: migrate migrate: ## Executes dev and test migrations inside of the metis app context. @@ -57,7 +57,7 @@ test: ## Execute (all) rspec tests inside of the metis app context. .PHONY: bash bash: ## Start a bash shell inside of the app context. - @docker-compose exec metis_app bash + @docker exec -ti "$$(docker ps --format '{{.Names}}' | grep metis_app)" bash .PHONY: db-port db-port: ## Print the db port associated with the app. diff --git a/config.yml.template b/config.yml.template index 0785aaed..8f28ba5d 100644 --- a/config.yml.template +++ b/config.yml.template @@ -1,10 +1,3 @@ -# Base development config.yml file. -# Assumes these network aliases: -# db -- metis reachable address pointing to the postgres instance -# janus.development.local -- HOST reachable address that will receive https redirects -# Committing changes to this file won't effect production, -# but it will effect all developer's environments, so let the team know -# via slack when changes are expected here. --- :test: :log_file: /dev/null diff --git a/docker/db/docker-entrypoint-initdb.d/init-user-db.sh b/docker/db/docker-entrypoint-initdb.d/init-user-db.sh index 840e4934..8ee11041 100755 --- a/docker/db/docker-entrypoint-initdb.d/init-user-db.sh +++ b/docker/db/docker-entrypoint-initdb.d/init-user-db.sh @@ -4,8 +4,8 @@ set -e # Add any other roles / databases here psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL - CREATE DATABASE metis_test; - CREATE DATABASE metis_development; + CREATE DATABASE IF NOT EXISTS metis_test; + CREATE DATABASE IF NOT EXISTS metis_development; GRANT ALL PRIVILEGES ON DATABASE metis_test TO $POSTGRES_USER; GRANT ALL PRIVILEGES ON DATABASE metis_development TO $POSTGRES_USER; EOSQL From a66921d142708521e537396db1e3b9ea0c304534 Mon Sep 17 00:00:00 2001 From: Zachary Collins Date: Wed, 10 Jun 2020 11:32:21 -0700 Subject: [PATCH 10/17] First pass webdav implementation --- Gemfile | 4 + Gemfile.lock | 24 +++ Makefile | 4 +- config.yml.template | 4 +- docker-compose.yml | 1 + docker/app/Dockerfile.development | 3 +- lib/metis.rb | 1 + lib/web_dav_resource.rb | 280 ++++++++++++++++++++++++++++++ spec/spec_helper.rb | 19 +- spec/web_dav_resource_spec.rb | 82 +++++++++ 10 files changed, 413 insertions(+), 9 deletions(-) create mode 100644 lib/web_dav_resource.rb create mode 100644 spec/web_dav_resource_spec.rb diff --git a/Gemfile b/Gemfile index b807f1a2..f204bb83 100644 --- a/Gemfile +++ b/Gemfile @@ -7,6 +7,8 @@ gem 'pg' gem 'sequel' gem 'fog-aws' gem 'etna' +gem 'dav4rack', git: 'https://github.com/planio-gmbh/dav4rack.git', branch: 'master' +gem 'bundler' group :test do gem 'rspec' @@ -17,4 +19,6 @@ group :test do gem 'timecop' gem 'database_cleaner' gem 'pry' + gem 'ruby-debug-ide' + gem 'debase' end diff --git a/Gemfile.lock b/Gemfile.lock index ccd560a4..b5044f4b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,15 @@ +GIT + remote: https://github.com/planio-gmbh/dav4rack.git + revision: 44ea2306904b9777c2eb09945973581947552a6c + branch: master + specs: + dav4rack (1.1.0) + addressable (>= 2.5.0) + nokogiri (>= 1.6.0) + ox (>= 2.1.0) + rack (>= 1.6) + uuidtools (~> 2.1.1) + GEM remote: https://rubygems.org/ specs: @@ -16,6 +28,9 @@ GEM crack (0.4.3) safe_yaml (~> 1.0.0) database_cleaner (1.8.5) + debase (0.2.4.1) + debase-ruby_core_source (>= 0.10.2) + debase-ruby_core_source (0.10.9) diff-lcs (1.3) docile (1.3.2) etna (0.1.11) @@ -60,6 +75,7 @@ GEM connection_pool (~> 2.2) nokogiri (1.10.9) mini_portile2 (~> 2.4.0) + ox (2.13.2) pg (1.2.3) pry (0.13.1) coderay (~> 1.1) @@ -68,6 +84,7 @@ GEM rack (2.2.2) rack-test (1.1.0) rack (>= 1.0, < 3) + rake (13.0.1) rspec (3.9.0) rspec-core (~> 3.9.0) rspec-expectations (~> 3.9.0) @@ -81,6 +98,8 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.9.0) rspec-support (3.9.3) + ruby-debug-ide (0.7.2) + rake (>= 0.8.1) safe_yaml (1.0.5) sequel (5.32.0) simplecov (0.18.5) @@ -91,6 +110,7 @@ GEM timecop (0.9.1) tzinfo (1.2.7) thread_safe (~> 0.1) + uuidtools (2.1.5) webmock (3.8.3) addressable (>= 2.3.6) crack (>= 0.3.2) @@ -101,7 +121,10 @@ PLATFORMS ruby DEPENDENCIES + bundler database_cleaner + dav4rack! + debase etna factory_bot fog-aws @@ -110,6 +133,7 @@ DEPENDENCIES rack rack-test rspec + ruby-debug-ide sequel simplecov timecop diff --git a/Makefile b/Makefile index dcef9c3d..5317ef97 100644 --- a/Makefile +++ b/Makefile @@ -48,8 +48,8 @@ console: ## Starts an irb console inside of the metis app context. .PHONY: migrate migrate: ## Executes dev and test migrations inside of the metis app context. - @ docker-compose run --rm metis_app ./bin/metis migrate - @ docker-compose run -e METIS_ENV=test --rm metis_app ./bin/metis migrate + @ docker exec "$$(docker ps --format '{{.Names}}' | grep metis_app)" ./bin/metis migrate + @ docker exec -e METIS_ENV=test "$$(docker ps --format '{{.Names}}' | grep metis_app)" ./bin/metis migrate .PHONY: test test: ## Execute (all) rspec tests inside of the metis app context. diff --git a/config.yml.template b/config.yml.template index 8f28ba5d..50240c77 100644 --- a/config.yml.template +++ b/config.yml.template @@ -18,7 +18,7 @@ :metis: haggis :db: :adapter: postgres - :host: db + :host: metis_db :database: metis_test :user: developer :password: password @@ -26,7 +26,7 @@ :development: :log_file: log/error.log - :auth_redirect: https://janus.etna-development.local + :auth_redirect: https://janus.development.local :token_name: JANUS_DEV_TOKEN :token_algo: RS256 :metis_uid_name: METIS_DEV_UID diff --git a/docker-compose.yml b/docker-compose.yml index e5df96b3..45c6e8fe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,7 @@ x-metis_base: &metis_base COVERAGE: 'true' DATABASE_HOST: 'db' METIS_ENV: 'development' + entrypoint: docker/app/docker-entrypoint.sh services: metis_app: diff --git a/docker/app/Dockerfile.development b/docker/app/Dockerfile.development index 8484e178..a36accbb 100644 --- a/docker/app/Dockerfile.development +++ b/docker/app/Dockerfile.development @@ -40,8 +40,7 @@ RUN gem install bundler --default -v "=$BUNDLER_VERSION" RUN gem update --system # We also the GEM_PATH to be this BUNDLE_PATH so that ides (Rubymine) will find gems easier. -ENV GEM_HOME="$BUNDLE_PATH" -ENV GEM_PATH="/root/.gem/ruby/2.5.0:/usr/local/lib/ruby/gems/2.5.0:/usr/local/bundle:$BUNDLE_PATH" +ENV GEM_PATH="/root/.gem/ruby/2.5.0:/usr/local/lib/ruby/gems/2.5.0:/usr/local/bundle:$BUNDLE_PATH/ruby/2.5.0" # bash improvements for developer environment RUN git clone --depth=1 https://github.com/Bash-it/bash-it.git ~/.bash_it && \ diff --git a/lib/metis.rb b/lib/metis.rb index e313850c..df71e1bb 100644 --- a/lib/metis.rb +++ b/lib/metis.rb @@ -3,6 +3,7 @@ require 'fileutils' require_relative 'archiver' require_relative 'assimilation' +require_relative 'web_dav_resource' # This class handles the http request and routing class Metis diff --git a/lib/web_dav_resource.rb b/lib/web_dav_resource.rb new file mode 100644 index 00000000..55c00991 --- /dev/null +++ b/lib/web_dav_resource.rb @@ -0,0 +1,280 @@ +require 'webrick/httputils' +require 'dav4rack' +require 'forwardable' +require 'dav4rack/resource' + +class Metis + class WebDavResource < DAV4Rack::Resource + extend Forwardable + include WEBrick::HTTPUtils + include DAV4Rack::Utils + + attr_reader :inhabitant + def_delegators :inhabitant, :creation_date, :last_modified, :etag, :content_length, :directory? + + def inhabitant + if @user.instance_of?(Etna::User) + @inhabitant ||= DataResourceNode.descend(path, @user) + else + nil + end + end + + def application + @application ||= Etna::Application.instance + end + + def authenticate(uname, password) + begin + # payload, _ = application.sign.jwt_decode(password) + payload, _ = JSON.parse(Base64.strict_decode64(password)) + rescue + return false + end + + @user = request.env['etna.user'] = Etna::User.new(payload.map { |k, v| [k.to_sym, v] }.to_h, password) + true + end + + def root + @options[:root_uri_path] + end + + def get(request, response) + raise NotFound unless exist? + + unless directory? + # Files are actually sent via apache, which swaps the body out of a response based on this header. + response['X-Sendfile'] = inhabitant.file.data_block.location + end + + OK + end + + def children + return [] unless exist? + + inhabitant.children.map do |node| + child node.path_segment + end + end + + def exist? + !inhabitant.nil? + end + + def collection? + directory? + end + + def content_type + if directory? + "text/html" + else + mime_type(path, DefaultMimeTypes) + end + end + + protected + + def bucket_allowed?(bucket) + true + # bucket.allowed?(@user, @request.env['etna.hmac']) + end + + def project + @project ||= Project.first(project_name: project_name) + end + end + + class DataResourceNode + attr_reader :parent, :path_segment, :user + + def initialize(user, parent, segment) + @user = user + @parent = parent + @path_segment = segment + end + + def self.descend(path, user) + (path[1..-1].split('/')).inject(RootDirectoryResourceNode.new(user)) do |parent, next_child| + parent&.find_child(next_child) + end + end + + def find_child(segment) + children.find do |node| + node.path_segment == segment + end + end + + def children + [] + end + + def etag + nil + end + + def directory? + true + end + + def content_length + 0 + end + + def creation_date + Time.now + end + + def last_modified + Time.now + end + end + + class RootDirectoryResourceNode < DataResourceNode + def initialize(user) + super(user, nil, nil) + end + + def children + project_names = Metis::Bucket.distinct.select(:project_name).map(&:project_name) + listable_projects = project_names.select do |project_name| + user.is_admin?(project_name) || user.permissions[project_name] + end + + listable_projects.map { |project_name| ProjectResourceNode.new(user, self, project_name) } + end + end + + class ProjectResourceNode < DataResourceNode + def creation_date + first_created_bucket&.created_at || Time.now + end + + def last_modified + last_updated_bucket&.updated_at || Time.now + end + + def children + buckets = Metis::Bucket.where(project_name: path_segment).all + listable_buckets = buckets.select do |bucket| + bucket.allowed?(user, nil) + end + + listable_buckets.map do |bucket| + BucketResourceNode.new(user, self, bucket.name) + end + end + + def last_updated_bucket + @last_updated_bucket ||= Metis::Bucket.where(project_name: path_segment).order_by(:updated_at).last + end + + def first_created_bucket + @first_created_bucket ||= Metis::Bucket.where(project_name: path_segment).order_by(:created_at).first + end + end + + class BucketResourceNode < DataResourceNode + def creation_date + first_created_bucket.try(:created_at) || Time.now + end + + # Maybe should be last modified file? But there is no index, not great query. + def last_modified + last_updated_bucket.try(:updated_at) || Time.now + end + + def children + return [] if bucket.nil? + + folder_names = Metis::Folder.where(bucket: bucket, folder: nil).select(:folder_name).map(&:folder_name) + file_names = Metis::File.where(bucket: bucket, folder: nil).select(:file_name).map(&:file_name) + + folder_names.map { |name| FolderResourceNode.new(user, self, name, bucket) } + \ + file_names.map { |name| FileResourceNode.new(user, self, name, bucket) } + end + + def bucket + @bucket ||= Metis::Bucket.where(name: path_segment, project_name: parent.path_segment).first + end + + def path + "/" + end + end + + class FolderResourceNode < DataResourceNode + attr_reader :bucket, :path + + def initialize(user, parent, segment, bucket) + super(user, parent, segment) + @bucket = bucket + @path = parent.path + segment + "/" + end + + def creation_date + folder.try(:created_at) || Time.now + end + + def last_modified + folder.try(:updated_at) || Time.now + end + + def children + return [] if folder.nil? + + folder.folders.map { |f| FolderResourceNode.new(user, self, f.folder_name, bucket) } + \ + folder.files.map { |f| FileResourceNode.new(user, self, f.file_name, bucket) } + end + + def folder + @folder ||= Metis::Folder.from_path(bucket, path) + end + end + + class FileResourceNode < DataResourceNode + attr_reader :bucket, :path + + def initialize(user, parent, segment, bucket) + super(user, parent, segment) + @bucket = bucket + @path = parent.path + segment + end + + def file + @file ||= Metis::File.from_path(bucket, path) + end + + def creation_date + return Time.now if file.nil? + file.data_block.created_at + end + + def last_modified + return Time.now if file.nil? + file.data_block.updated_at + end + + def etag + return "" if file.nil? + "#{file.id}-#{file.data_block.md5_hash}" + end + + def content_length + return 0 if file.nil? + stat.size + end + + def stat + return nil if file.nil? + @stat ||= ::File.stat(file.data_block.location) + end + + def directory? + false + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 93dec80f..37d115c4 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -8,6 +8,9 @@ require 'bundler' Bundler.require(:default, :test) +require 'dav4rack' +require 'dav4rack/interceptor' + ENV['METIS_ENV'] = 'test' require_relative '../lib/metis' @@ -37,8 +40,18 @@ def build_rack_mock_session use Rack::Static, urls: ['/css', '/js', '/fonts', '/img'], root: 'lib/client' use Etna::ParseBody use Etna::SymbolizeParams + + map '/webdav/projects/' do + run DAV4Rack::Handler.new(:resource_class => Metis::WebDavResource, :root_uri_path => '/webdav/projects/') + end + + use DAV4Rack::Interceptor, :mappings => { + '/webdav/projects/' => {:resource_class => Metis::WebDavResource}, + } + use Etna::TestAuth use Metis::SetUid + run Metis::Server.new end @@ -336,11 +349,11 @@ def hmac_header(params={}) end end -def default_bucket(project_name) +def default_bucket(project_name, bucket_name: 'files') @default_bucket ||= {} @default_bucket[project_name] ||= begin - stubs.create_bucket(project_name, 'files') - create( :bucket, project_name: project_name, name: 'files', owner: 'metis', access: 'viewer') + stubs.create_bucket(project_name, bucket_name) + create( :bucket, project_name: project_name, name: bucket_name, owner: 'metis', access: 'viewer') end end diff --git a/spec/web_dav_resource_spec.rb b/spec/web_dav_resource_spec.rb new file mode 100644 index 00000000..13454191 --- /dev/null +++ b/spec/web_dav_resource_spec.rb @@ -0,0 +1,82 @@ +describe Metis::WebDavResource do + include Rack::Test::Methods + + def app + OUTER_APP + end + + after(:each) do + stubs.clear + end + + let(:project_name) { 'labors' } + let(:other_project_name) { 'sports' } + let(:bucket_name) { 'files' } + let(:other_bucket_name) { 'files' } + let!(:bucket) { default_bucket(project_name, bucket_name: bucket_name) } + let!(:other_bucket) { default_bucket(other_project_name, bucket_name: other_bucket_name) } + let!(:location) { stubs.create_file(project_name, bucket_name, file_name, contents) } + let(:contents) { "1. Burn the hydra's neck after cutting.\n2. Use a river to clean the stables." } + let(:file_name) { 'readme_hercules.txt' } + let(:file) { create_file(project_name, file_name, contents, bucket: bucket) } + let(:hmac_params) { {} } + let(:params) { {} } + let(:env) { {} } + let(:project_role) { :admin } + let(:other_project_role) { :viewer } + let(:permissions) { [[project_name, project_role], [other_project_name, other_project_role]] } + let(:user) { {email: 'zeus@olympus.org', first: 'Zeus', perm: permissions.map { |project, r| "#{r.to_s[0,1]}:#{project}" }.join(',')} } + let(:propfind_xml) do + <<-PROPFIND + + + + + PROPFIND + end + + def application + @application ||= Etna::Application.instance + end + + subject do + # token = application.sign.jwt_token(user) + token = Base64.strict_encode64(user.to_json) + auth = Base64.strict_encode64("user:#{token}") + header('Authorization', "Basic #{auth}") + + custom_request(method, path, params, env) + last_response + end + + def response_xml + @response_xml ||= Nokogiri.XML(last_response.body) { |config| config.strict } + end + + describe 'fetching projects' do + let(:path) { '/webdav/projects/' } + let(:method) { 'PROPFIND' } + let(:env) { {'HTTP_DEPTH' => '1', input: propfind_xml} } + + it 'does a thing' do + expect(subject.status).to eq(207) + response_xml.xpath('//d:multistatus/d:response').each do |response| + propstat = response.xpath('//d:propstat').first + expect(propstat.xpath('//d:status').first.text).to match(/200 OK/) + end + + response = response_xml.xpath('//d:multistatus/d:response').last + hrefs = response.xpath('//d:href').map(&:text).map { |href| URI.parse(href).path } + expect(hrefs).to eq([]) + end + end + + # it 'downloads a file' do + # hmac_header + # get('/labors/download/files/readme_hercules.txt') + # expect(last_response.status).to eq(200) + # # normally our web server should catch this header and replace the + # # contents; we can't do that with Rack::Test + # expect(last_response.headers['X-Sendfile']).to eq(@location) + # end +end From 00bb0b4ce2c7b916981e3da2f4779cdd3ba09058 Mon Sep 17 00:00:00 2001 From: Zachary Collins Date: Wed, 10 Jun 2020 15:08:36 -0700 Subject: [PATCH 11/17] propfind tests complete --- lib/web_dav_resource.rb | 16 ++-- spec/spec_helper.rb | 4 +- spec/web_dav_resource_spec.rb | 154 ++++++++++++++++++++++++++++------ 3 files changed, 137 insertions(+), 37 deletions(-) diff --git a/lib/web_dav_resource.rb b/lib/web_dav_resource.rb index 55c00991..7f8f3b47 100644 --- a/lib/web_dav_resource.rb +++ b/lib/web_dav_resource.rb @@ -179,19 +179,19 @@ def first_created_bucket class BucketResourceNode < DataResourceNode def creation_date - first_created_bucket.try(:created_at) || Time.now + bucket&.created_at || Time.now end # Maybe should be last modified file? But there is no index, not great query. def last_modified - last_updated_bucket.try(:updated_at) || Time.now + bucket&.updated_at || Time.now end def children return [] if bucket.nil? - folder_names = Metis::Folder.where(bucket: bucket, folder: nil).select(:folder_name).map(&:folder_name) - file_names = Metis::File.where(bucket: bucket, folder: nil).select(:file_name).map(&:file_name) + folder_names = Metis::Folder.where(bucket: bucket, folder_id: nil).select(:folder_name).map(&:folder_name) + file_names = Metis::File.where(bucket: bucket, folder_id: nil).select(:file_name).map(&:file_name) folder_names.map { |name| FolderResourceNode.new(user, self, name, bucket) } + \ file_names.map { |name| FileResourceNode.new(user, self, name, bucket) } @@ -202,7 +202,7 @@ def bucket end def path - "/" + "" end end @@ -216,11 +216,11 @@ def initialize(user, parent, segment, bucket) end def creation_date - folder.try(:created_at) || Time.now + folder&.created_at || Time.now end def last_modified - folder.try(:updated_at) || Time.now + folder&.updated_at || Time.now end def children @@ -231,7 +231,7 @@ def children end def folder - @folder ||= Metis::Folder.from_path(bucket, path) + @folder ||= Metis::Folder.from_path(bucket, path).last end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 37d115c4..5d2a66ef 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -349,11 +349,11 @@ def hmac_header(params={}) end end -def default_bucket(project_name, bucket_name: 'files') +def default_bucket(project_name, bucket_name: 'files', access: 'viewer') @default_bucket ||= {} @default_bucket[project_name] ||= begin stubs.create_bucket(project_name, bucket_name) - create( :bucket, project_name: project_name, name: bucket_name, owner: 'metis', access: 'viewer') + create( :bucket, project_name: project_name, name: bucket_name, owner: 'metis', access: access) end end diff --git a/spec/web_dav_resource_spec.rb b/spec/web_dav_resource_spec.rb index 13454191..855eeef8 100644 --- a/spec/web_dav_resource_spec.rb +++ b/spec/web_dav_resource_spec.rb @@ -13,19 +13,24 @@ def app let(:other_project_name) { 'sports' } let(:bucket_name) { 'files' } let(:other_bucket_name) { 'files' } - let!(:bucket) { default_bucket(project_name, bucket_name: bucket_name) } - let!(:other_bucket) { default_bucket(other_project_name, bucket_name: other_bucket_name) } - let!(:location) { stubs.create_file(project_name, bucket_name, file_name, contents) } - let(:contents) { "1. Burn the hydra's neck after cutting.\n2. Use a river to clean the stables." } - let(:file_name) { 'readme_hercules.txt' } - let(:file) { create_file(project_name, file_name, contents, bucket: bucket) } + let(:bucket_access) { 'viewer' } + let(:other_bucket_access) { 'viewer' } + let!(:bucket) { default_bucket(project_name, bucket_name: bucket_name, access: bucket_access) } + let!(:other_bucket) { default_bucket(other_project_name, bucket_name: other_bucket_name, access: other_bucket_access) } let(:hmac_params) { {} } let(:params) { {} } let(:env) { {} } let(:project_role) { :admin } let(:other_project_role) { :viewer } let(:permissions) { [[project_name, project_role], [other_project_name, other_project_role]] } - let(:user) { {email: 'zeus@olympus.org', first: 'Zeus', perm: permissions.map { |project, r| "#{r.to_s[0,1]}:#{project}" }.join(',')} } + let(:encoded_permissions) do + permissions.inject({}) do |projects_by_role, (proj_name, role)| + (projects_by_role[role] ||= []).push(proj_name) + projects_by_role + end.map { |role, projs| "#{role.to_s[0, 1]}:#{projs.join(',')}" }.join(';') + end + + let(:user) { {email: 'zeus@olympus.org', first: 'Zeus', perm: encoded_permissions} } let(:propfind_xml) do <<-PROPFIND @@ -39,7 +44,7 @@ def application @application ||= Etna::Application.instance end - subject do + let(:subject_request) do # token = application.sign.jwt_token(user) token = Base64.strict_encode64(user.to_json) auth = Base64.strict_encode64("user:#{token}") @@ -49,34 +54,129 @@ def application last_response end + let(:statuses) do + response_xml.xpath('//d:multistatus/d:response').map do |response| + propstat = response.xpath('//d:propstat').first + propstat.xpath('//d:status').first.text + end + end + + let(:hrefs) do + response = response_xml.xpath('//d:multistatus/d:response').last + response.xpath('//d:href').map(&:text).map { |href| URI.parse(href).path } + end + def response_xml @response_xml ||= Nokogiri.XML(last_response.body) { |config| config.strict } end - describe 'fetching projects' do - let(:path) { '/webdav/projects/' } + describe 'propfind' do let(:method) { 'PROPFIND' } let(:env) { {'HTTP_DEPTH' => '1', input: propfind_xml} } - it 'does a thing' do - expect(subject.status).to eq(207) - response_xml.xpath('//d:multistatus/d:response').each do |response| - propstat = response.xpath('//d:propstat').first - expect(propstat.xpath('//d:status').first.text).to match(/200 OK/) + subject do + subject_request + expect(last_response.status).to eq(207) + statuses.each { |s| expect(s).to match(/200 OK/) } + # Consistent ordering so that tests are less fragile. + hrefs.sort + end + + describe 'listing projects' do + let(:path) { '/webdav/projects/' } + + it { is_expected.to eq(%W[/webdav/projects/ /webdav/projects/#{project_name}/ /webdav/projects/#{other_project_name}/].sort) } + + context 'when missing access to a project' do + let(:permissions) { [[project_name, project_role]] } + + it { is_expected.to eq(%W[/webdav/projects/ /webdav/projects/#{project_name}/].sort) } + + context 'but the user is a super admin' do + let(:permissions) { [[:administration, :admin]] } + + it { is_expected.to eq(%W[/webdav/projects/ /webdav/projects/#{project_name}/ /webdav/projects/#{other_project_name}/].sort) } + end + end + end + + describe 'listing buckets' do + let(:path) { "/webdav/projects/#{other_project_name}/" } + + it { is_expected.to eq(%W[/webdav/projects/#{other_project_name}/ /webdav/projects/#{other_project_name}/#{other_bucket_name}/].sort) } + + context 'when role is less than bucket access level' do + let(:other_bucket_access) { 'editor' } + + it { is_expected.to eq(%W[/webdav/projects/#{other_project_name}/].sort) } + end + + context 'when the parent project is inaccessible' do + let(:permissions) { [] } + + it 'should fail to find the resource' do + subject_request + expect(last_response.status).to eq(404) + end + end + end + + describe 'listing folders and files' do + def directories_to_folder(directories, project_name, bucket) + directories.inject(nil) do |parent, segment| + create(:folder, folder: parent, folder_name: segment, project_name: project_name, bucket: bucket, author: 'someguy@example.org' ) + end end - response = response_xml.xpath('//d:multistatus/d:response').last - hrefs = response.xpath('//d:href').map(&:text).map { |href| URI.parse(href).path } - expect(hrefs).to eq([]) + let(:path) { "/webdav/projects/#{project_name}/#{bucket_name}/" } + + let(:directories) { [] } + let(:file_name) { 'abc.txt' } + let(:folder) { directories_to_folder(directories, project_name, bucket) } + let(:contents) { 'abcdefg' } + let!(:location) { stubs.create_file(project_name, bucket_name, file_name, contents) } + let!(:file) { create_file(project_name, file_name, contents, bucket: bucket, folder: folder) } + + let(:other_directories) { [] } + let(:other_file_name) { 'def.txt' } + let(:other_folder) { directories_to_folder(other_directories, other_project_name, other_bucket) } + let(:other_contents) { 'hijklmno' } + let!(:other_location) { stubs.create_file(other_project_name, other_bucket_name, other_file_name, other_contents) } + let!(:other_file) { create_file(project_name, other_file_name, other_contents, bucket: other_bucket, folder: other_folder) } + + it { is_expected.to eq(%W[/webdav/projects/#{project_name}/#{bucket_name}/ /webdav/projects/#{project_name}/#{bucket_name}/#{file_name}].sort) } + + context 'when multiple items exist together' do + let(:other_project_name) { project_name } + let(:other_bucket_name) { bucket_name } + let(:other_bucket) { bucket } + + it { is_expected.to eq(%W[/webdav/projects/#{project_name}/#{bucket_name}/ /webdav/projects/#{project_name}/#{bucket_name}/#{file_name} /webdav/projects/#{project_name}/#{bucket_name}/#{other_file_name}].sort) } + + context 'when some items are folders' do + let(:other_directories) { ['folder_1'] } + + it { is_expected.to eq(%W[/webdav/projects/#{project_name}/#{bucket_name}/ /webdav/projects/#{project_name}/#{bucket_name}/#{file_name} /webdav/projects/#{project_name}/#{bucket_name}/folder_1/].sort) } + end + end + + describe 'listing directories' do + let(:directories) { ['a', 'b'] } + + it { is_expected.to eq(%W[/webdav/projects/#{project_name}/#{bucket_name}/ /webdav/projects/#{project_name}/#{bucket_name}/#{directories.first}/].sort) } + + context 'inside of other directories' do + let(:path) { "/webdav/projects/#{project_name}/#{bucket_name}/#{directories.first}/" } + + it { is_expected.to eq(%W[/webdav/projects/#{project_name}/#{bucket_name}/#{directories.first}/ /webdav/projects/#{project_name}/#{bucket_name}/#{directories.first}/#{directories[1]}/].sort) } + + context 'containing files' do + let(:path) { "/webdav/projects/#{project_name}/#{bucket_name}/#{directories.first}/#{directories[1]}/" } + + it { is_expected.to eq(%W[/webdav/projects/#{project_name}/#{bucket_name}/#{directories.first}/#{directories[1]}/ /webdav/projects/#{project_name}/#{bucket_name}/#{directories.first}/#{directories[1]}/#{file_name}].sort) } + end + end + end end end - - # it 'downloads a file' do - # hmac_header - # get('/labors/download/files/readme_hercules.txt') - # expect(last_response.status).to eq(200) - # # normally our web server should catch this header and replace the - # # contents; we can't do that with Rack::Test - # expect(last_response.headers['X-Sendfile']).to eq(@location) - # end end From 107f0a3082c1db28aacf2441b3b19d422fc1aed6 Mon Sep 17 00:00:00 2001 From: Zachary Collins Date: Wed, 10 Jun 2020 15:41:04 -0700 Subject: [PATCH 12/17] Fixing initial migration setup --- .gitignore | 1 + Makefile | 14 +++++++------- config.yml.template | 2 +- docker/app/docker-entrypoint.sh | 5 +---- .../db/docker-entrypoint-initdb.d/init-user-db.sh | 3 +-- 5 files changed, 11 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index 20496abb..a42c980e 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ spec/examples.txt *.woff *.woff2 *.swp +.docker-build-mark diff --git a/Makefile b/Makefile index dcef9c3d..c14ae397 100644 --- a/Makefile +++ b/Makefile @@ -13,17 +13,17 @@ vendor/bundle: Gemfile Gemfile.lock docker/app/Dockerfile @ $(MAKE) bundle @ touch vendor/bundle -tmp/docker-build-mark: $(wildcard docker/**/*) docker-compose.yml +.docker-build-mark: $(wildcard docker/**/*) docker-compose.yml docker-compose rm -f metis_db docker-compose pull metis_db - docker-compose build metis_app - @ touch tmp/docker-build-mark + docker-compose build + @ touch .docker-build-mark config.yml: cp config.yml.template config.yml .PHONY: up -up: config.yml ## Starts up the database, worker, and webservers of metis in the background. +up: config.yml .docker-build-mark ## Starts up the database, worker, and webservers of metis in the background. @ docker-compose up -d .PHONY: down @@ -48,8 +48,8 @@ console: ## Starts an irb console inside of the metis app context. .PHONY: migrate migrate: ## Executes dev and test migrations inside of the metis app context. - @ docker-compose run --rm metis_app ./bin/metis migrate - @ docker-compose run -e METIS_ENV=test --rm metis_app ./bin/metis migrate + @ docker exec "$$(docker ps --format '{{.Names}}' | grep metis_app)" ./bin/metis migrate + @ docker exec -e METIS_ENV=test "$$(docker ps --format '{{.Names}}' | grep metis_app)" ./bin/metis migrate .PHONY: test test: ## Execute (all) rspec tests inside of the metis app context. @@ -65,7 +65,7 @@ db-port: ## Print the db port associated with the app. .PHONY: psql psql: ## Start a psql shell conntected to the metis development db - @ PGPASSWORD=password psql -h localhost -p $(DB_PORT) -U developer -d metis_development + @ docker exec -e PGPASSWORD=password "$$(docker ps --format '{{.Names}}' | grep metis_app)" psql -h metis_db -U developer -d metis_development .PHONY: logs logs: ## Follow logs of running containers diff --git a/config.yml.template b/config.yml.template index 8f28ba5d..024cef3c 100644 --- a/config.yml.template +++ b/config.yml.template @@ -18,7 +18,7 @@ :metis: haggis :db: :adapter: postgres - :host: db + :host: metis_db :database: metis_test :user: developer :password: password diff --git a/docker/app/docker-entrypoint.sh b/docker/app/docker-entrypoint.sh index b5fbe0fb..79caedd0 100755 --- a/docker/app/docker-entrypoint.sh +++ b/docker/app/docker-entrypoint.sh @@ -11,10 +11,7 @@ if [ -z "$SKIP_RUBY_SETUP" ]; then rm -f tmp/pids/*.pid if [ -z "$SKIP_DB_WAIT" ]; then dockerize -wait tcp://metis_db:5432 -timeout 60s - if ! [ -e tmp/.migrated ]; then - ./bin/metis migrate - touch tmp/.migrated - fi + ./bin/metis migrate fi else while ! bundle check >/dev/null 2>&1; do diff --git a/docker/db/docker-entrypoint-initdb.d/init-user-db.sh b/docker/db/docker-entrypoint-initdb.d/init-user-db.sh index 8ee11041..47014059 100755 --- a/docker/db/docker-entrypoint-initdb.d/init-user-db.sh +++ b/docker/db/docker-entrypoint-initdb.d/init-user-db.sh @@ -4,8 +4,7 @@ set -e # Add any other roles / databases here psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL - CREATE DATABASE IF NOT EXISTS metis_test; - CREATE DATABASE IF NOT EXISTS metis_development; + CREATE DATABASE metis_test; GRANT ALL PRIVILEGES ON DATABASE metis_test TO $POSTGRES_USER; GRANT ALL PRIVILEGES ON DATABASE metis_development TO $POSTGRES_USER; EOSQL From 0bccd29e94f2e8354e3cda8526d011de8738f0cf Mon Sep 17 00:00:00 2001 From: Zachary Collins Date: Wed, 10 Jun 2020 15:44:11 -0700 Subject: [PATCH 13/17] psql should be interactive --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index c14ae397..5e42cf38 100644 --- a/Makefile +++ b/Makefile @@ -65,7 +65,7 @@ db-port: ## Print the db port associated with the app. .PHONY: psql psql: ## Start a psql shell conntected to the metis development db - @ docker exec -e PGPASSWORD=password "$$(docker ps --format '{{.Names}}' | grep metis_app)" psql -h metis_db -U developer -d metis_development + @ docker exec -ti -e PGPASSWORD=password "$$(docker ps --format '{{.Names}}' | grep metis_app)" psql -h metis_db -U developer -d metis_development .PHONY: logs logs: ## Follow logs of running containers From 457b271c5db3be0c71291a12f1e02f6a68ce7189 Mon Sep 17 00:00:00 2001 From: Zachary Collins Date: Thu, 11 Jun 2020 15:45:46 -0700 Subject: [PATCH 14/17] GET specs --- spec/web_dav_resource_spec.rb | 107 +++++++++++++++++++++++++--------- 1 file changed, 78 insertions(+), 29 deletions(-) diff --git a/spec/web_dav_resource_spec.rb b/spec/web_dav_resource_spec.rb index 855eeef8..19317143 100644 --- a/spec/web_dav_resource_spec.rb +++ b/spec/web_dav_resource_spec.rb @@ -9,20 +9,15 @@ def app stubs.clear end - let(:project_name) { 'labors' } - let(:other_project_name) { 'sports' } - let(:bucket_name) { 'files' } - let(:other_bucket_name) { 'files' } - let(:bucket_access) { 'viewer' } - let(:other_bucket_access) { 'viewer' } - let!(:bucket) { default_bucket(project_name, bucket_name: bucket_name, access: bucket_access) } - let!(:other_bucket) { default_bucket(other_project_name, bucket_name: other_bucket_name, access: other_bucket_access) } - let(:hmac_params) { {} } + # Request configurations let(:params) { {} } let(:env) { {} } + + # User / role configurations let(:project_role) { :admin } let(:other_project_role) { :viewer } let(:permissions) { [[project_name, project_role], [other_project_name, other_project_role]] } + let(:encoded_permissions) do permissions.inject({}) do |projects_by_role, (proj_name, role)| (projects_by_role[role] ||= []).push(proj_name) @@ -31,6 +26,31 @@ def app end let(:user) { {email: 'zeus@olympus.org', first: 'Zeus', perm: encoded_permissions} } + + # File configurations + let(:project_name) { 'labors' } + let(:other_project_name) { 'sports' } + let(:bucket_name) { 'files' } + let(:other_bucket_name) { 'files' } + let(:bucket_access) { 'viewer' } + let(:other_bucket_access) { 'viewer' } + let!(:bucket) { default_bucket(project_name, bucket_name: bucket_name, access: bucket_access) } + let!(:other_bucket) { default_bucket(other_project_name, bucket_name: other_bucket_name, access: other_bucket_access) } + let(:directories) { [] } + let(:file_name) { 'abc.txt' } + let(:folder) { directories_to_folder(directories, project_name, bucket) } + let(:contents) { 'abcdefg' } + let!(:location) { stubs.create_file(project_name, bucket_name, file_name, contents) } + let!(:file) { create_file(project_name, file_name, contents, bucket: bucket, folder: folder) } + + let(:other_directories) { [] } + let(:other_file_name) { 'def.txt' } + let(:other_folder) { directories_to_folder(other_directories, other_project_name, other_bucket) } + let(:other_contents) { 'hijklmno' } + let!(:other_location) { stubs.create_file(other_project_name, other_bucket_name, other_file_name, other_contents) } + let!(:other_file) { create_file(project_name, other_file_name, other_contents, bucket: other_bucket, folder: other_folder) } + + # Utility values let(:propfind_xml) do <<-PROPFIND @@ -69,6 +89,46 @@ def application def response_xml @response_xml ||= Nokogiri.XML(last_response.body) { |config| config.strict } end + + def directories_to_folder(directories, project_name, bucket) + directories.inject(nil) do |parent, segment| + create(:folder, folder: parent, folder_name: segment, project_name: project_name, bucket: bucket, author: 'someguy@example.org') + end + end + + describe 'get' do + let(:method) { 'GET' } + + subject do + subject_request + expect(last_response.status).to eq(200) + last_response.headers['X-Sendfile'] + end + + describe 'for top level' do + let(:path) { '/webdav/projects/' } + + it { is_expected.to be_nil } + end + + describe 'for a project' do + let(:path) { "/webdav/projects/#{project_name}/" } + + it { is_expected.to be_nil } + end + + describe 'for a bucket' do + let(:path) { "/webdav/projects/#{project_name}/#{bucket_name}/" } + + it { is_expected.to be_nil } + end + + describe 'for a file' do + let(:path) { "/webdav/projects/#{project_name}/#{bucket_name}/#{file_name}" } + + it { is_expected.to eq(location) } + end + end describe 'propfind' do let(:method) { 'PROPFIND' } @@ -122,28 +182,8 @@ def response_xml end describe 'listing folders and files' do - def directories_to_folder(directories, project_name, bucket) - directories.inject(nil) do |parent, segment| - create(:folder, folder: parent, folder_name: segment, project_name: project_name, bucket: bucket, author: 'someguy@example.org' ) - end - end - let(:path) { "/webdav/projects/#{project_name}/#{bucket_name}/" } - let(:directories) { [] } - let(:file_name) { 'abc.txt' } - let(:folder) { directories_to_folder(directories, project_name, bucket) } - let(:contents) { 'abcdefg' } - let!(:location) { stubs.create_file(project_name, bucket_name, file_name, contents) } - let!(:file) { create_file(project_name, file_name, contents, bucket: bucket, folder: folder) } - - let(:other_directories) { [] } - let(:other_file_name) { 'def.txt' } - let(:other_folder) { directories_to_folder(other_directories, other_project_name, other_bucket) } - let(:other_contents) { 'hijklmno' } - let!(:other_location) { stubs.create_file(other_project_name, other_bucket_name, other_file_name, other_contents) } - let!(:other_file) { create_file(project_name, other_file_name, other_contents, bucket: other_bucket, folder: other_folder) } - it { is_expected.to eq(%W[/webdav/projects/#{project_name}/#{bucket_name}/ /webdav/projects/#{project_name}/#{bucket_name}/#{file_name}].sort) } context 'when multiple items exist together' do @@ -174,6 +214,15 @@ def directories_to_folder(directories, project_name, bucket) let(:path) { "/webdav/projects/#{project_name}/#{bucket_name}/#{directories.first}/#{directories[1]}/" } it { is_expected.to eq(%W[/webdav/projects/#{project_name}/#{bucket_name}/#{directories.first}/#{directories[1]}/ /webdav/projects/#{project_name}/#{bucket_name}/#{directories.first}/#{directories[1]}/#{file_name}].sort) } + + context 'without permissions' do + let(:permissions) { [] } + + it 'should fail to find the resource' do + subject_request + expect(last_response.status).to eq(404) + end + end end end end From 135d9ce9526aad4ca117438533db426ba3968924 Mon Sep 17 00:00:00 2001 From: Zachary Collins Date: Fri, 12 Jun 2020 12:58:51 -0700 Subject: [PATCH 15/17] PUT spec for web dav --- lib/web_dav_resource.rb | 104 +++++++++++++++++++++++++++++++++- spec/web_dav_resource_spec.rb | 63 +++++++++++++++++++- 2 files changed, 164 insertions(+), 3 deletions(-) diff --git a/lib/web_dav_resource.rb b/lib/web_dav_resource.rb index 7f8f3b47..8ae22885 100644 --- a/lib/web_dav_resource.rb +++ b/lib/web_dav_resource.rb @@ -51,6 +51,23 @@ def get(request, response) OK end + def put(request, response) + raise Forbidden unless is_writable? + + io = request.body + # tempfile = "tmp/upload.#{Process.pid}.#{object_id}" + tempfile = Tempfile.new + open(tempfile, "wb") do |file| + while part = io.read(8192) + file << part + end + end + + raise Forbidden unless inhabitant.upload!(tempfile) + + OK + end + def children return [] unless exist? @@ -59,8 +76,12 @@ def children end end + def is_writable? + !inhabitant.nil? && inhabitant.is_writable? + end + def exist? - !inhabitant.nil? + !inhabitant.nil? && inhabitant.exist? end def collection? @@ -105,7 +126,15 @@ def self.descend(path, user) def find_child(segment) children.find do |node| node.path_segment == segment - end + end || writable_edge_node(segment) + end + + def writable_edge_node(segment) + nil + end + + def is_writable? + false end def children @@ -131,6 +160,10 @@ def creation_date def last_modified Time.now end + + def exist? + true + end end class RootDirectoryResourceNode < DataResourceNode @@ -197,6 +230,10 @@ def children file_names.map { |name| FileResourceNode.new(user, self, name, bucket) } end + def writable_edge_node(segment) + WritableEdgeNode.new(user, self, segment, bucket) + end + def bucket @bucket ||= Metis::Bucket.where(name: path_segment, project_name: parent.path_segment).first end @@ -233,6 +270,10 @@ def children def folder @folder ||= Metis::Folder.from_path(bucket, path).last end + + def writable_edge_node(segment) + WritableEdgeNode.new(user, self, segment, bucket) + end end class FileResourceNode < DataResourceNode @@ -276,5 +317,64 @@ def stat def directory? false end + + def is_writable? + true + end + + def upload!(uploaded_file) + # Most of this is copied from a combination of upload_controller and etna_controller. + # Ideally this would be captured in a service class and shareable. + blob = Metis::Blob.new(tempfile: uploaded_file) + + upload = Metis::Upload.find_or_create( + file_name: path, + bucket: bucket, + metis_uid: metis_uid, + project_name: bucket.project_name + ) do |f| + f.author = Metis::File.author(user) + f.file_size = 0 + f.current_byte_position = 0 + f.next_blob_size = ::File.size(blob.path) + f.next_blob_hash = Metis::File.md5(blob.path) + end + + upload.append_blob(blob, 0, '') + + folder_path, file_name = Metis::File.path_parts(upload.file_name) + folder = Metis::Folder.from_path(bucket, folder_path).last + + file = Metis::File.from_folder(bucket, folder, file_name) + + if file && file.read_only? + return false + end + + if Metis::Folder.exists?(file_name, upload.bucket, folder) + return false + end + + upload.finish! + true + end + + def metis_uid + Metis.instance.sign.uid + end + end + + class WritableEdgeNode < FileResourceNode + def file + nil + end + + def exist? + false + end + + def is_writable? + true + end end end diff --git a/spec/web_dav_resource_spec.rb b/spec/web_dav_resource_spec.rb index 19317143..e2995395 100644 --- a/spec/web_dav_resource_spec.rb +++ b/spec/web_dav_resource_spec.rb @@ -41,7 +41,8 @@ def app let(:folder) { directories_to_folder(directories, project_name, bucket) } let(:contents) { 'abcdefg' } let!(:location) { stubs.create_file(project_name, bucket_name, file_name, contents) } - let!(:file) { create_file(project_name, file_name, contents, bucket: bucket, folder: folder) } + let(:read_only) { false } + let!(:file) { create_file(project_name, file_name, contents, bucket: bucket, folder: folder, read_only: read_only) } let(:other_directories) { [] } let(:other_file_name) { 'def.txt' } @@ -96,9 +97,69 @@ def directories_to_folder(directories, project_name, bucket) end end + describe 'put' do + let(:method) { 'PUT' } + let(:path) { "/webdav/projects/#{project_name}/#{bucket_name}/#{put_file_name}" } + let(:put_file_name) { 'my_new_file' } + let(:put_file_contents) { 'somebody once told me the world is kinda baloney' } + let(:env) { { input: put_file_contents } } + + subject do + subject_request + expect(last_response.status).to eq(200) + ::File.read(Metis::File.from_path(bucket, "#{put_file_name}").data_block.location) + end + + it { is_expected.to eq(put_file_contents) } + + context 'for a file in the root path' do + let(:path) { "/webdav/#{put_file_name}"} + + it 'is not allowed' do + subject_request + expect(last_response.status).to eq(401) + end + end + + context 'for a file in a project path' do + let(:path) { "/webdav/#{project_name}/#{put_file_name}"} + + it 'is not allowed' do + subject_request + expect(last_response.status).to eq(401) + end + end + + context 'for a directory' do + let(:directories) { [put_file_name] } + + it 'is not allowed' do + subject_request + expect(last_response.status).to eq(403) + end + end + + context 'for a read only file' do + let(:file_name) { put_file_name } + let(:read_only) { true } + + it 'is not allowed' do + subject_request + expect(last_response.status).to eq(403) + end + end + + context 'for an existing file' do + let(:file_name) { put_file_name } + + it { is_expected.to eq(put_file_contents) } + end + end + describe 'get' do let(:method) { 'GET' } + subject do subject_request expect(last_response.status).to eq(200) From 9f012026e9975be26a2517db91ba621d0c84a8e9 Mon Sep 17 00:00:00 2001 From: Zachary Collins Date: Fri, 12 Jun 2020 14:34:46 -0700 Subject: [PATCH 16/17] mkcol implementation --- lib/web_dav_resource.rb | 40 +++++++++++++++++++----- spec/web_dav_resource_spec.rb | 57 +++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 7 deletions(-) diff --git a/lib/web_dav_resource.rb b/lib/web_dav_resource.rb index 8ae22885..75e82a27 100644 --- a/lib/web_dav_resource.rb +++ b/lib/web_dav_resource.rb @@ -40,6 +40,14 @@ def root @options[:root_uri_path] end + def make_collection + raise NotFound if inhabitant.nil? + raise Forbidden unless is_writable?(:directory) + raise Conflict unless inhabitant.mkdir! + + Created + end + def get(request, response) raise NotFound unless exist? @@ -52,7 +60,8 @@ def get(request, response) end def put(request, response) - raise Forbidden unless is_writable? + raise NotFound if inhabitant.nil? + raise Forbidden unless is_writable?(:file) io = request.body # tempfile = "tmp/upload.#{Process.pid}.#{object_id}" @@ -76,8 +85,8 @@ def children end end - def is_writable? - !inhabitant.nil? && inhabitant.is_writable? + def is_writable?(type) + !inhabitant.nil? && inhabitant.is_writable?(type) end def exist? @@ -133,7 +142,11 @@ def writable_edge_node(segment) nil end - def is_writable? + def is_writable?(type) + false + end + + def mkdir! false end @@ -318,8 +331,8 @@ def directory? false end - def is_writable? - true + def is_writable?(type) + type == :file end def upload!(uploaded_file) @@ -373,7 +386,20 @@ def exist? false end - def is_writable? + def is_writable?(type) + true + end + + def mkdir! + Metis::Folder.create( + folder: parent.respond_to?(:folder) ? parent.folder : nil, + folder_name: path_segment, + bucket: bucket, + project_name: bucket.project_name, + read_only: false, + author: Metis::File.author(user), + ) + true end end diff --git a/spec/web_dav_resource_spec.rb b/spec/web_dav_resource_spec.rb index e2995395..133f53f1 100644 --- a/spec/web_dav_resource_spec.rb +++ b/spec/web_dav_resource_spec.rb @@ -97,6 +97,63 @@ def directories_to_folder(directories, project_name, bucket) end end + describe 'mkcol' do + let(:method) { 'MKCOL' } + let(:path) { "/webdav/projects/#{project_name}/#{bucket_name}/#{mkcol_dir}" } + let(:mkcol_dir) { 'my_new_dir' } + + subject do + subject_request + expect(last_response.status).to eq(201) + Metis::Folder.from_path(bucket, mkcol_dir + "/").last&.folder_path + end + + it { is_expected.to eq ["#{mkcol_dir}"] } + + context 'for a folder in the root path' do + let(:path) { "/webdav/#{mkcol_dir}/"} + + it 'is not allowed' do + subject_request + expect(last_response.status).to eq(403) + end + end + + context 'for a folder in a project path' do + let(:path) { "/webdav/#{project_name}/#{mkcol_dir}/"} + + it 'is not allowed' do + subject_request + expect(last_response.status).to eq(403) + end + end + + context 'for an existing directory' do + let(:directories) { ["abc"] } + let(:mkcol_dir) { directories.first } + + it 'is not allowed' do + subject_request + expect(last_response.status).to eq(405) + end + + context 'as the child of it' do + let(:mkcol_dir) { "#{directories.first}/def" } + + it { is_expected.to eq [directories.first, "def"] } + end + end + + context 'for an existing file' do + let(:file_name) { mkcol_dir } + + it 'is not allowed' do + subject_request + expect(last_response.status).to eq(405) + end + end + end + describe 'put' do let(:method) { 'PUT' } let(:path) { "/webdav/projects/#{project_name}/#{bucket_name}/#{put_file_name}" } From 249c41cd9d07224041fdc862bda57ea3f41b61e9 Mon Sep 17 00:00:00 2001 From: Zachary Collins Date: Fri, 12 Jun 2020 16:36:22 -0700 Subject: [PATCH 17/17] Adding delete, move, copy --- lib/web_dav_resource.rb | 96 ++++++++++++++++++++++++++++++++++- spec/web_dav_resource_spec.rb | 75 ++++++++++++++++++++++++--- 2 files changed, 161 insertions(+), 10 deletions(-) diff --git a/lib/web_dav_resource.rb b/lib/web_dav_resource.rb index 75e82a27..0e043617 100644 --- a/lib/web_dav_resource.rb +++ b/lib/web_dav_resource.rb @@ -64,7 +64,6 @@ def put(request, response) raise Forbidden unless is_writable?(:file) io = request.body - # tempfile = "tmp/upload.#{Process.pid}.#{object_id}" tempfile = Tempfile.new open(tempfile, "wb") do |file| while part = io.read(8192) @@ -77,6 +76,21 @@ def put(request, response) OK end + def delete + raise NotFound unless exist? + raise Forbidden unless inhabitant.delete! + + NoContent + end + + def copy(dest_path, overwrite = false, depth = nil) + do_copy(dest_path, overwrite, depth, false) + end + + def move(dest_path, overwrite=false) + do_copy(dest_path, overwrite, nil, true) + end + def children return [] unless exist? @@ -107,6 +121,26 @@ def content_type protected + # For now, depth is ignored. + def do_copy(dest_path, overwrite, depth, is_move) + raise NotFound unless exist? + raise Forbidden unless inhabitant.copyable? + + dest = DataResourceNode.descend(dest_path, user) + raise PreconditionFailed if dest.exist? && !overwrite + + Metis.instance.db.transaction do + if dest.exist? + raise Conflict unless dest.delete! + end + + raise NotFound unless inhabitant.copy!(dest) + inhabitant.delete! if is_move + end + + NoContent + end + def bucket_allowed?(bucket) true # bucket.allowed?(@user, @request.env['etna.hmac']) @@ -132,6 +166,10 @@ def self.descend(path, user) end end + def parent_folder + parent.respond_to?(:folder) ? parent.folder : nil + end + def find_child(segment) children.find do |node| node.path_segment == segment @@ -142,6 +180,18 @@ def writable_edge_node(segment) nil end + def copyable? + false + end + + def copy!(dest) + false + end + + def delete! + false + end + def is_writable?(type) false end @@ -177,6 +227,10 @@ def last_modified def exist? true end + + def bucket + nil + end end class RootDirectoryResourceNode < DataResourceNode @@ -273,6 +327,26 @@ def last_modified folder&.updated_at || Time.now end + def copyable? + true + end + + def copy!(dest) + return false unless dest.bucket + Metis::Folder.find_or_create(folder_id: dest.parent_folder&.id, folder_name: dest.path_segment, bucket_id: dest.bucket.id, project_name: dest.bucket.project_name) do |f| + f.author = folder.author + end + true + end + + def delete! + Metis.instance.db.transaction do + Metis::File.where(folder: folder).delete + Metis::Folder.where(folder: folder).delete + folder.remove! + end + end + def children return [] if folder.nil? @@ -322,6 +396,24 @@ def content_length stat.size end + def copyable? + true + end + + def copy!(dest) + return false unless dest.bucket + Metis::File.find_or_create(folder_id: dest.parent_folder&.id, file_name: dest.path_segment, bucket_id: dest.bucket.id, project_name: dest.bucket.project_name) do |f| + f.author = file.author + f.data_block = file.data_block + end + true + end + + def delete! + file.remove! + true + end + def stat return nil if file.nil? @stat ||= ::File.stat(file.data_block.location) @@ -392,7 +484,7 @@ def is_writable?(type) def mkdir! Metis::Folder.create( - folder: parent.respond_to?(:folder) ? parent.folder : nil, + folder: parent_folder, folder_name: path_segment, bucket: bucket, project_name: bucket.project_name, diff --git a/spec/web_dav_resource_spec.rb b/spec/web_dav_resource_spec.rb index 133f53f1..ca133b5f 100644 --- a/spec/web_dav_resource_spec.rb +++ b/spec/web_dav_resource_spec.rb @@ -90,13 +90,72 @@ def application def response_xml @response_xml ||= Nokogiri.XML(last_response.body) { |config| config.strict } end - + def directories_to_folder(directories, project_name, bucket) directories.inject(nil) do |parent, segment| create(:folder, folder: parent, folder_name: segment, project_name: project_name, bucket: bucket, author: 'someguy@example.org') end end + ['copy', 'move'].each do |verb| + describe verb do + let(:method) { verb.upcase } + let(:source_path) { file_name } + let(:destination_path) { "new_thing" } + let(:destination_bucket_name) { destination_bucket.name } + let(:destination_project_name) { project_name } + let(:destination_bucket) { bucket } + let(:overwrite) { true } + let(:path) { "/webdav/projects/#{project_name}/#{bucket_name}/#{source_path}" } + let(:env) do + { + 'HTTP_OVERWRITE' => overwrite ? 'T' : 'F', + 'HTTP_DESTINATION' => "#{METIS_URL}/webdav/projects/#{destination_project_name}/#{destination_bucket_name}/#{destination_path}", + } + end + + subject do + subject_request + expect(last_response.status).to eq(204) + + { + sf_remains: !Metis::File.from_path(bucket, source_path).nil?, + sd_remains: !Metis::Folder.from_path(bucket, source_path).last.nil?, + dd_exists: !Metis::Folder.from_path(destination_bucket, destination_path).last.nil?, + df_exists: !Metis::File.from_path(destination_bucket, destination_path).nil?, + } + end + + it { is_expected.to eq({dd_exists: false, df_exists: true, sd_remains: false, sf_remains: verb == 'copy', }) } + + describe 'from a folder' do + let(:directories) { ['adirectory'] } + let(:source_path) { directories.first } + + it { is_expected.to eq({dd_exists: true, df_exists: false, sf_remains: false, sd_remains: verb == 'copy', }) } + end + + describe 'for non existent source' do + let(:source_path) { "abc" } + + it 'is not allowed' do + subject_request + expect(last_response.status).to eq(404) + end + end + + describe 'for a project dir' do + let(:path) { "/webdav/projects/#{project_name}/" } + + it 'is not allowed' do + subject_request + expect(last_response.status).to eq(403) + end + end + end + end + + describe 'mkcol' do let(:method) { 'MKCOL' } let(:path) { "/webdav/projects/#{project_name}/#{bucket_name}/#{mkcol_dir}" } @@ -108,10 +167,10 @@ def directories_to_folder(directories, project_name, bucket) Metis::Folder.from_path(bucket, mkcol_dir + "/").last&.folder_path end - it { is_expected.to eq ["#{mkcol_dir}"] } + it { is_expected.to eq [mkcol_dir] } context 'for a folder in the root path' do - let(:path) { "/webdav/#{mkcol_dir}/"} + let(:path) { "/webdav/#{mkcol_dir}/" } it 'is not allowed' do subject_request @@ -120,7 +179,7 @@ def directories_to_folder(directories, project_name, bucket) end context 'for a folder in a project path' do - let(:path) { "/webdav/#{project_name}/#{mkcol_dir}/"} + let(:path) { "/webdav/#{project_name}/#{mkcol_dir}/" } it 'is not allowed' do subject_request @@ -140,7 +199,7 @@ def directories_to_folder(directories, project_name, bucket) context 'as the child of it' do let(:mkcol_dir) { "#{directories.first}/def" } - it { is_expected.to eq [directories.first, "def"] } + it { is_expected.to eq [directories.first, "def"] } end end @@ -159,7 +218,7 @@ def directories_to_folder(directories, project_name, bucket) let(:path) { "/webdav/projects/#{project_name}/#{bucket_name}/#{put_file_name}" } let(:put_file_name) { 'my_new_file' } let(:put_file_contents) { 'somebody once told me the world is kinda baloney' } - let(:env) { { input: put_file_contents } } + let(:env) { {input: put_file_contents} } subject do subject_request @@ -170,7 +229,7 @@ def directories_to_folder(directories, project_name, bucket) it { is_expected.to eq(put_file_contents) } context 'for a file in the root path' do - let(:path) { "/webdav/#{put_file_name}"} + let(:path) { "/webdav/#{put_file_name}" } it 'is not allowed' do subject_request @@ -179,7 +238,7 @@ def directories_to_folder(directories, project_name, bucket) end context 'for a file in a project path' do - let(:path) { "/webdav/#{project_name}/#{put_file_name}"} + let(:path) { "/webdav/#{project_name}/#{put_file_name}" } it 'is not allowed' do subject_request