export legacy files to inst-fs on-demand

fixes SAS-1443

requires opt-in (ramping up rate in plugin setting, defaults to 0). will
initially opt in on sandbox accounts, then trial accounts, then a
gradual roll out to avoid hammering the service.

when opted in (100%), asking for a redirect target for a file that's not
yet in inst-fs will first try to add it to Inst-FS by posting the
metadata only (a nominally quick transaction). Inst-FS can use this to
create a "foreign" reference to the existing object in canvas' s3 bucket
(which inst-fs should be given access to), at which point it can
immediately start serving the object.

opting in at a level above 0% but below 100% will have the chosen chance
of doing the above per-access.

if successful, Inst-FS returns the new instfs_uuid with which the
attachment is updated, then the redirect continues to inst-fs instead of
s3. if unsuccessful, opted-out, or skipped due to opt-in threshold, the
existing s3 object is redirected to as before.

test-plan:
  (setup)
  - have inst-fs integrated locally
  - be using an s3 backing for non-instfs files
  - give inst-fs access to that s3 backing

  (testing)
  - turn off the inst-fs plugin setting
  - upload a file
  - turn on the inst-fs plugin setting, but leave the migrating flag
    unset
  - access the previously uploaded file, should be served from s3
  - turn on the migrating flag in the plugin setting
  - access the previously uploaded file, should be served (or at least
    attempted, if you haven't allowed inst-fs access to the canvas s3
    bucket) from inst-fs

Change-Id: I2b909d9b3f1c950e5a88f2af6ec7b40c05e71bb6
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/234167
Reviewed-by: Michael Jasper <mjasper@instructure.com>
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
QA-Review: Jacob Fugal <jacob@instructure.com>
Product-Review: Jacob Fugal <jacob@instructure.com>
This commit is contained in:
Jacob Fugal 2020-03-27 14:31:27 -06:00
parent eca9192e2e
commit 40908c8c9d
6 changed files with 156 additions and 18 deletions

View File

@ -512,6 +512,14 @@ class Attachment < ActiveRecord::Base
end end
end end
def root_account
begin
root_account_id && Account.find_cached(root_account_id)
rescue ::Canvas::AccountCacheError
nil
end
end
def namespace def namespace
read_attribute(:namespace) || (new_record? ? write_attribute(:namespace, infer_namespace) : nil) read_attribute(:namespace) || (new_record? ? write_attribute(:namespace, infer_namespace) : nil)
end end
@ -836,11 +844,7 @@ class Attachment < ActiveRecord::Base
end end
def url_ttl def url_ttl
settings = begin setting = root_account&.settings&.[](:s3_url_ttl_seconds)
root_account_id && Account.find_cached(root_account_id).settings
rescue ::Canvas::AccountCacheError
end
setting = settings&.[](:s3_url_ttl_seconds)
setting ||= Setting.get('attachment_url_ttl', 1.hour.to_s) setting ||= Setting.get('attachment_url_ttl', 1.hour.to_s)
setting.to_i.seconds setting.to_i.seconds
end end

View File

@ -17,4 +17,20 @@
%> %>
<%= fields_for :settings, OpenObject.new(settings) do |f| %> <%= fields_for :settings, OpenObject.new(settings) do |f| %>
<table style="width: 500px;" class="formtable">
<tr>
<td colspan="2">
<p><%= t(:description, <<-TEXT)
Hosts uploaded files with the Inst-FS service.
TEXT
%></p>
</td>
</tr>
<tr>
<td><%= f.blabel :migration_rate, :en => "Chance of migrating older files into Inst-FS on access (0 to 100)" %></td>
<td>
<%= f.text_field :migration_rate %>
</td>
</tr>
</table>
<% end %> <% end %>

View File

@ -405,8 +405,11 @@ Canvas::Plugin.register('inst_fs', nil, {
:author => 'Instructure', :author => 'Instructure',
:author_website => 'http://www.instructure.com', :author_website => 'http://www.instructure.com',
:version => '0.0.1', :version => '0.0.1',
:settings => nil, :settings => {
:settings_partial => 'plugins/inst_fs_settings' :migration_rate => 0,
},
:settings_partial => 'plugins/inst_fs_settings',
:validator => 'InstFsValidator'
}) })
Canvas::Plugin.register('unsplash', nil, { Canvas::Plugin.register('unsplash', nil, {
name: lambda{ t :name, 'Unsplash' }, name: lambda{ t :name, 'Unsplash' },

View File

@ -0,0 +1,33 @@
#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
module Canvas::Plugins::Validators::InstFsValidator
def self.validate(settings, plugin_setting)
if settings[:migration_rate].blank?
migration_rate = 0
else
migration_rate = settings[:migration_rate].to_f rescue nil
end
if migration_rate.nil? || migration_rate < 0 || migration_rate > 100
plugin_setting.errors.add(:base, I18n.t('Please enter a number between 0 and 100 for the migration rate'))
return false
end
settings[:migration] = migration_rate
settings.slice(:migration_rate).to_h.with_indifferent_access
end
end

View File

@ -51,19 +51,9 @@ class FileAuthenticator
Digest::MD5.hexdigest("#{@user&.global_id}|#{@acting_as&.global_id}|#{@oauth_host}") Digest::MD5.hexdigest("#{@user&.global_id}|#{@acting_as&.global_id}|#{@oauth_host}")
end end
def instfs_options(attachment, extras={})
{
user: @user,
acting_as: @acting_as,
access_token: @access_token,
root_account: @root_account,
oauth_host: @oauth_host,
expires_in: attachment.url_ttl,
}.merge(extras)
end
def download_url(attachment) def download_url(attachment)
return nil unless attachment return nil unless attachment
migrate_legacy_attachment_to_instfs(attachment)
if attachment.instfs_hosted? if attachment.instfs_hosted?
options = instfs_options(attachment, download: true) options = instfs_options(attachment, download: true)
InstFS.authenticated_url(attachment, options) InstFS.authenticated_url(attachment, options)
@ -75,6 +65,7 @@ class FileAuthenticator
def inline_url(attachment) def inline_url(attachment)
return nil unless attachment return nil unless attachment
migrate_legacy_attachment_to_instfs(attachment)
if attachment.instfs_hosted? if attachment.instfs_hosted?
options = instfs_options(attachment, download: false) options = instfs_options(attachment, download: false)
InstFS.authenticated_url(attachment, options) InstFS.authenticated_url(attachment, options)
@ -93,4 +84,32 @@ class FileAuthenticator
attachment.thumbnail_url(options) attachment.thumbnail_url(options)
end end
end end
private
def migrate_legacy_attachment_to_instfs(attachment)
# on-demand migration to inst-fs (if necessary and opted into). this is a
# server to server communication during the request (just before redirect,
# typically), but it's only a POST of the metadata to inst-fs, so should be
# quick, which we enforce with a timeout; inst-fs will "naturalize" the
# object contents later out-of-band
return unless InstFS.migrate_attachment?(attachment)
attachment.instfs_uuid = InstFS.export_reference(attachment)
attachment.save!
rescue InstFS::ExportReferenceError
# in the case of a recognized error exporting reference, just ignore,
# acting as if we didn't intend to migrate in the first place, rather than
# interrupting what could be a successful redirect to s3
end
def instfs_options(attachment, extras={})
{
user: @user,
acting_as: @acting_as,
access_token: @access_token,
root_account: @root_account,
oauth_host: @oauth_host,
expires_in: attachment.url_ttl,
}.merge(extras)
end
end end

View File

@ -22,6 +22,14 @@ module InstFS
Canvas::Plugin.find('inst_fs').enabled? && !!app_host && !!jwt_secret Canvas::Plugin.find('inst_fs').enabled? && !!app_host && !!jwt_secret
end end
def check_migration_rate?
rand < Canvas::Plugin.find('inst_fs').settings[:migration_rate].to_f / 100.0
end
def migrate_attachment?(attachment)
enabled? && !attachment.instfs_hosted? && Attachment.s3_storage? && check_migration_rate?
end
def login_pixel(user, session, oauth_host) def login_pixel(user, session, oauth_host)
return if session[:oauth2] # don't stomp an existing oauth flow in progress return if session[:oauth2] # don't stomp an existing oauth flow in progress
return if session[:pending_otp] return if session[:pending_otp]
@ -153,6 +161,60 @@ module InstFS
raise InstFS::DirectUploadError, "received code \"#{response.code}\" from service, with message \"#{response.body}\"" raise InstFS::DirectUploadError, "received code \"#{response.code}\" from service, with message \"#{response.body}\""
end end
def export_reference(attachment)
raise InstFS::ExportReferenceError, "attachment already has instfs_uuid" if attachment.instfs_hosted?
raise InstFS::ExportReferenceError, "can't export non-s3 attachments to inst-fs" unless Attachment.s3_storage?
# compare to s3_bucket_url in the aws-sdk-s3 gem's
# lib/aws-sdk-s3/customizations/bucket.rb; we're leaving out the bucket
# name from the url. otherwise, this is effectively
# `attachment.bucket.url`
s3_client = attachment.bucket.client
s3_url = s3_client.config.endpoint.dup
if s3_client.config.region == 'us-east-1' &&
s3_client.config.s3_us_east_1_regional_endpoint == 'legacy'
s3_url.host = Aws::S3::Plugins::IADRegionalEndpoint.legacy_host(s3_url.host)
end
body = {
objectStore: {
type: "s3",
params: {
host: s3_url.to_s,
bucket: attachment.bucket.name
}
},
# single reference
references: [{
storeKey: attachment.full_filename,
timestamp: attachment.created_at.to_i,
filename: attachment.filename,
displayName: attachment.display_name,
content_type: attachment.content_type,
encoding: attachment.encoding,
size: attachment.size,
user_id: attachment.context_user&.global_id&.to_s,
root_account_id: attachment.root_account&.global_id&.to_s,
sha512: nil, # to be calculated by inst-fs
}]
}.to_json
response = CanvasHttp.post(export_references_url, body: body, content_type: "application/json")
raise InstFS::ExportReferenceError, "received code \"#{response.code}\" from service, with message \"#{response.body}\"" unless response.class == Net::HTTPOK
json_response = JSON.parse(response.body)
well_formed =
json_response.is_a?(Hash) &&
json_response.key?("success") &&
json_response["success"].is_a?(Array) &&
json_response["success"].length == 1 &&
json_response["success"][0].is_a?(Hash)
json_response["success"][0].key?("id")
raise InstFS::ExportReferenceError, "import succeeded, but response body did not have expected shape" unless well_formed
json_response["success"][0]["id"]
end
def duplicate_file(instfs_uuid) def duplicate_file(instfs_uuid)
token = duplicate_file_jwt(instfs_uuid) token = duplicate_file_jwt(instfs_uuid)
url = "#{app_host}/files/#{instfs_uuid}/duplicate?token=#{token}" url = "#{app_host}/files/#{instfs_uuid}/duplicate?token=#{token}"
@ -387,6 +449,7 @@ module InstFS
end end
class DirectUploadError < StandardError; end class DirectUploadError < StandardError; end
class ExportReferenceError < StandardError; end
class DuplicationError < StandardError; end class DuplicationError < StandardError; end
class DeletionError < StandardError; end class DeletionError < StandardError; end
end end