Commit a66cfc47 authored by allison.browne's avatar allison.browne

Put publish of attachements behind flag

Since this is a major change that could prevent status page
from publishing, put it behind a feature flag. Remove
helpers in favor of more descriptive class names. Return
an error response
parent d8de618a
...@@ -16,11 +16,8 @@ class UploaderFinder ...@@ -16,11 +16,8 @@ class UploaderFinder
retrieve_file_state! retrieve_file_state!
uploader uploader
rescue ::Gitlab::Utils::PathTraversalAttackError, StandardError => e rescue ::Gitlab::Utils::PathTraversalAttackError
# Send any unexpected errors to sentry and return nil nil # no-op if for incorrect files
Raven.capture_exception(e) unless e.is_a?(::Gitlab::Utils::PathTraversalAttackError)
nil
end end
def prevent_path_traversal_attack! def prevent_path_traversal_attack!
......
# frozen_string_literal: true # frozen_string_literal: true
module StatusPage module StatusPage
module PublicationServiceHelpers module PublicationServiceResponses
extend ActiveSupport::Concern
def error(message, payload = {}) def error(message, payload = {})
ServiceResponse.error(message: message, payload: payload) ServiceResponse.error(message: message, payload: payload)
end end
......
...@@ -4,8 +4,8 @@ module StatusPage ...@@ -4,8 +4,8 @@ module StatusPage
# Publishes Attachments from incident comments and descriptions to s3 # Publishes Attachments from incident comments and descriptions to s3
# Should only be called from publish details or a service that inherits from the publish_base_service # Should only be called from publish details or a service that inherits from the publish_base_service
class PublishAttachmentsService class PublishAttachmentsService
include Gitlab::Utils::StrongMemoize include ::Gitlab::Utils::StrongMemoize
include StatusPage::PublicationServiceHelpers include ::StatusPage::PublicationServiceResponses
def initialize(project:, issue:, user_notes:, storage_client:) def initialize(project:, issue:, user_notes:, storage_client:)
@project = project @project = project
...@@ -13,12 +13,15 @@ module StatusPage ...@@ -13,12 +13,15 @@ module StatusPage
@user_notes = user_notes @user_notes = user_notes
@storage_client = storage_client @storage_client = storage_client
@total_uploads = existing_keys.size @total_uploads = existing_keys.size
@has_errors = false
end end
def execute def execute
publish_description_attachments publish_description_attachments
publish_user_note_attachments publish_user_note_attachments
return file_upload_error if @has_errors
success success
end end
...@@ -60,10 +63,11 @@ module StatusPage ...@@ -60,10 +63,11 @@ module StatusPage
storage_client.multipart_upload(key, open_file) storage_client.multipart_upload(key, open_file)
@total_uploads += 1 @total_uploads += 1
end end
rescue Error => e rescue StatusPage::Storage::Error => e
# Continue uploading other files if one fails # In production continue uploading other files if one fails But report the failure to Sentry
# But report the failure to Sentry # raise errors in development and test
Raven.capture_exception(e) @has_errors = true
Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e)
end end
def existing_keys def existing_keys
...@@ -84,5 +88,9 @@ module StatusPage ...@@ -84,5 +88,9 @@ module StatusPage
# Uploader object behaves like a file with an 'open' method # Uploader object behaves like a file with an 'open' method
UploaderFinder.new(project, secret, file_name).execute UploaderFinder.new(project, secret, file_name).execute
end end
def file_upload_error
error('One or more files did not upload properly')
end
end end
end end
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
module StatusPage module StatusPage
class PublishBaseService class PublishBaseService
include Gitlab::Utils::StrongMemoize include Gitlab::Utils::StrongMemoize
include StatusPage::PublicationServiceHelpers include StatusPage::PublicationServiceResponses
def initialize(project:) def initialize(project:)
@project = project @project = project
......
...@@ -14,7 +14,8 @@ module StatusPage ...@@ -14,7 +14,8 @@ module StatusPage
response = publish_json(issue, user_notes) response = publish_json(issue, user_notes)
return response if response.error? return response if response.error?
publish_attachments(issue, user_notes) response = publish_attachments(issue, user_notes)
return response if response.error?
success success
end end
...@@ -39,6 +40,8 @@ module StatusPage ...@@ -39,6 +40,8 @@ module StatusPage
end end
def publish_attachments(issue, user_notes) def publish_attachments(issue, user_notes)
return success unless attachements_enabled?
StatusPage::PublishAttachmentsService.new( StatusPage::PublishAttachmentsService.new(
project: @project, project: @project,
issue: issue, issue: issue,
...@@ -46,5 +49,9 @@ module StatusPage ...@@ -46,5 +49,9 @@ module StatusPage
storage_client: storage_client storage_client: storage_client
).execute ).execute
end end
def attachements_enabled?
Feature.enabled?(:status_page_attachments, @project, default_enabled: true)
end
end end
end end
...@@ -4,7 +4,7 @@ module StatusPage ...@@ -4,7 +4,7 @@ module StatusPage
module Storage module Storage
# Implements a minimal AWS S3 client. # Implements a minimal AWS S3 client.
class S3Client class S3Client
include StatusPage::Storage::S3Helpers include StatusPage::Storage::WrapsStorageErrors
def initialize(region:, bucket_name:, access_key_id:, secret_access_key:) def initialize(region:, bucket_name:, access_key_id:, secret_access_key:)
@bucket_name = bucket_name @bucket_name = bucket_name
......
...@@ -4,7 +4,7 @@ module StatusPage ...@@ -4,7 +4,7 @@ module StatusPage
module Storage module Storage
# Implements multipart upload in s3 # Implements multipart upload in s3
class S3MultipartUpload class S3MultipartUpload
include StatusPage::Storage::S3Helpers include StatusPage::Storage::WrapsStorageErrors
# 5 megabytes is the minimum part size specified in the amazon SDK # 5 megabytes is the minimum part size specified in the amazon SDK
MULTIPART_UPLOAD_PART_SIZE = 5.megabytes MULTIPART_UPLOAD_PART_SIZE = 5.megabytes
...@@ -28,7 +28,7 @@ module StatusPage ...@@ -28,7 +28,7 @@ module StatusPage
begin begin
parts = upload_part(upload_id) parts = upload_part(upload_id)
complete_upload(upload_id, parts) complete_upload(upload_id, parts)
# Rescue on Exception since even on keyboard inturrupt we want to abort the upload and re-raise # Rescue on Exception since even on keyboard interrupt we want to abort the upload and re-raise
# abort clears the already uploaded parts so that they do not cost the bucket owner # abort clears the already uploaded parts so that they do not cost the bucket owner
# The status page bucket lifecycle policy will clear out unaborted parts if # The status page bucket lifecycle policy will clear out unaborted parts if
# this fails without an exception (power failures etc.) # this fails without an exception (power failures etc.)
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
module StatusPage module StatusPage
module Storage module Storage
module S3Helpers module WrapsStorageErrors
def wrap_errors(**args) def wrap_errors(**args)
yield yield
rescue Aws::Errors::ServiceError => e rescue Aws::Errors::ServiceError => e
......
...@@ -15,18 +15,21 @@ describe StatusPage::Storage::S3MultipartUpload, :aws_s3 do ...@@ -15,18 +15,21 @@ describe StatusPage::Storage::S3MultipartUpload, :aws_s3 do
) )
end end
describe 'multipart_upload' do describe '#call' do
let(:key) { '123' } let(:key) { '123' }
let(:file) { Tempfile.new('foo') } let(:file) do
Tempfile.new('foo').tap do |file|
file.open
file.write('hello world')
file.rewind
end
end
let(:upload_id) { '123456789' } let(:upload_id) { '123456789' }
subject(:result) { described_class.new(client: s3_client, bucket_name: bucket_name, key: key, open_file: file).call } subject(:result) { described_class.new(client: s3_client, bucket_name: bucket_name, key: key, open_file: file).call }
before do before do
file.open
file.write('hello world')
file.rewind
stub_responses( stub_responses(
:create_multipart_upload, :create_multipart_upload,
instance_double(Aws::S3::Types::CreateMultipartUploadOutput, { to_h: { upload_id: upload_id } }) instance_double(Aws::S3::Types::CreateMultipartUploadOutput, { to_h: { upload_id: upload_id } })
...@@ -74,9 +77,10 @@ describe StatusPage::Storage::S3MultipartUpload, :aws_s3 do ...@@ -74,9 +77,10 @@ describe StatusPage::Storage::S3MultipartUpload, :aws_s3 do
stub_responses(:upload_part, aws_error) stub_responses(:upload_part, aws_error)
end end
it 'raises an error' do it 'aborts the upload and raises an error' do
expect(s3_client).to receive(:abort_multipart_upload)
msg = error_message(aws_error, key: key) msg = error_message(aws_error, key: key)
expect(s3_client).to receive(:abort_multipart_upload)
expect { result }.to raise_error(StatusPage::Storage::Error, msg) expect { result }.to raise_error(StatusPage::Storage::Error, msg)
end end
end end
...@@ -87,9 +91,10 @@ describe StatusPage::Storage::S3MultipartUpload, :aws_s3 do ...@@ -87,9 +91,10 @@ describe StatusPage::Storage::S3MultipartUpload, :aws_s3 do
stub_responses(:complete_multipart_upload, aws_error) stub_responses(:complete_multipart_upload, aws_error)
end end
it 'raises an error' do it 'aborts the upload and raises an error' do
expect(s3_client).to receive(:abort_multipart_upload)
msg = error_message(aws_error, key: key) msg = error_message(aws_error, key: key)
expect(s3_client).to receive(:abort_multipart_upload)
expect { result }.to raise_error(StatusPage::Storage::Error, msg) expect { result }.to raise_error(StatusPage::Storage::Error, msg)
end end
end end
......
...@@ -71,6 +71,17 @@ describe StatusPage::PublishAttachmentsService do ...@@ -71,6 +71,17 @@ describe StatusPage::PublishAttachmentsService do
expect(subject.payload).to eq({}) expect(subject.payload).to eq({})
end end
context 'when upload to storage throws an error' do
it 'returns an error response' do
storage_error = StatusPage::Storage::Error.new(bucket: '', error: StandardError.new)
# no raise to mimic prod behavior
allow(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
allow(storage_client).to receive(:multipart_upload).and_raise(storage_error)
expect(subject.error?).to be true
end
end
context 'user notes uploads' do context 'user notes uploads' do
let(:user_note) { instance_double(Note, note: markdown_field) } let(:user_note) { instance_double(Note, note: markdown_field) }
let(:user_notes) { [user_note] } let(:user_notes) { [user_note] }
......
...@@ -3,6 +3,8 @@ ...@@ -3,6 +3,8 @@
require 'spec_helper' require 'spec_helper'
describe StatusPage::PublishDetailsService do describe StatusPage::PublishDetailsService do
include ::StatusPage::PublicationServiceResponses
let_it_be(:project, refind: true) { create(:project) } let_it_be(:project, refind: true) { create(:project) }
let(:user_notes) { [] } let(:user_notes) { [] }
let(:incident_id) { 1 } let(:incident_id) { 1 }
...@@ -31,16 +33,45 @@ describe StatusPage::PublishDetailsService do ...@@ -31,16 +33,45 @@ describe StatusPage::PublishDetailsService do
end end
end end
context 'publishes attachments' do context 'publishing attachments' do
it 'sends attachements to s3' do before do
allow(storage_client).to receive(:upload_object) allow(storage_client).to receive(:upload_object).and_return(success)
allow(storage_client).to receive(:list_object_keys).and_return([]) allow(storage_client).to receive(:list_object_keys).and_return([])
end
context 'when feature flag disabled' do
before do
stub_feature_flags(status_page_attachments: false)
end
expect_next_instance_of(StatusPage::PublishAttachmentsService) do |service| it 'does not publish attachments and returns success' do
expect(service).to receive(:execute) expect(StatusPage::PublishAttachmentsService).not_to receive(:new)
expect(subject.success?).to be true
end end
end
context 'when successful' do
let(:success_response) { double(error?: false, success?: true) }
it 'sends attachments to storage and returns success' do
expect_next_instance_of(StatusPage::PublishAttachmentsService) do |service|
expect(service).to receive(:execute).and_return(success_response)
end
subject expect(subject.success?).to be true
end
end
context 'when error returned from PublishAttachmentsService' do
let(:error_response) { double(error?: true, success?: false) }
it 'returns an error' do
expect_next_instance_of(StatusPage::PublishAttachmentsService) do |service|
expect(service).to receive(:execute).and_return(error_response)
end
expect(subject.success?).to be false
end
end end
end end
end end
......
...@@ -8,7 +8,7 @@ module API ...@@ -8,7 +8,7 @@ module API
path = params[attr_name] path = params[attr_name]
Gitlab::Utils.check_path_traversal!(path) Gitlab::Utils.check_path_traversal!(path)
rescue Gitlab::Utils::PathTraversalAttackError rescue ::Gitlab::Utils::PathTraversalAttackError
raise Grape::Exceptions::Validation, params: [@scope.full_name(attr_name)], raise Grape::Exceptions::Validation, params: [@scope.full_name(attr_name)],
message: "should be a valid file path" message: "should be a valid file path"
end end
......
...@@ -3,8 +3,7 @@ ...@@ -3,8 +3,7 @@
module Gitlab module Gitlab
module Utils module Utils
extend self extend self
# disable Cop/CustomErrorClass because recommended style causes 'already initialized constant' warning PathTraversalAttackError ||= Class.new(StandardError)
class PathTraversalAttackError < StandardError; end # rubocop:disable Cop/CustomErrorClass
# Ensure that the relative path will not traverse outside the base directory # Ensure that the relative path will not traverse outside the base directory
# We url decode the path to avoid passing invalid paths forward in url encoded format. # We url decode the path to avoid passing invalid paths forward in url encoded format.
......
...@@ -15,7 +15,7 @@ describe UploaderFinder do ...@@ -15,7 +15,7 @@ describe UploaderFinder do
upload.save upload.save
end end
context 'when sucessful' do context 'when successful' do
before do before do
allow_next_instance_of(FileUploader) do |uploader| allow_next_instance_of(FileUploader) do |uploader|
allow(uploader).to receive(:retrieve_from_store!).with(upload.path).and_return(uploader) allow(uploader).to receive(:retrieve_from_store!).with(upload.path).and_return(uploader)
...@@ -48,7 +48,7 @@ describe UploaderFinder do ...@@ -48,7 +48,7 @@ describe UploaderFinder do
end end
it 'returns nil when unexpected error is raised' do it 'returns nil when unexpected error is raised' do
expect(subject).to be(nil) expect { subject }.to raise_error(StandardError)
end end
end end
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment