conditional release content migration service

test plan:
* make sure canvas is configured with a
 conditional release service with g/85046 merged or checked out
* create assignments in a course with conditional content
* after copying the course, or exporting/importing the course,
 the new assignments should have conditional content

closes #CNVS-30371 #CYOE-235

Change-Id: I42693f2d12185f5c7f665d303baf938ad4af08ad
Reviewed-on: https://gerrit.instructure.com/85108
Tested-by: Jenkins
Reviewed-by: Michael Brewer-Davis <mbd@instructure.com>
Reviewed-by: Dan Minkevitch <dan@instructure.com>
Reviewed-by: Jeremy Stanley <jeremy@instructure.com>
Reviewed-by: Christian Prescott <cprescott@instructure.com>
QA-Review: Alex Morris <amorris@instructure.com>
Product-Review: James Williams  <jamesw@instructure.com>
This commit is contained in:
James Williams 2016-07-13 07:30:59 -06:00
parent d086e37945
commit a4f7dc86a1
10 changed files with 215 additions and 17 deletions

View File

@ -168,7 +168,7 @@ class BigBlueButtonConference < WebConference
http_response = nil
Canvas.timeout_protection("big_blue_button") do
logger.debug "big blue button api call: #{url_str}"
http_response = CanvasHttp.get(url_str, {}, 5)
http_response = CanvasHttp.get(url_str, redirect_limit: 5)
end
case http_response

View File

@ -0,0 +1,96 @@
module ConditionalRelease
class MigrationService
class << self
def applies_to_course?(course)
ConditionalRelease::Service.enabled_in_context?(course)
end
def begin_export(course, opts)
data = nil
if opts[:selective]
assignment_ids = opts[:exported_assets].map{|asset| (match = asset.match(/assignment_(\d+)/) && match[1])}.compact
return unless assignment_ids.any?
data = {:export_settings => {:selective => '1', :exported_assignment_ids => assignment_ids}}.to_param
end
response = CanvasHttp.post(ConditionalRelease::Service.content_exports_url, headers_for(course), form_data: data)
if response.code =~ /^2/
json = JSON.parse(response.body)
{:export_id => json['id'], :course => course}
else
raise "Error queueing export for Conditional Release: #{response.body}"
end
end
def export_completed?(export_data)
response = CanvasHttp.get("#{ConditionalRelease::Service.content_exports_url}/#{export_data[:export_id]}", headers_for(export_data[:course]))
if response.code =~ /^2/
json = JSON.parse(response.body)
case json['state']
when 'completed'
true
when 'failed'
raise "Content Export for Conditional Release failed"
else
false
end
else
raise "Error retrieving export state for Conditional Release: #{response.body}"
end
end
def retrieve_export(export_data)
response = CanvasHttp.get("#{ConditionalRelease::Service.content_exports_url}/#{export_data[:export_id]}/download", headers_for(export_data[:course]))
if response.code =~ /^2/
json = JSON.parse(response.body)
unless json.values.all?(&:empty?) # don't bother saving if there's nothing to import
return json
end
else
raise "Error retrieving export for Conditional Release: #{response.body}"
end
end
def send_imported_content(course, imported_content)
data = {:file => StringIO.new(imported_content.to_json)}
response = CanvasHttp.post(ConditionalRelease::Service.content_imports_url, headers_for(course), form_data: data, multipart: true)
if response.code =~ /^2/
json = JSON.parse(response.body)
{:import_id => json['id'], :course => course}
else
raise "Error sending import for Conditional Release: #{response.body}"
end
end
def import_completed?(import_data)
response = CanvasHttp.get("#{ConditionalRelease::Service.content_imports_url}/#{import_data[:import_id]}", headers_for(import_data[:course]))
if response.code =~ /^2/
json = JSON.parse(response.body)
case json['state']
when 'completed'
true
when 'failed'
raise "Content Import for Conditional Release failed"
else
false
end
else
raise "Error retrieving import state for Conditional Release: #{response.body}"
end
end
protected
def headers_for(course)
token = Canvas::Security::ServicesJwt.generate({
sub: 'MIGRATION_SERVICE',
role: 'admin',
account_id: Context.get_account(course).root_account.lti_guid.to_s,
context_type: 'Course',
context_id: course.id.to_s,
workflow: 'conditonal-release-api'
})
{"Authorization" => "Bearer #{token}"}
end
end
end
end

View File

@ -26,6 +26,8 @@ module ConditionalRelease
protocol: nil, # defaults to Canvas
edit_rule_path: "ui/editor",
create_account_path: 'api/account',
content_exports_path: 'api/content_exports',
content_imports_path: 'api/content_imports',
}.freeze
def self.env_for(context, user = nil, session: nil, assignment: nil, domain: nil, real_user: nil)
@ -105,6 +107,14 @@ module ConditionalRelease
config[:create_account_path]
end
def self.content_exports_url
build_url(config[:content_exports_path])
end
def self.content_imports_url
build_url(config[:content_imports_path])
end
class << self
private
def config_file

View File

@ -0,0 +1 @@
Canvas::Migration::ExternalContent::Migrator.register_service('conditional_release', ConditionalRelease::MigrationService)

View File

@ -41,11 +41,7 @@ module CanvasHttp
# rather than reading it all into memory.
#
# Eventually it may be expanded to optionally do cert verification as well.
#
# TODO: this doesn't yet handle relative redirects (relative Location HTTP
# header), which actually isn't even technically allowed by the HTTP spec.
# But everybody allows and handles it.
def self.request(request_class, url_str, other_headers = {}, redirect_limit = 3)
def self.request(request_class, url_str, other_headers = {}, redirect_limit: 3, form_data: nil, multipart: false)
last_scheme = nil
last_host = nil
@ -54,9 +50,20 @@ module CanvasHttp
_, uri = CanvasHttp.validate_url(url_str, last_host, last_scheme) # uses the last host and scheme for relative redirects
http = CanvasHttp.connection_for_uri(uri)
multipart_query = nil
if form_data && multipart
multipart_query, multipart_headers = Multipart::Post.new.prepare_query(form_data)
other_headers = other_headers.merge(multipart_headers)
end
request = request_class.new(uri.request_uri, other_headers)
add_form_data(request, form_data) if form_data && !multipart
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
http.request(request) do |response|
args = [request]
args << multipart_query if multipart
http.request(*args) do |response|
if response.is_a?(Net::HTTPRedirection) && !response.is_a?(Net::HTTPNotModified)
last_host = uri.host
last_scheme = uri.scheme
@ -76,6 +83,15 @@ module CanvasHttp
end
end
def self.add_form_data(request, form_data)
if form_data.is_a?(String)
request.body = form_data
request.content_type = 'application/x-www-form-urlencoded'
else
request.set_form_data(form_data)
end
end
# returns [normalized_url_string, URI] if valid, raises otherwise
def self.validate_url(value, host=nil, scheme=nil)
value = value.strip

View File

@ -84,7 +84,7 @@ describe "CanvasHttp" do
to_return(status: 301, headers: { 'Location' => 'http://www.example2.com/a'})
stub_request(:get, "http://www.example2.com/a").
to_return(status: 301, headers: { 'Location' => 'http://www.example3.com/a'})
expect { CanvasHttp.get("http://www.example.com/a", {}, 2) }.to raise_error(CanvasHttp::TooManyRedirectsError)
expect { CanvasHttp.get("http://www.example.com/a", redirect_limit: 2) }.to raise_error(CanvasHttp::TooManyRedirectsError)
end
it "should yield requests to blocks" do

View File

@ -7,7 +7,6 @@ module Canvas::Migration::ExternalContent
end
def register_service(key, service)
key = key.to_url
raise "service with the key #{key} is already registered" if self.registered_services[key] && self.registered_services[key] != service
Canvas::Migration::ExternalContent::ServiceInterface.validate_service!(service)
self.registered_services[key] = service
@ -29,29 +28,79 @@ module Canvas::Migration::ExternalContent
pending_exports
end
def retry_delay
Setting.get('external_content_retry_delay_seconds', '20').to_i.seconds
end
def retry_limit
Setting.get('external_content_retry_limit', '5').to_i
end
def retry_block_for_each(pending_keys)
retry_count = 0
while pending_keys.any? && retry_count <= retry_limit
sleep(retry_delay) if retry_count > 0
pending_keys.each do |service_key|
begin
pending_keys.delete(service_key) if yield(service_key)
rescue => e
pending_keys.delete(service_key) # don't retry if failed
Canvas::Errors.capture_exception(:external_content_migration, e)
end
end
retry_count += 1
end
if pending_keys.any?
Canvas::Errors.capture_exception(:external_content_migration,
"External content migrations timed out for #{pending_keys.join(', ')}")
end
end
# retrieves data from each service to be saved as JSON in the exported package
def retrieve_exported_content(pending_exports)
exported_content = {}
pending_exports.each do |key, pending_export|
service_data = self.registered_services[key].retrieve_export(pending_export)
exported_content[key] = Canvas::Migration::ExternalContent::Translator.new.translate_data(service_data, :export) if service_data
retry_block_for_each(pending_exports.keys) do |key|
pending_export = pending_exports[key]
service = self.registered_services[key]
if service.export_completed?(pending_export)
service_data = service.retrieve_export(pending_export)
exported_content[key] = Canvas::Migration::ExternalContent::Translator.new.translate_data(service_data, :export) if service_data
true
end
end
exported_content
end
# sends back the imported content to the external services
def send_imported_content(migration, imported_content)
imported_content = Canvas::Migration::ExternalContent::Translator.new(migration).translate_data(imported_content, :import)
pending_imports = {}
imported_content.each do |key, content|
service = self.registered_services[key]
if service
begin
service.send_imported_content(migration.context, content)
if import = service.send_imported_content(migration.context, content)
pending_imports[key] = import
end
rescue => e
Canvas::Errors.capture_exception(:external_content_migration, e)
end
end
end
ensure_imports_completed(pending_imports)
end
def ensure_imports_completed(pending_imports)
# keep pinging until they're all finished
retry_block_for_each(pending_imports.keys) do |key|
self.registered_services[key].import_completed?(pending_imports[key])
end
end
end
end

View File

@ -14,16 +14,25 @@ module Canvas::Migration::ExternalContent
# if the export is selective, opts[:selective] will be true
# and opts[:exported_assets] will be a set of exported asset strings
#
# export_completed?(export_data)
# check to see if the export is ready to be downloaded
#
# retrieve_export(export_data)
# return the data that we need to save in the package
#
# send_imported_content(course, imported_content)
# give back the translated data for importing
# gives back the translated data for importing to the service
# return information needed to verify import is complete
#
# import_completed?(import_data)
# verifies that the import completed
methods = {
:applies_to_course? => 1,
:begin_export => 2,
:export_completed? => 1,
:retrieve_export => 1,
:send_imported_content => 2
:send_imported_content => 2,
:import_completed? => 1
}
methods.each do |method_name, arity|
raise "external content service needs to implement #{method_name}" unless service.respond_to?(method_name)

View File

@ -273,9 +273,9 @@ class CourseLinkValidator
# ping the url and make sure we get a 200
def reachable_url?(url)
begin
response = CanvasHttp.head(url, { "Accept-Encoding" => "gzip" }, 9)
response = CanvasHttp.head(url, { "Accept-Encoding" => "gzip" }, redirect_limit: 9)
if %w{404 405}.include?(response.code)
response = CanvasHttp.get(url, { "Accept-Encoding" => "gzip" }, 9)
response = CanvasHttp.get(url, { "Accept-Encoding" => "gzip" }, redirect_limit: 9)
end
case response.code

View File

@ -19,6 +19,7 @@ describe ContentMigration do
it "should skip everything if #applies_to_course? returns false" do
TestExternalContentService.stubs(:applies_to_course?).returns(false)
TestExternalContentService.expects(:begin_export).never
TestExternalContentService.expects(:export_completed?).never
TestExternalContentService.expects(:retrieve_export).never
TestExternalContentService.expects(:send_imported_content).never
@ -30,6 +31,7 @@ describe ContentMigration do
test_data = {:sometestdata => "something"}
TestExternalContentService.expects(:begin_export).with(@copy_from, {}).returns(test_data)
TestExternalContentService.expects(:export_completed?).with(test_data).returns(true)
TestExternalContentService.expects(:retrieve_export).with(test_data).returns(nil)
TestExternalContentService.expects(:send_imported_content).never
run_course_copy
@ -58,6 +60,7 @@ describe ContentMigration do
'$canvas_page_id' => page.id,
'$canvas_quiz_id' => quiz.id
}
TestExternalContentService.stubs(:export_completed?).returns(true)
TestExternalContentService.stubs(:retrieve_export).returns(data)
run_course_copy
@ -92,6 +95,7 @@ describe ContentMigration do
TestExternalContentService.stubs(:applies_to_course?).returns(true)
TestExternalContentService.stubs(:begin_export).returns(true)
TestExternalContentService.stubs(:export_completed?).returns(true)
TestExternalContentService.stubs(:retrieve_export).returns(
{'$canvas_assignment_id' => assmt.id, '$canvas_discussion_topic_id' => topic.id})
@ -115,6 +119,7 @@ describe ContentMigration do
item = cm.add_item(:id => assmt.id, :type => 'assignment')
TestExternalContentService.stubs(:applies_to_course?).returns(true)
TestExternalContentService.stubs(:export_completed?).returns(true)
TestExternalContentService.stubs(:retrieve_export).returns({})
@cm.copy_options = {:context_modules => {mig_id(cm) => "1"}}
@ -125,5 +130,17 @@ describe ContentMigration do
run_course_copy
end
it "should only check a few times for the export to finish before timing out" do
TestExternalContentService.stubs(:applies_to_course?).returns(true)
TestExternalContentService.stubs(:begin_export).returns(true)
Canvas::Migration::ExternalContent::Migrator.expects(:retry_delay).at_least_once.returns(0) # so we're not actually sleeping for 30s a pop
TestExternalContentService.expects(:export_completed?).times(6).returns(false) # retries 5 times
Canvas::Errors.expects(:capture_exception).with(:external_content_migration,
"External content migrations timed out for test_service")
run_course_copy
end
end
end