Commit cfa5c307 authored by Rémy Coutable's avatar Rémy Coutable

Merge branch '24704-restore-download-repository-path' into 'master'

Restore "download repository path" feature, now with fixes and a feature flag

Closes gitlab-workhorse#218 and #24704

See merge request gitlab-org/gitlab-ce!27275
parents 128f91fc e052d49a
...@@ -351,6 +351,10 @@ ...@@ -351,6 +351,10 @@
// Expects up to 3 digits on the badge // Expects up to 3 digits on the badge
margin-right: 40px; margin-right: 40px;
} }
.dropdown-menu-content {
padding: $dropdown-item-padding-y $dropdown-item-padding-x;
}
} }
.droplab-dropdown { .droplab-dropdown {
......
...@@ -23,7 +23,7 @@ class Projects::RepositoriesController < Projects::ApplicationController ...@@ -23,7 +23,7 @@ class Projects::RepositoriesController < Projects::ApplicationController
append_sha = false if @filename == shortname append_sha = false if @filename == shortname
end end
send_git_archive @repository, ref: @ref, format: params[:format], append_sha: append_sha send_git_archive @repository, ref: @ref, path: params[:path], format: params[:format], append_sha: append_sha
rescue => ex rescue => ex
logger.error("#{self.class.name}: #{ex}") logger.error("#{self.class.name}: #{ex}")
git_not_found! git_not_found!
......
...@@ -299,6 +299,10 @@ module ProjectsHelper ...@@ -299,6 +299,10 @@ module ProjectsHelper
}.to_json }.to_json
end end
def directory?
@path.present?
end
def external_classification_label_help_message def external_classification_label_help_message
default_label = ::Gitlab::CurrentSettings.current_application_settings default_label = ::Gitlab::CurrentSettings.current_application_settings
.external_authorization_service_default_label .external_authorization_service_default_label
......
...@@ -299,13 +299,14 @@ class Repository ...@@ -299,13 +299,14 @@ class Repository
end end
end end
def archive_metadata(ref, storage_path, format = "tar.gz", append_sha:) def archive_metadata(ref, storage_path, format = "tar.gz", append_sha:, path: nil)
raw_repository.archive_metadata( raw_repository.archive_metadata(
ref, ref,
storage_path, storage_path,
project.path, project.path,
format, format,
append_sha: append_sha append_sha: append_sha,
path: path
) )
end end
......
...@@ -7,31 +7,22 @@ ...@@ -7,31 +7,22 @@
= sprite_icon('download') = sprite_icon('download')
%span.sr-only= _('Select Archive Format') %span.sr-only= _('Select Archive Format')
= sprite_icon("arrow-down") = sprite_icon("arrow-down")
%ul.dropdown-menu.dropdown-menu-right{ role: 'menu' } .dropdown-menu.dropdown-menu-right{ role: 'menu' }
%li.dropdown-header %section
#{ _('Source code') } %h5.m-0.dropdown-bold-header= _('Download source code')
%li .dropdown-menu-content
= link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'zip'), rel: 'nofollow', download: '' do = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: nil
%span= _('Download zip') - if directory? && Feature.enabled?(:git_archive_path, default_enabled: true)
%li %section.border-top.pt-1.mt-1
= link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar.gz'), rel: 'nofollow', download: '' do %h5.m-0.dropdown-bold-header= _('Download this directory')
%span= _('Download tar.gz') .dropdown-menu-content
%li = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: @path
= link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar.bz2'), rel: 'nofollow', download: '' do
%span= _('Download tar.bz2')
%li
= link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar'), rel: 'nofollow', download: '' do
%span= _('Download tar')
- if pipeline && pipeline.latest_builds_with_artifacts.any? - if pipeline && pipeline.latest_builds_with_artifacts.any?
%li.dropdown-header Artifacts %section.border-top.pt-1.mt-1
- unless pipeline.latest? %h5.m-0.dropdown-bold-header= _('Download artifacts')
- latest_pipeline = project.pipeline_for(ref) - unless pipeline.latest?
%li %span.unclickable= ci_status_for_statuseable(project.pipeline_for(ref))
.unclickable= ci_status_for_statuseable(latest_pipeline) %h6.m-0.dropdown-header= _('Previous Artifacts')
%li.dropdown-header Previous Artifacts %ul
- pipeline.latest_builds_with_artifacts.each do |job| - pipeline.latest_builds_with_artifacts.each do |job|
%li %li= link_to job.name, latest_succeeded_project_artifacts_path(project, "#{ref}/download", job: job.name), rel: 'nofollow', download: ''
= link_to latest_succeeded_project_artifacts_path(project, "#{ref}/download", job: job.name), rel: 'nofollow', download: '' do
%span
#{s_('DownloadArtifacts|Download')} '#{job.name}'
- formats = [['zip', 'btn-primary'], ['tar.gz'], ['tar.bz2'], ['tar']]
.d-flex.justify-content-between
- formats.each do |(fmt, extra_class)|
= link_to fmt, project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: fmt), rel: 'nofollow', download: '', class: "btn btn-xs #{extra_class}"
---
title: Download a folder from repository
merge_request: 26532
author: kiameisomabes
type: added
...@@ -241,4 +241,24 @@ Projects that contain a `.xcodeproj` or `.xcworkspace` directory can now be clon ...@@ -241,4 +241,24 @@ Projects that contain a `.xcodeproj` or `.xcworkspace` directory can now be clon
in Xcode using the new **Open in Xcode** button, located next to the Git URL in Xcode using the new **Open in Xcode** button, located next to the Git URL
used for cloning your project. The button is only shown on macOS. used for cloning your project. The button is only shown on macOS.
## Download Source Code
Source code stored in the repository can be downloaded.
By clicking the download icon, a dropdown will open with links to download the following:
![Download source code](img/download_source_code.png)
- **Source Code:**
This allows users to download the source code on branch they're currently
viewing. Available zip, tar, tar.gz and tar.bz2.
- **Directory:**
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/24704) in GitLab 11.10
Only shows up when viewing a sub-directory. This allows users to download
the specific directory they're currently viewing. Also available in zip, tar,
tar.gz and tar.bz2.
- **Artifacts:**
This allows users to download the artifacts of the latest CI build.
[jupyter]: https://jupyter.org [jupyter]: https://jupyter.org
...@@ -231,12 +231,12 @@ module Gitlab ...@@ -231,12 +231,12 @@ module Gitlab
end end
end end
def archive_metadata(ref, storage_path, project_path, format = "tar.gz", append_sha:) def archive_metadata(ref, storage_path, project_path, format = "tar.gz", append_sha:, path: nil)
ref ||= root_ref ref ||= root_ref
commit = Gitlab::Git::Commit.find(self, ref) commit = Gitlab::Git::Commit.find(self, ref)
return {} if commit.nil? return {} if commit.nil?
prefix = archive_prefix(ref, commit.id, project_path, append_sha: append_sha) prefix = archive_prefix(ref, commit.id, project_path, append_sha: append_sha, path: path)
{ {
'ArchivePrefix' => prefix, 'ArchivePrefix' => prefix,
...@@ -248,13 +248,14 @@ module Gitlab ...@@ -248,13 +248,14 @@ module Gitlab
# This is both the filename of the archive (missing the extension) and the # This is both the filename of the archive (missing the extension) and the
# name of the top-level member of the archive under which all files go # name of the top-level member of the archive under which all files go
def archive_prefix(ref, sha, project_path, append_sha:) def archive_prefix(ref, sha, project_path, append_sha:, path:)
append_sha = (ref != sha) if append_sha.nil? append_sha = (ref != sha) if append_sha.nil?
formatted_ref = ref.tr('/', '-') formatted_ref = ref.tr('/', '-')
prefix_segments = [project_path, formatted_ref] prefix_segments = [project_path, formatted_ref]
prefix_segments << sha if append_sha prefix_segments << sha if append_sha
prefix_segments << path.tr('/', '-').gsub(%r{^/|/$}, '') if path
prefix_segments.join('-') prefix_segments.join('-')
end end
......
...@@ -63,16 +63,34 @@ module Gitlab ...@@ -63,16 +63,34 @@ module Gitlab
] ]
end end
def send_git_archive(repository, ref:, format:, append_sha:) def send_git_archive(repository, ref:, format:, append_sha:, path: nil)
path_enabled = Feature.enabled?(:git_archive_path, default_enabled: true)
path = nil unless path_enabled
format ||= 'tar.gz' format ||= 'tar.gz'
format = format.downcase format = format.downcase
params = repository.archive_metadata(ref, Gitlab.config.gitlab.repository_downloads_path, format, append_sha: append_sha)
raise "Repository or ref not found" if params.empty?
params['GitalyServer'] = gitaly_server_hash(repository) metadata = repository.archive_metadata(
ref,
Gitlab.config.gitlab.repository_downloads_path,
format,
append_sha: append_sha,
path: path
)
# If present DisableCache must be a Boolean. Otherwise workhorse ignores it. raise "Repository or ref not found" if metadata.empty?
params =
if path_enabled
send_git_archive_params(repository, metadata, path, archive_format(format))
else
metadata
end
# If present, DisableCache must be a Boolean. Otherwise
# workhorse ignores it.
params['DisableCache'] = true if git_archive_cache_disabled? params['DisableCache'] = true if git_archive_cache_disabled?
params['GitalyServer'] = gitaly_server_hash(repository)
[ [
SEND_DATA_HEADER, SEND_DATA_HEADER,
...@@ -216,10 +234,19 @@ module Gitlab ...@@ -216,10 +234,19 @@ module Gitlab
protected protected
# This is the outermost encoding of a senddata: header. It is safe for
# inclusion in HTTP response headers
def encode(hash) def encode(hash)
Base64.urlsafe_encode64(JSON.dump(hash)) Base64.urlsafe_encode64(JSON.dump(hash))
end end
# This is for encoding individual fields inside the senddata JSON that
# contain binary data. In workhorse, the corresponding struct field should
# be type []byte
def encode_binary(binary)
Base64.encode64(binary)
end
def gitaly_server_hash(repository) def gitaly_server_hash(repository)
{ {
address: Gitlab::GitalyClient.address(repository.project.repository_storage), address: Gitlab::GitalyClient.address(repository.project.repository_storage),
...@@ -238,6 +265,34 @@ module Gitlab ...@@ -238,6 +265,34 @@ module Gitlab
def git_archive_cache_disabled? def git_archive_cache_disabled?
ENV['WORKHORSE_ARCHIVE_CACHE_DISABLED'].present? || Feature.enabled?(:workhorse_archive_cache_disabled) ENV['WORKHORSE_ARCHIVE_CACHE_DISABLED'].present? || Feature.enabled?(:workhorse_archive_cache_disabled)
end end
def archive_format(format)
case format
when "tar.bz2", "tbz", "tbz2", "tb2", "bz2"
Gitaly::GetArchiveRequest::Format::TAR_BZ2
when "tar"
Gitaly::GetArchiveRequest::Format::TAR
when "zip"
Gitaly::GetArchiveRequest::Format::ZIP
else
Gitaly::GetArchiveRequest::Format::TAR_GZ
end
end
def send_git_archive_params(repository, metadata, path, format)
{
'ArchivePath' => metadata['ArchivePath'],
'GetArchiveRequest' => encode_binary(
Gitaly::GetArchiveRequest.new(
repository: repository.gitaly_repository,
commit_id: metadata['CommitId'],
prefix: metadata['ArchivePrefix'],
format: format,
path: path.presence || ""
).to_proto
)
}
end
end end
end end
end end
...@@ -3298,19 +3298,10 @@ msgstr "" ...@@ -3298,19 +3298,10 @@ msgstr ""
msgid "Download export" msgid "Download export"
msgstr "" msgstr ""
msgid "Download tar" msgid "Download source code"
msgstr "" msgstr ""
msgid "Download tar.bz2" msgid "Download this directory"
msgstr ""
msgid "Download tar.gz"
msgstr ""
msgid "Download zip"
msgstr ""
msgid "DownloadArtifacts|Download"
msgstr "" msgstr ""
msgid "DownloadCommit|Email Patches" msgid "DownloadCommit|Email Patches"
...@@ -6679,6 +6670,9 @@ msgstr "" ...@@ -6679,6 +6670,9 @@ msgstr ""
msgid "Preview payload" msgid "Preview payload"
msgstr "" msgstr ""
msgid "Previous Artifacts"
msgstr ""
msgid "Prioritize" msgid "Prioritize"
msgstr "" msgstr ""
......
...@@ -35,7 +35,7 @@ describe 'Download buttons in branches page' do ...@@ -35,7 +35,7 @@ describe 'Download buttons in branches page' do
it 'shows download artifacts button' do it 'shows download artifacts button' do
href = latest_succeeded_project_artifacts_path(project, 'binary-encoding/download', job: 'build') href = latest_succeeded_project_artifacts_path(project, 'binary-encoding/download', job: 'build')
expect(page).to have_link "Download '#{build.name}'", href: href expect(page).to have_link build.name, href: href
end end
end end
end end
......
...@@ -30,7 +30,7 @@ describe 'Projects > Files > Download buttons in files tree' do ...@@ -30,7 +30,7 @@ describe 'Projects > Files > Download buttons in files tree' do
it 'shows download artifacts button' do it 'shows download artifacts button' do
href = latest_succeeded_project_artifacts_path(project, "#{project.default_branch}/download", job: 'build') href = latest_succeeded_project_artifacts_path(project, "#{project.default_branch}/download", job: 'build')
expect(page).to have_link "Download '#{build.name}'", href: href expect(page).to have_link build.name, href: href
end end
end end
end end
...@@ -35,11 +35,10 @@ describe 'Projects > Show > Download buttons' do ...@@ -35,11 +35,10 @@ describe 'Projects > Show > Download buttons' do
it 'shows download artifacts button' do it 'shows download artifacts button' do
href = latest_succeeded_project_artifacts_path(project, "#{project.default_branch}/download", job: 'build') href = latest_succeeded_project_artifacts_path(project, "#{project.default_branch}/download", job: 'build')
expect(page).to have_link "Download '#{build.name}'", href: href expect(page).to have_link build.name, href: href
end end
it 'download links have download attribute' do it 'download links have download attribute' do
expect(page).to have_selector('a', text: 'Download')
page.all('a', text: 'Download').each do |link| page.all('a', text: 'Download').each do |link|
expect(link[:download]).to eq '' expect(link[:download]).to eq ''
end end
......
...@@ -36,7 +36,7 @@ describe 'Download buttons in tags page' do ...@@ -36,7 +36,7 @@ describe 'Download buttons in tags page' do
it 'shows download artifacts button' do it 'shows download artifacts button' do
href = latest_succeeded_project_artifacts_path(project, "#{tag}/download", job: 'build') href = latest_succeeded_project_artifacts_path(project, "#{tag}/download", job: 'build')
expect(page).to have_link "Download '#{build.name}'", href: href expect(page).to have_link build.name, href: href
end end
end end
end end
......
...@@ -152,13 +152,14 @@ describe Gitlab::Git::Repository, :seed_helper do ...@@ -152,13 +152,14 @@ describe Gitlab::Git::Repository, :seed_helper do
let(:append_sha) { true } let(:append_sha) { true }
let(:ref) { 'master' } let(:ref) { 'master' }
let(:format) { nil } let(:format) { nil }
let(:path) { nil }
let(:expected_extension) { 'tar.gz' } let(:expected_extension) { 'tar.gz' }
let(:expected_filename) { "#{expected_prefix}.#{expected_extension}" } let(:expected_filename) { "#{expected_prefix}.#{expected_extension}" }
let(:expected_path) { File.join(storage_path, cache_key, expected_filename) } let(:expected_path) { File.join(storage_path, cache_key, expected_filename) }
let(:expected_prefix) { "gitlab-git-test-#{ref}-#{SeedRepo::LastCommit::ID}" } let(:expected_prefix) { "gitlab-git-test-#{ref}-#{SeedRepo::LastCommit::ID}" }
subject(:metadata) { repository.archive_metadata(ref, storage_path, 'gitlab-git-test', format, append_sha: append_sha) } subject(:metadata) { repository.archive_metadata(ref, storage_path, 'gitlab-git-test', format, append_sha: append_sha, path: path) }
it 'sets CommitId to the commit SHA' do it 'sets CommitId to the commit SHA' do
expect(metadata['CommitId']).to eq(SeedRepo::LastCommit::ID) expect(metadata['CommitId']).to eq(SeedRepo::LastCommit::ID)
...@@ -176,6 +177,14 @@ describe Gitlab::Git::Repository, :seed_helper do ...@@ -176,6 +177,14 @@ describe Gitlab::Git::Repository, :seed_helper do
expect(metadata['ArchivePath']).to eq(expected_path) expect(metadata['ArchivePath']).to eq(expected_path)
end end
context 'path is set' do
let(:path) { 'foo/bar' }
it 'appends the path to the prefix' do
expect(metadata['ArchivePrefix']).to eq("#{expected_prefix}-foo-bar")
end
end
context 'append_sha varies archive path and filename' do context 'append_sha varies archive path and filename' do
where(:append_sha, :ref, :expected_prefix) do where(:append_sha, :ref, :expected_prefix) do
sha = SeedRepo::LastCommit::ID sha = SeedRepo::LastCommit::ID
......
...@@ -16,40 +16,80 @@ describe Gitlab::Workhorse do ...@@ -16,40 +16,80 @@ describe Gitlab::Workhorse do
let(:ref) { 'master' } let(:ref) { 'master' }
let(:format) { 'zip' } let(:format) { 'zip' }
let(:storage_path) { Gitlab.config.gitlab.repository_downloads_path } let(:storage_path) { Gitlab.config.gitlab.repository_downloads_path }
let(:base_params) { repository.archive_metadata(ref, storage_path, format, append_sha: nil) } let(:path) { 'some/path' if Feature.enabled?(:git_archive_path, default_enabled: true) }
let(:gitaly_params) do let(:metadata) { repository.archive_metadata(ref, storage_path, format, append_sha: nil, path: path) }
base_params.merge(
'GitalyServer' => {
'address' => Gitlab::GitalyClient.address(project.repository_storage),
'token' => Gitlab::GitalyClient.token(project.repository_storage)
},
'GitalyRepository' => repository.gitaly_repository.to_h.deep_stringify_keys
)
end
let(:cache_disabled) { false } let(:cache_disabled) { false }
subject do subject do
described_class.send_git_archive(repository, ref: ref, format: format, append_sha: nil) described_class.send_git_archive(repository, ref: ref, format: format, append_sha: nil, path: path)
end end
before do before do
allow(described_class).to receive(:git_archive_cache_disabled?).and_return(cache_disabled) allow(described_class).to receive(:git_archive_cache_disabled?).and_return(cache_disabled)
end end
it 'sets the header correctly' do context 'feature flag disabled' do
key, command, params = decode_workhorse_header(subject) before do
stub_feature_flags(git_archive_path: false)
end
expect(key).to eq('Gitlab-Workhorse-Send-Data') it 'sets the header correctly' do
expect(command).to eq('git-archive') key, command, params = decode_workhorse_header(subject)
expect(params).to include(gitaly_params)
expected_params = metadata.merge(
'GitalyRepository' => repository.gitaly_repository.to_h,
'GitalyServer' => {
address: Gitlab::GitalyClient.address(project.repository_storage),
token: Gitlab::GitalyClient.token(project.repository_storage)
}
)
expect(key).to eq('Gitlab-Workhorse-Send-Data')
expect(command).to eq('git-archive')
expect(params).to eq(expected_params.deep_stringify_keys)
end
context 'when archive caching is disabled' do
let(:cache_disabled) { true }
it 'tells workhorse not to use the cache' do
_, _, params = decode_workhorse_header(subject)
expect(params).to include({ 'DisableCache' => true })
end
end
end end
context 'when archive caching is disabled' do context 'feature flag enabled' do
let(:cache_disabled) { true } it 'sets the header correctly' do
key, command, params = decode_workhorse_header(subject)
expect(key).to eq('Gitlab-Workhorse-Send-Data')
expect(command).to eq('git-archive')
expect(params).to eq({
'GitalyServer' => {
address: Gitlab::GitalyClient.address(project.repository_storage),
token: Gitlab::GitalyClient.token(project.repository_storage)
},
'ArchivePath' => metadata['ArchivePath'],
'GetArchiveRequest' => Base64.encode64(
Gitaly::GetArchiveRequest.new(
repository: repository.gitaly_repository,
commit_id: metadata['CommitId'],
prefix: metadata['ArchivePrefix'],
format: Gitaly::GetArchiveRequest::Format::ZIP,
path: path
).to_proto
)
}.deep_stringify_keys)
end
it 'tells workhorse not to use the cache' do context 'when archive caching is disabled' do
_, _, params = decode_workhorse_header(subject) let(:cache_disabled) { true }
expect(params).to include({ 'DisableCache' => true })
it 'tells workhorse not to use the cache' do
_, _, params = decode_workhorse_header(subject)
expect(params).to include({ 'DisableCache' => true })
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