Commit b575378a authored by Michael Kozono's avatar Michael Kozono

Respond with distinct error when file not found

…as opposed to record not found.
parent f89c3101
...@@ -1077,6 +1077,9 @@ ActiveRecord::Schema.define(version: 20180405101928) do ...@@ -1077,6 +1077,9 @@ ActiveRecord::Schema.define(version: 20180405101928) do
t.integer "repositories_verification_failed_count" t.integer "repositories_verification_failed_count"
t.integer "wikis_verified_count" t.integer "wikis_verified_count"
t.integer "wikis_verification_failed_count" t.integer "wikis_verification_failed_count"
t.integer "lfs_objects_synced_missing_on_primary_count"
t.integer "job_artifacts_synced_missing_on_primary_count"
t.integer "attachments_synced_missing_on_primary_count"
end end
add_index "geo_node_statuses", ["geo_node_id"], name: "index_geo_node_statuses_on_geo_node_id", unique: true, using: :btree add_index "geo_node_statuses", ["geo_node_id"], name: "index_geo_node_statuses_on_geo_node_id", unique: true, using: :btree
......
module Geo module Geo
# This class is responsible for:
# * Handling file requests from the secondary over the API
# * Returning the necessary response data to send the file back
class FileUploadService < FileService class FileUploadService < FileService
attr_reader :auth_header attr_reader :auth_header
......
...@@ -23,8 +23,7 @@ module API ...@@ -23,8 +23,7 @@ module API
file = response[:file] file = response[:file]
present_disk_file!(file.path, file.filename) present_disk_file!(file.path, file.filename)
else else
status response[:code] error! response, response.delete(:code)
response
end end
end end
......
module Gitlab module Gitlab
module Geo module Geo
# This class is responsible for:
# * Requesting an Upload file from the primary
# * Saving it in the right place on successful download
# * Returning a detailed Result object
class FileTransfer < Transfer class FileTransfer < Transfer
def initialize(file_type, upload) def initialize(file_type, upload)
@file_type = file_type @file_type = file_type
......
module Gitlab module Gitlab
module Geo module Geo
# This class is responsible for:
# * Finding an Upload record
# * Returning the necessary response data to send the file back
#
# TODO: Rearrange things so this class not inherited by JobArtifactUploader and LfsUploader
# Maybe rename it so it doesn't seem generic. It only works with Upload records.
class FileUploader class FileUploader
include LogHelpers
FILE_NOT_FOUND_GEO_CODE = 'FILE_NOT_FOUND'.freeze
attr_reader :object_db_id, :message attr_reader :object_db_id, :message
def initialize(object_db_id, message) def initialize(object_db_id, message)
...@@ -11,8 +21,9 @@ module Gitlab ...@@ -11,8 +21,9 @@ module Gitlab
def execute def execute
recorded_file = Upload.find_by(id: object_db_id) recorded_file = Upload.find_by(id: object_db_id)
return error unless recorded_file&.exist? return error('Upload not found') unless recorded_file
return error unless valid?(recorded_file) return file_not_found(recorded_file) unless recorded_file.exist?
return error('Upload not found') unless valid?(recorded_file)
success(CarrierWave::SanitizedFile.new(recorded_file.absolute_path)) success(CarrierWave::SanitizedFile.new(recorded_file.absolute_path))
end end
...@@ -37,9 +48,20 @@ module Gitlab ...@@ -37,9 +48,20 @@ module Gitlab
{ code: :ok, message: 'Success', file: file } { code: :ok, message: 'Success', file: file }
end end
def error(message = 'File not found') def error(message)
{ code: :not_found, message: message } { code: :not_found, message: message }
end end
# A 404 implies the client made a mistake requesting that resource.
# In this case, we know that the resource should exist, so it is a 500 server error.
# We send a special "geo_code" so the secondary can mark the file as synced.
def file_not_found(resource)
{
code: :not_found,
geo_code: FILE_NOT_FOUND_GEO_CODE,
message: "#{resource.class.name} ##{resource.id} file not found"
}
end
end end
end end
end end
module Gitlab module Gitlab
module Geo module Geo
# This class is responsible for:
# * Requesting an ::Ci::JobArtifact file from the primary
# * Saving it in the right place on successful download
# * Returning a detailed Result object
class JobArtifactTransfer < Transfer class JobArtifactTransfer < Transfer
def initialize(job_artifact) def initialize(job_artifact)
@file_type = :job_artifact @file_type = :job_artifact
......
module Gitlab module Gitlab
module Geo module Geo
# This class is responsible for:
# * Finding an ::Ci::JobArtifact record
# * Returning the necessary response data to send the file back
#
# TODO: Rearrange things so this class does not inherit from FileUploader
class JobArtifactUploader < ::Gitlab::Geo::FileUploader class JobArtifactUploader < ::Gitlab::Geo::FileUploader
def execute def execute
job_artifact = ::Ci::JobArtifact.find_by(id: object_db_id) job_artifact = ::Ci::JobArtifact.find_by(id: object_db_id)
...@@ -9,7 +14,9 @@ module Gitlab ...@@ -9,7 +14,9 @@ module Gitlab
end end
unless job_artifact.file.present? && job_artifact.file.exists? unless job_artifact.file.present? && job_artifact.file.exists?
return error('Job artifact does not have a file') log_error("Could not upload job artifact because it does not have a file", id: job_artifact.id)
return file_not_found(job_artifact)
end end
success(job_artifact.file) success(job_artifact.file)
......
module Gitlab module Gitlab
module Geo module Geo
# This class is responsible for:
# * Requesting an LfsObject file from the primary
# * Saving it in the right place on successful download
# * Returning a detailed Result object
class LfsTransfer < Transfer class LfsTransfer < Transfer
def initialize(lfs_object) def initialize(lfs_object)
@file_type = :lfs @file_type = :lfs
......
module Gitlab module Gitlab
module Geo module Geo
# This class is responsible for:
# * Finding an LfsObject record
# * Returning the necessary response data to send the file back
#
# TODO: Rearrange things so this class does not inherit from FileUploader
class LfsUploader < FileUploader class LfsUploader < FileUploader
def execute def execute
lfs_object = LfsObject.find_by(id: object_db_id) lfs_object = LfsObject.find_by(id: object_db_id)
return error unless lfs_object.present? return error('LFS object not found') unless lfs_object
return error if message[:checksum] != lfs_object.oid return error('LFS object not found') if message[:checksum] != lfs_object.oid
unless lfs_object.file.present? && lfs_object.file.exists? unless lfs_object.file.present? && lfs_object.file.exists?
return error('LFS object does not have a file') log_error("Could not upload LFS object because it does not have a file", id: lfs_object.id)
return file_not_found(lfs_object)
end end
success(lfs_object.file) success(lfs_object.file)
......
...@@ -14,25 +14,39 @@ module Gitlab ...@@ -14,25 +14,39 @@ module Gitlab
@request_data = request_data @request_data = request_data
end end
# Returns number of bytes downloaded or -1 if unsuccessful. # Returns Result object with success boolean and number of bytes downloaded.
def download_from_primary def download_from_primary
return unless Gitlab::Geo.secondary? return failure unless Gitlab::Geo.secondary?
return if File.directory?(filename) return failure if File.directory?(filename)
primary = Gitlab::Geo.primary_node primary = Gitlab::Geo.primary_node
return unless primary return failure unless primary
url = primary.geo_transfers_url(file_type, file_id.to_s) url = primary.geo_transfers_url(file_type, file_id.to_s)
req_headers = TransferRequest.new(request_data).headers req_headers = TransferRequest.new(request_data).headers
return unless ensure_path_exists return failure unless ensure_path_exists
download_file(url, req_headers) download_file(url, req_headers)
end end
class Result
attr_reader :success, :bytes_downloaded, :primary_missing_file
def initialize(success:, bytes_downloaded:, primary_missing_file: false)
@success = success
@bytes_downloaded = bytes_downloaded
@primary_missing_file = primary_missing_file
end
end
private private
def failure(primary_missing_file: false)
Result.new(success: false, bytes_downloaded: 0, primary_missing_file: primary_missing_file)
end
def ensure_path_exists def ensure_path_exists
path = Pathname.new(filename) path = Pathname.new(filename)
dir = path.dirname dir = path.dirname
...@@ -55,7 +69,7 @@ module Gitlab ...@@ -55,7 +69,7 @@ module Gitlab
file_size = -1 file_size = -1
temp_file = open_temp_file(filename) temp_file = open_temp_file(filename)
return unless temp_file return failure unless temp_file
begin begin
response = Gitlab::HTTP.get(url, allow_local_requests: true, headers: req_headers, stream_body: true) do |fragment| response = Gitlab::HTTP.get(url, allow_local_requests: true, headers: req_headers, stream_body: true) do |fragment|
...@@ -65,13 +79,13 @@ module Gitlab ...@@ -65,13 +79,13 @@ module Gitlab
temp_file.flush temp_file.flush
unless response.success? unless response.success?
log_error("Unsuccessful download", filename: filename, response_code: response.code, response_msg: response.msg, url: url) log_error("Unsuccessful download", filename: filename, response_code: response.code, response_msg: response.try(:msg), url: url)
return file_size return failure(primary_missing_file: primary_missing_file?(response, temp_file))
end end
if File.directory?(filename) if File.directory?(filename)
log_error("Destination file is a directory", filename: filename) log_error("Destination file is a directory", filename: filename)
return file_size return failure
end end
FileUtils.mv(temp_file.path, filename) FileUtils.mv(temp_file.path, filename)
...@@ -85,7 +99,18 @@ module Gitlab ...@@ -85,7 +99,18 @@ module Gitlab
temp_file.unlink temp_file.unlink
end end
file_size Result.new(success: file_size > -1, bytes_downloaded: [file_size, 0].max)
end
def primary_missing_file?(response, temp_file)
body = File.read(temp_file.path) if File.exist?(temp_file.path)
if response.code == 404 && body.present?
json_response = JSON.parse(body)
json_response['geo_code'] == Gitlab::Geo::FileUploader::FILE_NOT_FOUND_GEO_CODE
end
rescue JSON::ParserError
false
end end
def default_permissions def default_permissions
......
require 'spec_helper'
describe Gitlab::Geo::FileUploader, :geo do
shared_examples_for 'returns necessary params for sending a file from an API endpoint' do
subject { @subject ||= uploader.execute }
context 'when the upload exists' do
let(:uploader) { described_class.new(upload.id, message) }
before do
expect(Upload).to receive(:find_by).with(id: upload.id).and_return(upload)
end
context 'when the upload has a file' do
before do
FileUtils.mkdir_p(File.dirname(upload.absolute_path))
FileUtils.touch(upload.absolute_path) unless File.exist?(upload.absolute_path)
end
context 'when the message parameters match the upload' do
let(:message) { { id: upload.model_id, type: upload.model_type, checksum: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' } }
it 'returns the file in a success hash' do
expect(subject).to include(code: :ok, message: 'Success')
expect(subject[:file].file).to eq(upload.absolute_path)
end
end
context 'when the message id does not match the upload model_id' do
let(:message) { { id: 10000, type: upload.model_type, checksum: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' } }
it 'returns an error hash' do
expect(subject).to include(code: :not_found, message: "Upload not found")
end
end
context 'when the message type does not match the upload model_type' do
let(:message) { { id: upload.model_id, type: 'bad_type', checksum: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' } }
it 'returns an error hash' do
expect(subject).to include(code: :not_found, message: "Upload not found")
end
end
context 'when the message checksum does not match the upload checksum' do
let(:message) { { id: upload.model_id, type: upload.model_type, checksum: 'doesnotmatch' } }
it 'returns an error hash' do
expect(subject).to include(code: :not_found, message: "Upload not found")
end
end
end
context 'when the upload does not have a file' do
let(:message) { { id: upload.model_id, type: upload.model_type, checksum: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' } }
it 'returns an error hash' do
expect(subject).to include(code: :not_found, geo_code: 'FILE_NOT_FOUND', message: match(/Upload #\d+ file not found/))
end
end
end
context 'when the upload does not exist' do
it 'returns an error hash' do
result = described_class.new(10000, {}).execute
expect(result).to eq(code: :not_found, message: "Upload not found")
end
end
end
describe '#execute' do
context 'user avatar' do
it_behaves_like "returns necessary params for sending a file from an API endpoint" do
let(:upload) { create(:upload, model: build(:user)) }
end
end
context 'group avatar' do
it_behaves_like "returns necessary params for sending a file from an API endpoint" do
let(:upload) { create(:upload, model: build(:group)) }
end
end
context 'project avatar' do
it_behaves_like "returns necessary params for sending a file from an API endpoint" do
let(:upload) { create(:upload, model: build(:project)) }
end
end
context 'with an attachment' do
it_behaves_like "returns necessary params for sending a file from an API endpoint" do
let(:upload) { create(:upload, :attachment_upload) }
end
end
context 'with a snippet' do
it_behaves_like "returns necessary params for sending a file from an API endpoint" do
let(:upload) { create(:upload, :personal_snippet_upload) }
end
end
context 'with file upload' do
it_behaves_like "returns necessary params for sending a file from an API endpoint" do
let(:upload) { create(:upload, :issuable_upload) }
end
end
context 'with namespace file upload' do
it_behaves_like "returns necessary params for sending a file from an API endpoint" do
let(:upload) { create(:upload, :namespace_upload) }
end
end
end
end
...@@ -2,7 +2,8 @@ require 'spec_helper' ...@@ -2,7 +2,8 @@ require 'spec_helper'
describe Gitlab::Geo::JobArtifactUploader, :geo do describe Gitlab::Geo::JobArtifactUploader, :geo do
context '#execute' do context '#execute' do
subject { described_class.new(job_artifact.id, {}).execute } let(:uploader) { described_class.new(job_artifact.id, {}) }
subject { uploader.execute }
context 'when the job artifact exists' do context 'when the job artifact exists' do
before do before do
...@@ -29,7 +30,13 @@ describe Gitlab::Geo::JobArtifactUploader, :geo do ...@@ -29,7 +30,13 @@ describe Gitlab::Geo::JobArtifactUploader, :geo do
let(:job_artifact) { create(:ci_job_artifact) } let(:job_artifact) { create(:ci_job_artifact) }
it 'returns an error hash' do it 'returns an error hash' do
expect(subject).to eq(code: :not_found, message: "Job artifact does not have a file") expect(subject).to include(code: :not_found, geo_code: 'FILE_NOT_FOUND', message: match(/JobArtifact #\d+ file not found/))
end
it 'logs the missing file' do
expect(uploader).to receive(:log_error).with("Could not upload job artifact because it does not have a file", id: job_artifact.id)
subject
end end
end end
end end
......
require 'spec_helper'
describe Gitlab::Geo::LfsUploader, :geo do
context '#execute' do
subject { uploader.execute }
context 'when the LFS object exists' do
let(:uploader) { described_class.new(lfs_object.id, message) }
before do
expect(LfsObject).to receive(:find_by).with(id: lfs_object.id).and_return(lfs_object)
end
context 'when the LFS object has a file' do
let(:lfs_object) { create(:lfs_object, :with_file) }
let(:message) { { checksum: lfs_object.oid } }
context 'when the message checksum matches the LFS object oid' do
it 'returns the file in a success hash' do
expect(subject).to eq(code: :ok, message: 'Success', file: lfs_object.file)
end
end
context 'when the message checksum does not match the LFS object oid' do
let(:message) { { checksum: 'foo' } }
it 'returns an error hash' do
expect(subject).to include(code: :not_found, message: "LFS object not found")
end
end
end
context 'when the LFS object does not have a file' do
let(:lfs_object) { create(:lfs_object) }
let(:message) { { checksum: lfs_object.oid } }
it 'returns an error hash' do
expect(subject).to include(code: :not_found, geo_code: 'FILE_NOT_FOUND', message: match(/LfsObject #\d+ file not found/))
end
it 'logs the missing file' do
expect(uploader).to receive(:log_error).with("Could not upload LFS object because it does not have a file", id: lfs_object.id)
subject
end
end
end
context 'when the LFS object does not exist' do
let(:uploader) { described_class.new(10000, {}) }
it 'returns an error hash' do
expect(subject).to eq(code: :not_found, message: 'LFS object not found')
end
end
end
end
...@@ -23,36 +23,68 @@ describe Gitlab::Geo::Transfer do ...@@ -23,36 +23,68 @@ describe Gitlab::Geo::Transfer do
stub_current_geo_node(secondary_node) stub_current_geo_node(secondary_node)
end end
it 'when the destination filename is a directory' do context 'when the destination filename is a directory' do
transfer = described_class.new(:lfs, lfs_object.id, '/tmp', { sha256: lfs_object.id }) it 'returns a failed result' do
transfer = described_class.new(:lfs, lfs_object.id, '/tmp', { sha256: lfs_object.id })
expect(transfer.download_from_primary).to eq(nil) result = transfer.download_from_primary
expect_result(result, success: false, bytes_downloaded: 0, primary_missing_file: false)
end
end end
it 'when the HTTP response is successful' do context 'when the HTTP response is successful' do
expect(FileUtils).to receive(:mv).with(anything, lfs_object.file.path).and_call_original it 'returns a successful result' do
response = double(success?: true) expect(FileUtils).to receive(:mv).with(anything, lfs_object.file.path).and_call_original
expect(Gitlab::HTTP).to receive(:get).and_yield(content.to_s).and_return(response) response = double(:response, success?: true)
expect(Gitlab::HTTP).to receive(:get).and_yield(content.to_s).and_return(response)
expect(subject.download_from_primary).to eq(size) result = subject.download_from_primary
stat = File.stat(lfs_object.file.path)
expect(stat.size).to eq(size) expect_result(result, success: true, bytes_downloaded: size, primary_missing_file: false)
expect(stat.mode & 0777).to eq(0666 - File.umask) stat = File.stat(lfs_object.file.path)
expect(File.binread(lfs_object.file.path)).to eq(content) expect(stat.size).to eq(size)
expect(stat.mode & 0777).to eq(0666 - File.umask)
expect(File.binread(lfs_object.file.path)).to eq(content)
end
end end
it 'when the HTTP response is unsuccessful' do context 'when the HTTP response is unsuccessful' do
expect(FileUtils).not_to receive(:mv).with(anything, lfs_object.file.path).and_call_original context 'when the HTTP response indicates a missing file on the primary' do
response = double(success?: false, code: 404, msg: 'No such file') it 'returns a failed result indicating primary_missing_file' do
expect(Gitlab::HTTP).to receive(:get).and_return(response) expect(FileUtils).not_to receive(:mv).with(anything, lfs_object.file.path).and_call_original
response = double(:response, success?: false, code: 404, msg: "No such file")
expect(File).to receive(:read).and_return("{\"geo_code\":\"#{Gitlab::Geo::FileUploader::FILE_NOT_FOUND_GEO_CODE}\"}")
expect(Gitlab::HTTP).to receive(:get).and_return(response)
result = subject.download_from_primary
expect_result(result, success: false, bytes_downloaded: 0, primary_missing_file: true)
end
end
context 'when the HTTP response does not indicate a missing file on the primary' do
it 'returns a failed result' do
expect(FileUtils).not_to receive(:mv).with(anything, lfs_object.file.path).and_call_original
response = double(:response, success?: false, code: 404, msg: 'No such file')
expect(Gitlab::HTTP).to receive(:get).and_return(response)
expect(subject.download_from_primary).to eq(-1) result = subject.download_from_primary
expect_result(result, success: false, bytes_downloaded: 0)
end
end
end end
it 'when Tempfile fails' do context 'when Tempfile fails' do
expect(Tempfile).to receive(:new).and_raise(Errno::ENAMETOOLONG) it 'returns a failed result' do
expect(Tempfile).to receive(:new).and_raise(Errno::ENAMETOOLONG)
expect(subject.download_from_primary).to eq(nil) result = subject.download_from_primary
expect(result.success).to eq(false)
expect(result.bytes_downloaded).to eq(0)
end
end end
context "invalid path" do context "invalid path" do
...@@ -62,8 +94,17 @@ describe Gitlab::Geo::Transfer do ...@@ -62,8 +94,17 @@ describe Gitlab::Geo::Transfer do
allow(FileUtils).to receive(:mkdir_p) { raise Errno::EEXIST } allow(FileUtils).to receive(:mkdir_p) { raise Errno::EEXIST }
expect(subject).to receive(:log_error).with("unable to create directory /foo: File exists") expect(subject).to receive(:log_error).with("unable to create directory /foo: File exists")
expect(subject.download_from_primary).to be_nil result = subject.download_from_primary
expect(result.success).to eq(false)
expect(result.bytes_downloaded).to eq(0)
end end
end end
end end
def expect_result(result, success:, bytes_downloaded:, primary_missing_file: nil)
expect(result.success).to eq(success)
expect(result.bytes_downloaded).to eq(bytes_downloaded)
expect(result.primary_missing_file).to eq(primary_missing_file)
end
end end
...@@ -103,17 +103,30 @@ describe API::Geo do ...@@ -103,17 +103,30 @@ describe API::Geo do
expect(response).to have_gitlab_http_status(401) expect(response).to have_gitlab_http_status(401)
end end
context 'file file exists' do context 'when the Upload record exists' do
it 'responds with 200 with X-Sendfile' do context 'when the file exists' do
get api("/geo/transfers/file/#{upload.id}"), nil, req_header it 'responds with 200 with X-Sendfile' do
get api("/geo/transfers/file/#{upload.id}"), nil, req_header
expect(response).to have_gitlab_http_status(200)
expect(response.headers['Content-Type']).to eq('application/octet-stream')
expect(response.headers['X-Sendfile']).to end_with('dk.png')
end
end
expect(response).to have_gitlab_http_status(200) context 'file does not exist' do
expect(response.headers['Content-Type']).to eq('application/octet-stream') it 'responds with 404 and a specific geo code' do
expect(response.headers['X-Sendfile']).to end_with('dk.png') File.unlink(upload.absolute_path)
get api("/geo/transfers/file/#{upload.id}"), nil, req_header
expect(response).to have_gitlab_http_status(404)
expect(json_response['geo_code']).to eq(Gitlab::Geo::FileUploader::FILE_NOT_FOUND_GEO_CODE)
end
end end
end end
context 'file does not exist' do context 'when the Upload record does not exist' do
it 'responds with 404' do it 'responds with 404' do
get api("/geo/transfers/file/100000"), nil, req_header get api("/geo/transfers/file/100000"), nil, req_header
...@@ -139,13 +152,26 @@ describe API::Geo do ...@@ -139,13 +152,26 @@ describe API::Geo do
expect(response).to have_gitlab_http_status(401) expect(response).to have_gitlab_http_status(401)
end end
context 'LFS file exists' do context 'LFS object exists' do
it 'responds with 200 with X-Sendfile' do context 'file exists' do
get api("/geo/transfers/lfs/#{lfs_object.id}"), nil, req_header it 'responds with 200 with X-Sendfile' do
get api("/geo/transfers/lfs/#{lfs_object.id}"), nil, req_header
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
expect(response.headers['Content-Type']).to eq('application/octet-stream') expect(response.headers['Content-Type']).to eq('application/octet-stream')
expect(response.headers['X-Sendfile']).to eq(lfs_object.file.path) expect(response.headers['X-Sendfile']).to eq(lfs_object.file.path)
end
end
context 'file does not exist' do
it 'responds with 404 and a specific geo code' do
File.unlink(lfs_object.file.path)
get api("/geo/transfers/lfs/#{lfs_object.id}"), nil, req_header
expect(response).to have_gitlab_http_status(404)
expect(json_response['geo_code']).to eq(Gitlab::Geo::FileUploader::FILE_NOT_FOUND_GEO_CODE)
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