Commit 045ef664 authored by Luke Duncalfe's avatar Luke Duncalfe

Generate smaller image sizes for Designs

Adds a new DesignV432x230Uploader to generate and serve smaller
versions of design files.

The original (full-sized) design files are stored in Git LFS, and so
have a different uploader, `LfsObjectUploader`.

The decision to make a separate uploader to handle just the smaller
image versions came out of a discussion in
https://gitlab.com/gitlab-org/gitlab/merge_requests/20511. Initially,
the direction taken was to make LfsObjectUploader conditionally generate
smaller images for LfsObject files. The feedback was that this was
adding logic that was too specific to design management into an uploader
that is very general, and that we should use a separate uploader to
handle just the smaller image versions.

A service now runs after a new DesignManagement::Version has been
created, which downloads the original design files from their Git LFS
storage and generates smaller versions, storing them in the regular
uploads storage.

This change is behind the :design_management_resize_images feature flag.

https://gitlab.com/gitlab-org/gitlab/issues/12577
https://gitlab.com/gitlab-org/gitlab/issues/13815
parent 7303af24
---
title: Add id and image_v432x230 columns to design_management_designs_versions
merge_request: 22860
author:
type: changed
# frozen_string_literal: true
class AddImageToDesignManagementDesignsVersions < ActiveRecord::Migration[6.0]
DOWNTIME = false
def change
add_column :design_management_designs_versions, :image_v432x230, :string, limit: 255
end
end
# frozen_string_literal: true
class AddIdToDesignManagementDesignsVersions < ActiveRecord::Migration[6.0]
DOWNTIME = false
def change
add_column :design_management_designs_versions, :id, :primary_key
end
end
...@@ -1417,10 +1417,11 @@ ActiveRecord::Schema.define(version: 2020_02_14_034836) do ...@@ -1417,10 +1417,11 @@ ActiveRecord::Schema.define(version: 2020_02_14_034836) do
t.index ["project_id"], name: "index_design_management_designs_on_project_id" t.index ["project_id"], name: "index_design_management_designs_on_project_id"
end end
create_table "design_management_designs_versions", id: false, force: :cascade do |t| create_table "design_management_designs_versions", force: :cascade do |t|
t.bigint "design_id", null: false t.bigint "design_id", null: false
t.bigint "version_id", null: false t.bigint "version_id", null: false
t.integer "event", limit: 2, default: 0, null: false t.integer "event", limit: 2, default: 0, null: false
t.string "image_v432x230", limit: 255
t.index ["design_id", "version_id"], name: "design_management_designs_versions_uniqueness", unique: true t.index ["design_id", "version_id"], name: "design_management_designs_versions_uniqueness", unique: true
t.index ["design_id"], name: "index_design_management_designs_versions_on_design_id" t.index ["design_id"], name: "index_design_management_designs_versions_on_design_id"
t.index ["event"], name: "index_design_management_designs_versions_on_event" t.index ["event"], name: "index_design_management_designs_versions_on_event"
......
...@@ -73,6 +73,9 @@ gitlab-rake "gitlab:uploads:migrate[FileUploader, Project]" ...@@ -73,6 +73,9 @@ gitlab-rake "gitlab:uploads:migrate[FileUploader, Project]"
gitlab-rake "gitlab:uploads:migrate[PersonalFileUploader, Snippet]" gitlab-rake "gitlab:uploads:migrate[PersonalFileUploader, Snippet]"
gitlab-rake "gitlab:uploads:migrate[NamespaceFileUploader, Snippet]" gitlab-rake "gitlab:uploads:migrate[NamespaceFileUploader, Snippet]"
gitlab-rake "gitlab:uploads:migrate[FileUploader, MergeRequest]" gitlab-rake "gitlab:uploads:migrate[FileUploader, MergeRequest]"
# Design Management design thumbnails (EE)
gitlab-rake "gitlab:uploads:migrate[DesignManagement::DesignV432x230Uploader, DesignManagement::Action, :image_v432x230]"
``` ```
**Source Installation** **Source Installation**
...@@ -102,6 +105,8 @@ sudo -u git -H bundle exec rake "gitlab:uploads:migrate[PersonalFileUploader, Sn ...@@ -102,6 +105,8 @@ sudo -u git -H bundle exec rake "gitlab:uploads:migrate[PersonalFileUploader, Sn
sudo -u git -H bundle exec rake "gitlab:uploads:migrate[NamespaceFileUploader, Snippet]" sudo -u git -H bundle exec rake "gitlab:uploads:migrate[NamespaceFileUploader, Snippet]"
sudo -u git -H bundle exec rake "gitlab:uploads:migrate[FileUploader, MergeRequest]" sudo -u git -H bundle exec rake "gitlab:uploads:migrate[FileUploader, MergeRequest]"
# Design Management design thumbnails (EE)
sudo -u git -H bundle exec rake "gitlab:uploads:migrate[DesignManagement::DesignV432x230Uploader, DesignManagement::Action]"
``` ```
## Migrate legacy uploads out of deprecated paths ## Migrate legacy uploads out of deprecated paths
......
...@@ -21,6 +21,7 @@ There are many places where file uploading is used, according to contexts: ...@@ -21,6 +21,7 @@ There are many places where file uploading is used, according to contexts:
- CI Artifacts (archive, metadata, trace) - CI Artifacts (archive, metadata, trace)
- LFS Objects - LFS Objects
- Merge request diffs - Merge request diffs
- Design Management design thumbnails (EE)
## Disk storage ## Disk storage
...@@ -37,6 +38,7 @@ they are still not 100% standardized. You can see them below: ...@@ -37,6 +38,7 @@ they are still not 100% standardized. You can see them below:
| Project avatars | yes | uploads/-/system/project/avatar/:id/:filename | `AvatarUploader` | Project | | Project avatars | yes | uploads/-/system/project/avatar/:id/:filename | `AvatarUploader` | Project |
| Issues/MR/Notes Markdown attachments | yes | uploads/:project_path_with_namespace/:random_hex/:filename | `FileUploader` | Project | | Issues/MR/Notes Markdown attachments | yes | uploads/:project_path_with_namespace/:random_hex/:filename | `FileUploader` | Project |
| Issues/MR/Notes Legacy Markdown attachments | no | uploads/-/system/note/attachment/:id/:filename | `AttachmentUploader` | Note | | Issues/MR/Notes Legacy Markdown attachments | no | uploads/-/system/note/attachment/:id/:filename | `AttachmentUploader` | Note |
| Design Management design thumbnails (EE) | yes | uploads/-/system/design_management/action/image_v432x230/:id/:filename | `DesignManagement::DesignV432x230Uploader` | DesignManagement::Action |
| CI Artifacts (CE) | yes | `shared/artifacts/:disk_hash[0..1]/:disk_hash[2..3]/:disk_hash/:year_:month_:date/:job_id/:job_artifact_id` (:disk_hash is SHA256 digest of project_id) | `JobArtifactUploader` | Ci::JobArtifact | | CI Artifacts (CE) | yes | `shared/artifacts/:disk_hash[0..1]/:disk_hash[2..3]/:disk_hash/:year_:month_:date/:job_id/:job_artifact_id` (:disk_hash is SHA256 digest of project_id) | `JobArtifactUploader` | Ci::JobArtifact |
| LFS Objects (CE) | yes | shared/lfs-objects/:hex/:hex/:object_hash | `LfsObjectUploader` | LfsObject | | LFS Objects (CE) | yes | shared/lfs-objects/:hex/:hex/:object_hash | `LfsObjectUploader` | LfsObject |
| External merge request diffs | yes | shared/external-diffs/merge_request_diffs/mr-:parent_id/diff-:id | `ExternalDiffUploader` | MergeRequestDiff | | External merge request diffs | yes | shared/external-diffs/merge_request_diffs/mr-:parent_id/diff-:id | `ExternalDiffUploader` | MergeRequestDiff |
......
...@@ -2,8 +2,12 @@ ...@@ -2,8 +2,12 @@
module DesignManagement module DesignManagement
class Action < ApplicationRecord class Action < ApplicationRecord
include WithUploads
self.table_name = "#{DesignManagement.table_name_prefix}designs_versions" self.table_name = "#{DesignManagement.table_name_prefix}designs_versions"
mount_uploader :image_v432x230, DesignManagement::DesignV432x230Uploader
belongs_to :design, class_name: "DesignManagement::Design", inverse_of: :actions belongs_to :design, class_name: "DesignManagement::Design", inverse_of: :actions
belongs_to :version, class_name: "DesignManagement::Version", inverse_of: :actions belongs_to :version, class_name: "DesignManagement::Version", inverse_of: :actions
......
...@@ -53,7 +53,7 @@ module DesignManagement ...@@ -53,7 +53,7 @@ module DesignManagement
scope :for_designs, -> (designs) do scope :for_designs, -> (designs) do
where(id: ::DesignManagement::Action.where(design_id: designs).select(:version_id)).distinct where(id: ::DesignManagement::Action.where(design_id: designs).select(:version_id)).distinct
end end
scope :earlier_or_equal_to, -> (version) { where('id <= ?', version) } scope :earlier_or_equal_to, -> (version) { where("(#{table_name}.id) <= ?", version) } # rubocop:disable GitlabSecurity/SqlInjection
scope :ordered, -> { order(id: :desc) } scope :ordered, -> { order(id: :desc) }
scope :for_issue, -> (issue) { where(issue: issue) } scope :for_issue, -> (issue) { where(issue: issue) }
scope :by_sha, -> (sha) { where(sha: sha) } scope :by_sha, -> (sha) { where(sha: sha) }
......
# frozen_string_literal: true
module DesignManagement
# This service generates smaller image versions for `DesignManagement::Design`
# records within a given `DesignManagement::Version`.
class GenerateImageVersionsService < DesignService
# We limit processing to only designs with file sizes that don't
# exceed `MAX_DESIGN_SIZE`.
#
# Note, we may be able to remove checking this limit, if when we come to
# implement a file size limit for designs, there are no designs that
# exceed 40MB on GitLab.com
#
# See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22860#note_281780387
MAX_DESIGN_SIZE = 40.megabytes.freeze
def initialize(version)
super(version.project, version.author, issue: version.issue)
@version = version
end
def execute
# rubocop: disable CodeReuse/ActiveRecord
version.actions.includes(:design).each do |action|
generate_image(action)
end
# rubocop: enable CodeReuse/ActiveRecord
success(version: version)
end
private
attr_reader :version
def generate_image(action)
raw_file = get_raw_file(action)
unless raw_file
log_error("No design file found for Action: #{action.id}")
return
end
# Skip attempting to process images that would be rejected by CarrierWave.
return unless DesignManagement::DesignV432x230Uploader::MIME_TYPE_WHITELIST.include?(raw_file.content_type)
# Store and process the file
action.image_v432x230.store!(raw_file)
action.save!
rescue CarrierWave::UploadError => e
Gitlab::ErrorTracking.track_exception(e, project_id: project.id, design_id: action.design_id, version_id: action.version_id)
log_error(e.message)
end
# Returns the `CarrierWave::SanitizedFile` of the original design file
def get_raw_file(action)
raw_files_by_path[action.design.full_path]
end
# Returns the `Carrierwave:SanitizedFile` instances for all of the original
# design files, mapping to { design.filename => `Carrierwave::SanitizedFile` }.
#
# As design files are stored in Git LFS, the only way to retrieve their original
# files is to first fetch the LFS pointer file data from the Git design repository.
# The LFS pointer file data contains an "OID" that lets us retrieve `LfsObject`
# records, which have an Uploader (`LfsObjectUploader`) for the original design file.
def raw_files_by_path
@raw_files_by_path ||= begin
LfsObject.for_oids(blobs_by_oid.keys).each_with_object({}) do |lfs_object, h|
blob = blobs_by_oid[lfs_object.oid]
file = lfs_object.file.file
# The `CarrierWave::SanitizedFile` is loaded without knowing the `content_type`
# of the file, due to the file not having an extension.
#
# Set the content_type from the `Blob`.
file.content_type = blob.content_type
h[blob.path] = file
end
end
end
# Returns the `Blob`s that correspond to the design files in the repository.
#
# All design `Blob`s are LFS Pointer files, and are therefore small amounts
# of data to load.
#
# `Blob`s whose size are above a certain threshold: `MAX_DESIGN_SIZE`
# are filtered out.
def blobs_by_oid
@blobs ||= begin
items = version.designs.map { |design| [version.sha, design.full_path] }
blobs = repository.blobs_at(items)
blobs.reject! { |blob| blob.lfs_size > MAX_DESIGN_SIZE }
blobs.index_by(&:lfs_oid)
end
end
end
end
# frozen_string_literal: true
module DesignManagement
# This Uploader is used to generate and serve the smaller versions of
# the design files.
#
# The original (full-sized) design files are stored in Git LFS, and so
# have a different uploader, `LfsObjectUploader`.
class DesignV432x230Uploader < GitlabUploader
include CarrierWave::MiniMagick
include RecordsUploads::Concern
include ObjectStorage::Concern
prepend ObjectStorage::Extension::RecordsUploads
# We currently cannot resize `image/ico` or `image/svg+xml` mime types.
# See https://gitlab.com/gitlab-org/gitlab/issues/207069
MIME_TYPE_WHITELIST = %w(image/png image/jpeg image/bmp image/gif image/tiff).freeze
process resize_to_fit: [432, 230]
# Allow CarrierWave to reject files without correct mimetypes.
def content_type_whitelist
MIME_TYPE_WHITELIST
end
# Override `GitlabUploader` and always return false, otherwise local
# `LfsObject` files would be deleted.
# https://github.com/carrierwaveuploader/carrierwave/blob/f84672a/lib/carrierwave/uploader/cache.rb#L131-L135
def move_to_cache
false
end
private
def dynamic_segment
File.join(model.class.underscore, mounted_as.to_s, model.id.to_s)
end
end
end
...@@ -441,7 +441,7 @@ ...@@ -441,7 +441,7 @@
:feature_category: :design_management :feature_category: :design_management
:has_external_dependencies: :has_external_dependencies:
:latency_sensitive: :latency_sensitive:
:resource_boundary: :unknown :resource_boundary: :memory
:weight: 1 :weight: 1
:idempotent: :idempotent:
- :name: elastic_batch_project_indexer - :name: elastic_batch_project_indexer
......
...@@ -5,11 +5,15 @@ module DesignManagement ...@@ -5,11 +5,15 @@ module DesignManagement
include ApplicationWorker include ApplicationWorker
feature_category :design_management feature_category :design_management
# Declare this worker as memory bound due to
# `GenerateImageVersionsService` resizing designs
worker_resource_boundary :memory
def perform(version_id) def perform(version_id)
version = DesignManagement::Version.find(version_id) version = DesignManagement::Version.find(version_id)
add_system_note(version) add_system_note(version)
generate_image_versions(version)
rescue ActiveRecord::RecordNotFound => e rescue ActiveRecord::RecordNotFound => e
Sidekiq.logger.warn(e) Sidekiq.logger.warn(e)
end end
...@@ -19,5 +23,11 @@ module DesignManagement ...@@ -19,5 +23,11 @@ module DesignManagement
def add_system_note(version) def add_system_note(version)
SystemNoteService.design_version_added(version) SystemNoteService.design_version_added(version)
end end
def generate_image_versions(version)
return unless ::Feature.enabled?(:design_management_resize_images, version.project)
DesignManagement::GenerateImageVersionsService.new(version).execute
end
end end
end end
# frozen_string_literal: true
module EE
module Gitlab
module Uploads
module MigrationHelper
extend ActiveSupport::Concern
EE_CATEGORIES = [
%w(DesignManagement::DesignV432x230Uploader DesignManagement::Action :image_v432x230)
].freeze
class_methods do
extend ::Gitlab::Utils::Override
override :categories
def categories
super + EE_CATEGORIES
end
end
end
end
end
end
# frozen_string_literal: true
FactoryBot.define do
factory :design_action, class: 'DesignManagement::Action' do
design
association :version, factory: :design_version
event { :creation }
trait :with_image_v432x230 do
image_v432x230 { fixture_file_upload('spec/fixtures/dk.png') }
end
end
end
...@@ -53,11 +53,20 @@ FactoryBot.define do ...@@ -53,11 +53,20 @@ FactoryBot.define do
design.clear_version_cache design.clear_version_cache
end end
# Use this trait to build designs that are backed by Git LFS, committed
# to the repository, and with an LfsObject correctly created for it.
trait :with_lfs_file do trait :with_lfs_file do
with_file with_file
transient do transient do
file { Gitlab::Git::LfsPointerFile.new('').pointer } raw_file { fixture_file_upload('spec/fixtures/dk.png', 'image/png') }
lfs_pointer { Gitlab::Git::LfsPointerFile.new(SecureRandom.random_bytes) }
file { lfs_pointer.pointer }
end
after :create do |design, evaluator|
lfs_object = create(:lfs_object, file: evaluator.raw_file, oid: evaluator.lfs_pointer.sha256, size: evaluator.lfs_pointer.size)
create(:lfs_objects_project, project: design.project, lfs_object: lfs_object, repository_type: :design)
end end
end end
...@@ -82,8 +91,8 @@ FactoryBot.define do ...@@ -82,8 +91,8 @@ FactoryBot.define do
end end
end end
# Use this trait if you want your designs to be as true-to-life as possible, # Use this trait to build designs that have commits in the repository
# with correctly made commits in the repository and files that can be retrieved. # and files that can be retrieved.
trait :with_file do trait :with_file do
transient do transient do
deleted { false } deleted { false }
......
...@@ -71,6 +71,23 @@ FactoryBot.define do ...@@ -71,6 +71,23 @@ FactoryBot.define do
version.actions.reset version.actions.reset
end end
# Use this trait to build versions with designs that are backed by Git LFS, committed
# to the repository, and with an LfsObject correctly created for it.
trait :with_lfs_file do
committed
transient do
raw_file { fixture_file_upload('spec/fixtures/dk.png', 'image/png') }
lfs_pointer { Gitlab::Git::LfsPointerFile.new(SecureRandom.random_bytes) }
file { lfs_pointer.pointer }
end
after :create do |version, evaluator|
lfs_object = create(:lfs_object, file: evaluator.raw_file, oid: evaluator.lfs_pointer.sha256, size: evaluator.lfs_pointer.size)
create(:lfs_objects_project, project: version.project, lfs_object: lfs_object, repository_type: :design)
end
end
# This trait is for versions that must be present in the git repository. # This trait is for versions that must be present in the git repository.
trait :committed do trait :committed do
transient do transient do
......
# frozen_string_literal: true
FactoryBot.modify do
factory :upload do
trait :design_action_image_v432x230_upload do
mount_point { :image_v432x230 }
model { create(:design_action) }
uploader { ::DesignManagement::DesignV432x230Uploader.name }
end
end
end
...@@ -56,7 +56,7 @@ describe DesignManagement::Version do ...@@ -56,7 +56,7 @@ describe DesignManagement::Version do
expect(described_class.earlier_or_equal_to(version_2)).to contain_exactly(version_1, version_2) expect(described_class.earlier_or_equal_to(version_2)).to contain_exactly(version_1, version_2)
end end
it 'can be passed either a DesignManagement::Design or an ID' do it 'can be passed either a DesignManagement::Version or an ID' do
[version_1, version_1.id].each do |arg| [version_1, version_1.id].each do |arg|
expect(described_class.earlier_or_equal_to(arg)).to eq([version_1]) expect(described_class.earlier_or_equal_to(arg)).to eq([version_1])
end end
......
# frozen_string_literal: true
require 'spec_helper'
describe DesignManagement::GenerateImageVersionsService do
let_it_be(:project) { create(:project) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:version) { create(:design, :with_lfs_file, issue: issue).versions.first }
let_it_be(:action) { version.actions.first }
describe '#execute' do
it 'generates the image' do
expect { described_class.new(version).execute }
.to change { action.reload.image_v432x230.file }
.from(nil).to(CarrierWave::SanitizedFile)
end
it 'skips generating image versions if the design file size is too large' do
stub_const("#{described_class.name}::MAX_DESIGN_SIZE", 1.byte)
described_class.new(version).execute
expect(action.reload.image_v432x230.file).to eq(nil)
end
it 'returns the status' do
result = described_class.new(version).execute
expect(result[:status]).to eq(:success)
end
it 'returns the version' do
result = described_class.new(version).execute
expect(result[:version]).to eq(version)
end
it 'logs if the raw image cannot be found' do
version.designs.first.update(filename: 'foo.png')
expect(Gitlab::AppLogger).to receive(:error).with("No design file found for Action: #{action.id}")
described_class.new(version).execute
end
context 'when an error is encountered when generating the image versions' do
before do
expect_next_instance_of(DesignManagement::DesignV432x230Uploader) do |uploader|
expect(uploader).to receive(:cache!).and_raise(CarrierWave::DownloadError, 'foo')
end
end
it 'logs the error' do
expect(Gitlab::AppLogger).to receive(:error).with('foo')
described_class.new(version).execute
end
it 'tracks the error' do
expect(Gitlab::ErrorTracking).to receive(:track_exception).with(
instance_of(CarrierWave::DownloadError),
project_id: project.id, version_id: version.id, design_id: version.designs.first.id
)
described_class.new(version).execute
end
end
end
end
# frozen_string_literal: true
require 'rake_helper'
describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do
let(:batch_size) { 3 }
before do
stub_env('MIGRATION_BATCH_SIZE', batch_size.to_s)
stub_uploads_object_storage(uploader_class)
Rake.application.rake_require 'tasks/gitlab/uploads/migrate'
allow(ObjectStorage::MigrateUploadsWorker).to receive(:perform_async)
end
context "for DesignManagement::DesignV432x230Uploader" do
let(:uploader_class) { DesignManagement::DesignV432x230Uploader }
let(:model_class) { DesignManagement::Action }
let(:mounted_as) { :image_v432x230 }
before do
create_list(:design_action, 10, :with_image_v432x230)
end
it_behaves_like 'enqueue upload migration jobs in batch', batch: 4
end
end
# frozen_string_literal: true
require 'spec_helper'
describe DesignManagement::DesignV432x230Uploader do
include CarrierWave::Test::Matchers
let(:model) { create(:design_action, :with_image_v432x230) }
let(:upload) { create(:upload, :design_action_image_v432x230_upload, model: model) }
subject(:uploader) { described_class.new(model, :image_v432x230) }
it_behaves_like 'builds correct paths',
store_dir: %r[uploads/-/system/design_management/action/image_v432x230/],
upload_path: %r[uploads/-/system/design_management/action/image_v432x230/],
relative_path: %r[uploads/-/system/design_management/action/image_v432x230/],
absolute_path: %r[#{CarrierWave.root}/uploads/-/system/design_management/action/image_v432x230/]
context 'object_store is REMOTE' do
before do
stub_uploads_object_storage
end
include_context 'with storage', described_class::Store::REMOTE
it_behaves_like 'builds correct paths',
store_dir: %r[design_management/action/image_v432x230/],
upload_path: %r[design_management/action/image_v432x230/],
relative_path: %r[design_management/action/image_v432x230/]
end
describe "#migrate!" do
before do
uploader.store!(fixture_file_upload('spec/fixtures/dk.png'))
stub_uploads_object_storage
end
it_behaves_like 'migrates', to_store: described_class::Store::REMOTE
it_behaves_like 'migrates', from_store: described_class::Store::REMOTE, to_store: described_class::Store::LOCAL
end
it 'resizes images', :aggregate_failures do
image_loader = CarrierWave::Test::Matchers::ImageLoader
original_file = fixture_file_upload('spec/fixtures/dk.png')
uploader.store!(original_file)
expect(
image_loader.load_image(original_file.tempfile.path)
).to have_attributes(
width: 460,
height: 322
)
expect(
image_loader.load_image(uploader.file.file)
).to have_attributes(
width: 329,
height: 230
)
end
context 'uploading a whitelisted file extension' do
it 'stores the image successfully' do
fixture_file = fixture_file_upload('spec/fixtures/dk.png')
expect { uploader.cache!(fixture_file) }.to change { uploader.file }.from(nil).to(kind_of(CarrierWave::SanitizedFile))
end
end
context 'uploading a non-whitelisted file' do
it 'will deny the upload' do
fixture_file = fixture_file_upload('spec/fixtures/logo_sample.svg', 'image/svg+xml')
expect { uploader.cache!(fixture_file) }.to raise_exception(
CarrierWave::IntegrityError,
'You are not allowed to upload image/svg+xml files'
)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe ObjectStorage::MigrateUploadsWorker do
let(:model_class) { Project }
let(:uploads) { Upload.all }
let(:to_store) { ObjectStorage::Store::REMOTE }
context 'for DesignManagement::DesignV432x230Uploader' do
let(:model_class) { DesignManagement::Action }
let!(:design_actions) { create_list(:design_action, 10, :with_image_v432x230) }
let(:mounted_as) { :image_v432x230 }
before do
stub_uploads_object_storage(DesignManagement::DesignV432x230Uploader)
end
it_behaves_like 'uploads migration worker'
end
end
...@@ -6,6 +6,10 @@ describe DesignManagement::NewVersionWorker do ...@@ -6,6 +6,10 @@ describe DesignManagement::NewVersionWorker do
describe '#perform' do describe '#perform' do
let(:worker) { described_class.new } let(:worker) { described_class.new }
before do
stub_feature_flags(design_management_resize_images: true)
end
context 'the id is wrong or out-of-date' do context 'the id is wrong or out-of-date' do
let(:version_id) { -1 } let(:version_id) { -1 }
...@@ -15,6 +19,12 @@ describe DesignManagement::NewVersionWorker do ...@@ -15,6 +19,12 @@ describe DesignManagement::NewVersionWorker do
worker.perform(version_id) worker.perform(version_id)
end end
it 'does not invoke GenerateImageVersionsService' do
expect(DesignManagement::GenerateImageVersionsService).not_to receive(:new)
worker.perform(version_id)
end
it 'logs the reason for this failure' do it 'logs the reason for this failure' do
expect(Sidekiq.logger).to receive(:warn) expect(Sidekiq.logger).to receive(:warn)
.with(an_instance_of(ActiveRecord::RecordNotFound)) .with(an_instance_of(ActiveRecord::RecordNotFound))
...@@ -24,23 +34,43 @@ describe DesignManagement::NewVersionWorker do ...@@ -24,23 +34,43 @@ describe DesignManagement::NewVersionWorker do
end end
context 'the version id is valid' do context 'the version id is valid' do
let_it_be(:version) { create(:design_version, :committed, designs_count: 2) } let_it_be(:version) { create(:design_version, :with_lfs_file, designs_count: 2) }
it 'creates a system note' do it 'creates a system note' do
expect { worker.perform(version.id) }.to change { Note.system.count }.by(1) expect { worker.perform(version.id) }.to change { Note.system.count }.by(1)
end end
it 'invokes GenerateImageVersionsService' do
expect_next_instance_of(DesignManagement::GenerateImageVersionsService) do |service|
expect(service).to receive(:execute)
end
worker.perform(version.id)
end
it 'does not log anything' do it 'does not log anything' do
expect(Sidekiq.logger).not_to receive(:warn) expect(Sidekiq.logger).not_to receive(:warn)
worker.perform(version.id) worker.perform(version.id)
end end
context 'when the `design_management_resize_images` flag is disabled' do
before do
stub_feature_flags(design_management_resize_images: false)
end
it 'does not invoke GenerateImageVersionsService' do
expect(DesignManagement::GenerateImageVersionsService).not_to receive(:new)
worker.perform(version.id)
end
end
end end
context 'the version includes multiple types of action' do context 'the version includes multiple types of action' do
let_it_be(:version) do let_it_be(:version) do
create(:design_version, :committed, create(:design_version, :with_lfs_file,
created_designs: create_list(:design, 1), created_designs: create_list(:design, 1, :with_lfs_file),
modified_designs: create_list(:design, 1)) modified_designs: create_list(:design, 1))
end end
......
# frozen_string_literal: true # frozen_string_literal: true
# The method `filename` must be defined in classes that use this module. # The method `filename` must be defined in classes that mix in this module.
# #
# This module is intended to be used as a helper and not a security gate # This module is intended to be used as a helper and not a security gate
# to validate that a file is safe, as it identifies files only by the # to validate that a file is safe, as it identifies files only by the
...@@ -35,6 +35,13 @@ module Gitlab ...@@ -35,6 +35,13 @@ module Gitlab
DANGEROUS_VIDEO_EXT = [].freeze # None, yet DANGEROUS_VIDEO_EXT = [].freeze # None, yet
DANGEROUS_AUDIO_EXT = [].freeze # None, yet DANGEROUS_AUDIO_EXT = [].freeze # None, yet
def self.extension_match?(filename, extensions)
return false unless filename.present?
extension = File.extname(filename).delete('.')
extensions.include?(extension.downcase)
end
def image? def image?
extension_match?(SAFE_IMAGE_EXT) extension_match?(SAFE_IMAGE_EXT)
end end
...@@ -74,10 +81,7 @@ module Gitlab ...@@ -74,10 +81,7 @@ module Gitlab
private private
def extension_match?(extensions) def extension_match?(extensions)
return false unless filename ::Gitlab::FileTypeDetection.extension_match?(filename, extensions)
extension = File.extname(filename).delete('.')
extensions.include?(extension.downcase)
end end
end end
end end
...@@ -377,3 +377,6 @@ ee: ...@@ -377,3 +377,6 @@ ee:
- protected_environments: - protected_environments:
- :deploy_access_levels - :deploy_access_levels
- :service_desk_setting - :service_desk_setting
excluded_attributes:
actions:
- image_v432x230
...@@ -21,6 +21,10 @@ module Gitlab ...@@ -21,6 +21,10 @@ module Gitlab
prepare_variables(args, logger) prepare_variables(args, logger)
end end
def self.categories
CATEGORIES
end
def migrate_to_remote_storage def migrate_to_remote_storage
@to_store = ObjectStorage::Store::REMOTE @to_store = ObjectStorage::Store::REMOTE
...@@ -70,3 +74,5 @@ module Gitlab ...@@ -70,3 +74,5 @@ module Gitlab
end end
end end
end end
Gitlab::Uploads::MigrationHelper.prepend_if_ee('EE::Gitlab::Uploads::MigrationHelper')
...@@ -3,7 +3,7 @@ namespace :gitlab do ...@@ -3,7 +3,7 @@ namespace :gitlab do
namespace :migrate do namespace :migrate do
desc "GitLab | Uploads | Migrate all uploaded files to object storage" desc "GitLab | Uploads | Migrate all uploaded files to object storage"
task all: :environment do task all: :environment do
Gitlab::Uploads::MigrationHelper::CATEGORIES.each do |args| Gitlab::Uploads::MigrationHelper.categories.each do |args|
Rake::Task["gitlab:uploads:migrate"].invoke(*args) Rake::Task["gitlab:uploads:migrate"].invoke(*args)
Rake::Task["gitlab:uploads:migrate"].reenable Rake::Task["gitlab:uploads:migrate"].reenable
end end
...@@ -20,7 +20,7 @@ namespace :gitlab do ...@@ -20,7 +20,7 @@ namespace :gitlab do
namespace :migrate_to_local do namespace :migrate_to_local do
desc "GitLab | Uploads | Migrate all uploaded files to local storage" desc "GitLab | Uploads | Migrate all uploaded files to local storage"
task all: :environment do task all: :environment do
Gitlab::Uploads::MigrationHelper::CATEGORIES.each do |args| Gitlab::Uploads::MigrationHelper.categories.each do |args|
Rake::Task["gitlab:uploads:migrate_to_local"].invoke(*args) Rake::Task["gitlab:uploads:migrate_to_local"].invoke(*args)
Rake::Task["gitlab:uploads:migrate_to_local"].reenable Rake::Task["gitlab:uploads:migrate_to_local"].reenable
end end
......
...@@ -2,6 +2,35 @@ ...@@ -2,6 +2,35 @@
require 'spec_helper' require 'spec_helper'
describe Gitlab::FileTypeDetection do describe Gitlab::FileTypeDetection do
describe '.extension_match?' do
let(:extensions) { %w[foo bar] }
it 'returns false when filename is blank' do
expect(described_class.extension_match?(nil, extensions)).to eq(false)
expect(described_class.extension_match?('', extensions)).to eq(false)
end
it 'returns true when filename matches extensions' do
expect(described_class.extension_match?('file.foo', extensions)).to eq(true)
expect(described_class.extension_match?('file.bar', extensions)).to eq(true)
end
it 'returns false when filename does not match extensions' do
expect(described_class.extension_match?('file.baz', extensions)).to eq(false)
end
it 'can match case insensitive filenames' do
expect(described_class.extension_match?('file.FOO', extensions)).to eq(true)
end
it 'can match filenames with periods' do
expect(described_class.extension_match?('my.file.foo', extensions)).to eq(true)
end
it 'can match filenames with directories' do
expect(described_class.extension_match?('my/file.foo', extensions)).to eq(true)
end
end
context 'when class is an uploader' do context 'when class is an uploader' do
let(:uploader) do let(:uploader) do
example_uploader = Class.new(CarrierWave::Uploader::Base) do example_uploader = Class.new(CarrierWave::Uploader::Base) do
......
...@@ -567,6 +567,8 @@ designs: *design ...@@ -567,6 +567,8 @@ designs: *design
actions: actions:
- design - design
- version - version
- uploads
- file_uploads
versions: &version versions: &version
- author - author
- issue - issue
......
...@@ -769,7 +769,9 @@ DesignManagement::Design: ...@@ -769,7 +769,9 @@ DesignManagement::Design:
- project_id - project_id
- filename - filename
DesignManagement::Action: DesignManagement::Action:
- id
- event - event
- image_v432x230
DesignManagement::Version: DesignManagement::Version:
- id - id
- created_at - created_at
......
...@@ -14,7 +14,7 @@ describe LfsObject do ...@@ -14,7 +14,7 @@ describe LfsObject do
end end
end end
describe 'for_oids' do describe '.for_oids' do
it 'returns the correct LfsObjects' do it 'returns the correct LfsObjects' do
lfs_object_1, lfs_object_2 = create_list(:lfs_object, 2) lfs_object_1, lfs_object_2 = create_list(:lfs_object, 2)
......
# frozen_string_literal: true
# Expects the calling spec to define:
# - uploader_class
# - model_class
# - mounted_as
RSpec.shared_examples 'enqueue upload migration jobs in batch' do |batch:|
def run(task)
args = [uploader_class.to_s, model_class.to_s, mounted_as].compact
run_rake_task(task, *args)
end
it 'migrates local storage to remote object storage' do
expect(ObjectStorage::MigrateUploadsWorker)
.to receive(:perform_async).exactly(batch).times
.and_return("A fake job.")
run('gitlab:uploads:migrate')
end
it 'migrates remote object storage to local storage' do
expect(Upload).to receive(:where).exactly(batch + 1).times { Upload.all }
expect(ObjectStorage::MigrateUploadsWorker)
.to receive(:perform_async)
.with(anything, model_class.name, mounted_as, ObjectStorage::Store::LOCAL)
.exactly(batch).times
.and_return("A fake job.")
run('gitlab:uploads:migrate_to_local')
end
end
# frozen_string_literal: true
# Expects the calling spec to define:
# - model_class
# - mounted_as
# - to_store
RSpec.shared_examples 'uploads migration worker' do
def perform(uploads, store = nil)
described_class.new.perform(uploads.ids, model_class.to_s, mounted_as, store || to_store)
rescue ObjectStorage::MigrateUploadsWorker::Report::MigrationFailures
# swallow
end
describe '.enqueue!' do
def enqueue!
described_class.enqueue!(uploads, model_class, mounted_as, to_store)
end
it 'is guarded by .sanity_check!' do
expect(described_class).to receive(:perform_async)
expect(described_class).to receive(:sanity_check!)
enqueue!
end
context 'sanity_check! fails' do
include_context 'sanity_check! fails'
it 'does not enqueue a job' do
expect(described_class).not_to receive(:perform_async)
expect { enqueue! }.to raise_error(described_class::SanityCheckError)
end
end
end
describe '.sanity_check!' do
shared_examples 'raises a SanityCheckError' do |expected_message|
let(:mount_point) { nil }
it do
expect { described_class.sanity_check!(uploads, model_class, mount_point) }
.to raise_error(described_class::SanityCheckError).with_message(expected_message)
end
end
context 'uploader types mismatch' do
let!(:outlier) { create(:upload, uploader: 'GitlabUploader') }
include_examples 'raises a SanityCheckError', /Multiple uploaders found/
end
context 'mount point not found' do
include_examples 'raises a SanityCheckError', /Mount point [a-z:]+ not found in/ do
let(:mount_point) { :potato }
end
end
end
describe '#perform' do
shared_examples 'outputs correctly' do |success: 0, failures: 0|
total = success + failures
if success > 0
it 'outputs the reports' do
expect(Rails.logger).to receive(:info).with(%r{Migrated #{success}/#{total} files})
perform(uploads)
end
end
if failures > 0
it 'outputs upload failures' do
expect(Rails.logger).to receive(:warn).with(/Error .* I am a teapot/)
perform(uploads)
end
end
end
it_behaves_like 'outputs correctly', success: 10
it 'migrates files to remote storage' do
perform(uploads)
expect(Upload.where(store: ObjectStorage::Store::LOCAL).count).to eq(0)
end
context 'reversed' do
let(:to_store) { ObjectStorage::Store::LOCAL }
before do
perform(uploads, ObjectStorage::Store::REMOTE)
end
it 'migrates files to local storage' do
expect(Upload.where(store: ObjectStorage::Store::REMOTE).count).to eq(10)
perform(uploads)
expect(Upload.where(store: ObjectStorage::Store::LOCAL).count).to eq(10)
end
end
context 'migration is unsuccessful' do
before do
allow_any_instance_of(ObjectStorage::Concern)
.to receive(:migrate!).and_raise(CarrierWave::UploadError, 'I am a teapot.')
end
it_behaves_like 'outputs correctly', failures: 10
end
end
end
RSpec.shared_context 'sanity_check! fails' do
before do
expect(described_class).to receive(:sanity_check!).and_raise(described_class::SanityCheckError)
end
end
...@@ -16,32 +16,6 @@ describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do ...@@ -16,32 +16,6 @@ describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do
allow(ObjectStorage::MigrateUploadsWorker).to receive(:perform_async) allow(ObjectStorage::MigrateUploadsWorker).to receive(:perform_async)
end end
def run(task)
args = [uploader_class.to_s, model_class.to_s, mounted_as].compact
run_rake_task(task, *args)
end
shared_examples 'enqueue jobs in batch' do |batch:|
it 'migrates local storage to remote object storage' do
expect(ObjectStorage::MigrateUploadsWorker)
.to receive(:perform_async).exactly(batch).times
.and_return("A fake job.")
run('gitlab:uploads:migrate')
end
it 'migrates remote object storage to local storage' do
expect(Upload).to receive(:where).exactly(batch + 1).times { Upload.all }
expect(ObjectStorage::MigrateUploadsWorker)
.to receive(:perform_async)
.with(anything, model_class.name, mounted_as, ObjectStorage::Store::LOCAL)
.exactly(batch).times
.and_return("A fake job.")
run('gitlab:uploads:migrate_to_local')
end
end
context "for AvatarUploader" do context "for AvatarUploader" do
let(:uploader_class) { AvatarUploader } let(:uploader_class) { AvatarUploader }
let(:mounted_as) { :avatar } let(:mounted_as) { :avatar }
...@@ -50,7 +24,7 @@ describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do ...@@ -50,7 +24,7 @@ describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do
let(:model_class) { Project } let(:model_class) { Project }
let!(:projects) { create_list(:project, 10, :with_avatar) } let!(:projects) { create_list(:project, 10, :with_avatar) }
it_behaves_like 'enqueue jobs in batch', batch: 4 it_behaves_like 'enqueue upload migration jobs in batch', batch: 4
end end
context "for Group" do context "for Group" do
...@@ -60,7 +34,7 @@ describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do ...@@ -60,7 +34,7 @@ describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do
create_list(:group, 10, :with_avatar) create_list(:group, 10, :with_avatar)
end end
it_behaves_like 'enqueue jobs in batch', batch: 4 it_behaves_like 'enqueue upload migration jobs in batch', batch: 4
end end
context "for User" do context "for User" do
...@@ -70,7 +44,7 @@ describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do ...@@ -70,7 +44,7 @@ describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do
create_list(:user, 10, :with_avatar) create_list(:user, 10, :with_avatar)
end end
it_behaves_like 'enqueue jobs in batch', batch: 4 it_behaves_like 'enqueue upload migration jobs in batch', batch: 4
end end
end end
...@@ -85,7 +59,7 @@ describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do ...@@ -85,7 +59,7 @@ describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do
create_list(:note, 10, :with_attachment) create_list(:note, 10, :with_attachment)
end end
it_behaves_like 'enqueue jobs in batch', batch: 4 it_behaves_like 'enqueue upload migration jobs in batch', batch: 4
end end
context "for Appearance" do context "for Appearance" do
...@@ -97,7 +71,7 @@ describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do ...@@ -97,7 +71,7 @@ describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do
end end
%i(logo header_logo).each do |mount| %i(logo header_logo).each do |mount|
it_behaves_like 'enqueue jobs in batch', batch: 1 do it_behaves_like 'enqueue upload migration jobs in batch', batch: 1 do
let(:mounted_as) { mount } let(:mounted_as) { mount }
end end
end end
...@@ -115,7 +89,7 @@ describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do ...@@ -115,7 +89,7 @@ describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do
end end
end end
it_behaves_like 'enqueue jobs in batch', batch: 4 it_behaves_like 'enqueue upload migration jobs in batch', batch: 4
end end
context "for PersonalFileUploader" do context "for PersonalFileUploader" do
...@@ -129,7 +103,7 @@ describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do ...@@ -129,7 +103,7 @@ describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do
end end
end end
it_behaves_like 'enqueue jobs in batch', batch: 4 it_behaves_like 'enqueue upload migration jobs in batch', batch: 4
end end
context "for NamespaceFileUploader" do context "for NamespaceFileUploader" do
...@@ -143,6 +117,6 @@ describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do ...@@ -143,6 +117,6 @@ describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do
end end
end end
it_behaves_like 'enqueue jobs in batch', batch: 4 it_behaves_like 'enqueue upload migration jobs in batch', batch: 4
end end
end end
...@@ -3,12 +3,6 @@ ...@@ -3,12 +3,6 @@
require 'spec_helper' require 'spec_helper'
describe ObjectStorage::MigrateUploadsWorker do describe ObjectStorage::MigrateUploadsWorker do
shared_context 'sanity_check! fails' do
before do
expect(described_class).to receive(:sanity_check!).and_raise(described_class::SanityCheckError)
end
end
let(:model_class) { Project } let(:model_class) { Project }
let(:uploads) { Upload.all } let(:uploads) { Upload.all }
let(:to_store) { ObjectStorage::Store::REMOTE } let(:to_store) { ObjectStorage::Store::REMOTE }
...@@ -19,109 +13,6 @@ describe ObjectStorage::MigrateUploadsWorker do ...@@ -19,109 +13,6 @@ describe ObjectStorage::MigrateUploadsWorker do
# swallow # swallow
end end
shared_examples "uploads migration worker" do
describe '.enqueue!' do
def enqueue!
described_class.enqueue!(uploads, Project, mounted_as, to_store)
end
it 'is guarded by .sanity_check!' do
expect(described_class).to receive(:perform_async)
expect(described_class).to receive(:sanity_check!)
enqueue!
end
context 'sanity_check! fails' do
include_context 'sanity_check! fails'
it 'does not enqueue a job' do
expect(described_class).not_to receive(:perform_async)
expect { enqueue! }.to raise_error(described_class::SanityCheckError)
end
end
end
describe '.sanity_check!' do
shared_examples 'raises a SanityCheckError' do |expected_message|
let(:mount_point) { nil }
it do
expect { described_class.sanity_check!(uploads, model_class, mount_point) }
.to raise_error(described_class::SanityCheckError).with_message(expected_message)
end
end
context 'uploader types mismatch' do
let!(:outlier) { create(:upload, uploader: 'GitlabUploader') }
include_examples 'raises a SanityCheckError', /Multiple uploaders found/
end
context 'mount point not found' do
include_examples 'raises a SanityCheckError', /Mount point [a-z:]+ not found in/ do
let(:mount_point) { :potato }
end
end
end
describe '#perform' do
shared_examples 'outputs correctly' do |success: 0, failures: 0|
total = success + failures
if success > 0
it 'outputs the reports' do
expect(Rails.logger).to receive(:info).with(%r{Migrated #{success}/#{total} files})
perform(uploads)
end
end
if failures > 0
it 'outputs upload failures' do
expect(Rails.logger).to receive(:warn).with(/Error .* I am a teapot/)
perform(uploads)
end
end
end
it_behaves_like 'outputs correctly', success: 10
it 'migrates files to remote storage' do
perform(uploads)
expect(Upload.where(store: ObjectStorage::Store::LOCAL).count).to eq(0)
end
context 'reversed' do
let(:to_store) { ObjectStorage::Store::LOCAL }
before do
perform(uploads, ObjectStorage::Store::REMOTE)
end
it 'migrates files to local storage' do
expect(Upload.where(store: ObjectStorage::Store::REMOTE).count).to eq(10)
perform(uploads)
expect(Upload.where(store: ObjectStorage::Store::LOCAL).count).to eq(10)
end
end
context 'migration is unsuccessful' do
before do
allow_any_instance_of(ObjectStorage::Concern)
.to receive(:migrate!).and_raise(CarrierWave::UploadError, "I am a teapot.")
end
it_behaves_like 'outputs correctly', failures: 10
end
end
end
context "for AvatarUploader" do context "for AvatarUploader" do
let!(:projects) { create_list(:project, 10, :with_avatar) } let!(:projects) { create_list(:project, 10, :with_avatar) }
let(:mounted_as) { :avatar } let(:mounted_as) { :avatar }
......
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