diff options
author | Ash McKenzie <amckenzie@gitlab.com> | 2018-07-26 17:49:49 +1000 |
---|---|---|
committer | Ash McKenzie <amckenzie@gitlab.com> | 2018-08-01 00:24:10 +1000 |
commit | aa4d2ba8c109948a13f58787a269205be1abd11d (patch) | |
tree | 1647c9017c3148d64cd491e5a56444f08c3b1040 | |
parent | 28ff59405111209bbf5cd6cb59b4ffd648922a74 (diff) | |
download | gitlab-shell-aa4d2ba8c109948a13f58787a269205be1abd11d.tar.gz |
New Action classes
* Base - contains all common logic
* Gitaly - performs interactions with Gitaly
* API2FARecovery - 2FA recovery code generation
* GitLFSAuthenticate - git-lfs authentication
-rw-r--r-- | lib/action.rb | 7 | ||||
-rw-r--r-- | lib/action/api_2fa_recovery.rb | 54 | ||||
-rw-r--r-- | lib/action/base.rb | 30 | ||||
-rw-r--r-- | lib/action/git_lfs_authenticate.rb | 26 | ||||
-rw-r--r-- | lib/action/gitaly.rb | 119 | ||||
-rw-r--r-- | spec/action/api_2fa_recovery.rb_spec.rb | 73 | ||||
-rw-r--r-- | spec/action/base_spec.rb | 12 | ||||
-rw-r--r-- | spec/action/git_lfs_authenticate_spec.rb | 47 | ||||
-rw-r--r-- | spec/action/gitaly_spec.rb | 133 |
9 files changed, 501 insertions, 0 deletions
diff --git a/lib/action.rb b/lib/action.rb new file mode 100644 index 0000000..1f9cc6c --- /dev/null +++ b/lib/action.rb @@ -0,0 +1,7 @@ +require_relative 'action/base' +require_relative 'action/gitaly' +require_relative 'action/git_lfs_authenticate' +require_relative 'action/api_2fa_recovery' + +module Action +end diff --git a/lib/action/api_2fa_recovery.rb b/lib/action/api_2fa_recovery.rb new file mode 100644 index 0000000..827f8aa --- /dev/null +++ b/lib/action/api_2fa_recovery.rb @@ -0,0 +1,54 @@ +require_relative '../action' +require_relative '../gitlab_logger' + +module Action + class API2FARecovery < Base + def initialize(key_id) + @key_id = key_id + end + + def execute(_, _) + recover + end + + private + + attr_reader :key_id + + def continue?(question) + puts "#{question} (yes/no)" + STDOUT.flush # Make sure the question gets output before we wait for input + response = STDIN.gets.chomp + puts '' # Add a buffer in the output + response == 'yes' + end + + def recover + continue = continue?( + "Are you sure you want to generate new two-factor recovery codes?\n" \ + "Any existing recovery codes you saved will be invalidated." + ) + + unless continue + puts 'New recovery codes have *not* been generated. Existing codes will remain valid.' + return + end + + resp = api.two_factor_recovery_codes(key_id) + if resp['success'] + codes = resp['recovery_codes'].join("\n") + $logger.info('API 2FA recovery success', user: user.log_username) + puts "Your two-factor authentication recovery codes are:\n\n" \ + "#{codes}\n\n" \ + "During sign in, use one of the codes above when prompted for\n" \ + "your two-factor code. Then, visit your Profile Settings and add\n" \ + "a new device so you do not lose access to your account again." + true + else + $logger.info('API 2FA recovery error', user: user.log_username) + puts "An error occurred while trying to generate new recovery codes.\n" \ + "#{resp['message']}" + end + end + end +end diff --git a/lib/action/base.rb b/lib/action/base.rb new file mode 100644 index 0000000..1f24c8c --- /dev/null +++ b/lib/action/base.rb @@ -0,0 +1,30 @@ +require 'json' + +require_relative '../gitlab_config' +require_relative '../gitlab_net' +require_relative '../gitlab_metrics' +require_relative '../user' + +module Action + class Base + def self.create_from_json(_) + raise NotImplementedError + end + + private + + attr_reader :key_id + + def config + @config ||= GitlabConfig.new + end + + def api + @api ||= GitlabNet.new + end + + def user + @user ||= User.new(key_id, audit_usernames: config.audit_usernames) + end + end +end diff --git a/lib/action/git_lfs_authenticate.rb b/lib/action/git_lfs_authenticate.rb new file mode 100644 index 0000000..d38d845 --- /dev/null +++ b/lib/action/git_lfs_authenticate.rb @@ -0,0 +1,26 @@ +require_relative '../action' +require_relative '../gitlab_logger' + +module Action + class GitLFSAuthenticate < Base + def initialize(key_id, repo_name) + @key_id = key_id + @repo_name = repo_name + end + + def execute(_, _) + GitlabMetrics.measure('lfs-authenticate') do + $logger.info('Processing LFS authentication', user: user.log_username) + lfs_access = api.lfs_authenticate(key_id, repo_name) + return unless lfs_access + + puts lfs_access.authentication_payload + end + true + end + + private + + attr_reader :key_id, :repo_name + end +end diff --git a/lib/action/gitaly.rb b/lib/action/gitaly.rb new file mode 100644 index 0000000..65397e6 --- /dev/null +++ b/lib/action/gitaly.rb @@ -0,0 +1,119 @@ +require_relative '../action' +require_relative '../gitlab_logger' +require_relative '../gitlab_net' + +module Action + class Gitaly < Base + REPOSITORY_PATH_NOT_PROVIDED = "Repository path not provided. Please make sure you're using GitLab v8.10 or later.".freeze + MIGRATED_COMMANDS = { + 'git-upload-pack' => File.join(ROOT_PATH, 'bin', 'gitaly-upload-pack'), + 'git-upload-archive' => File.join(ROOT_PATH, 'bin', 'gitaly-upload-archive'), + 'git-receive-pack' => File.join(ROOT_PATH, 'bin', 'gitaly-receive-pack') + }.freeze + + def initialize(key_id, gl_repository, gl_username, repository_path, gitaly) + @key_id = key_id + @gl_repository = gl_repository + @gl_username = gl_username + @repository_path = repository_path + @gitaly = gitaly + end + + def self.create_from_json(key_id, json) + new(key_id, + json['gl_repository'], + json['gl_username'], + json['repository_path'], + json['gitaly']) + end + + def execute(command, args) + raise ArgumentError, REPOSITORY_PATH_NOT_PROVIDED unless repository_path + raise InvalidRepositoryPathError unless valid_repository? + + $logger.info('Performing Gitaly command', user: user.log_username) + process(command, args) + end + + private + + attr_reader :gl_repository, :gl_username, :repository_path, :gitaly + + def process(command, args) + executable = command + args = [repository_path] + + if MIGRATED_COMMANDS.key?(executable) && gitaly + executable = MIGRATED_COMMANDS[executable] + gitaly_address = gitaly['address'] + args = [gitaly_address, JSON.dump(gitaly_request)] + end + + args_string = [File.basename(executable), *args].join(' ') + $logger.info('executing git command', command: args_string, user: user.log_username) + + exec_cmd(executable, *args) + end + + def exec_cmd(*args) + env = exec_env + env['GITALY_TOKEN'] = gitaly['token'] if gitaly && gitaly.include?('token') + + if git_trace_available? + env.merge!( + 'GIT_TRACE' => config.git_trace_log_file, + 'GIT_TRACE_PACKET' => config.git_trace_log_file, + 'GIT_TRACE_PERFORMANCE' => config.git_trace_log_file + ) + end + + # We use 'chdir: ROOT_PATH' to let the next executable know where config.yml is. + Kernel.exec(env, *args, unsetenv_others: true, chdir: ROOT_PATH) + end + + def exec_env + { + 'HOME' => ENV['HOME'], + 'PATH' => ENV['PATH'], + 'LD_LIBRARY_PATH' => ENV['LD_LIBRARY_PATH'], + 'LANG' => ENV['LANG'], + 'GL_ID' => key_id, + 'GL_PROTOCOL' => GitlabNet::GL_PROTOCOL, + 'GL_REPOSITORY' => gl_repository, + 'GL_USERNAME' => gl_username + } + end + + def gitaly_request + # The entire gitaly_request hash should be built in gitlab-ce and passed + # on as-is. For now we build a fake one on the spot. + { + 'repository' => gitaly['repository'], + 'gl_repository' => gl_repository, + 'gl_id' => key_id, + 'gl_username' => gl_username + } + end + + def valid_repository? + File.absolute_path(repository_path) == repository_path + end + + def git_trace_available? + return false unless config.git_trace_log_file + + if Pathname(config.git_trace_log_file).relative? + $logger.warn('git trace log path must be absolute, ignoring', git_trace_log_file: config.git_trace_log_file) + return false + end + + begin + File.open(config.git_trace_log_file, 'a') { nil } + return true + rescue => ex + $logger.warn('Failed to open git trace log file', git_trace_log_file: config.git_trace_log_file, error: ex.to_s) + return false + end + end + end +end diff --git a/spec/action/api_2fa_recovery.rb_spec.rb b/spec/action/api_2fa_recovery.rb_spec.rb new file mode 100644 index 0000000..1f5219a --- /dev/null +++ b/spec/action/api_2fa_recovery.rb_spec.rb @@ -0,0 +1,73 @@ +require_relative '../spec_helper' +require_relative '../../lib/action/api_2fa_recovery' + +describe Action::API2FARecovery do + let(:key_id) { "key-#{rand(100) + 100}" } + let(:key) { Actor::Key.new(key_id) } + let(:username) { 'testuser' } + let(:discover_payload) { { 'username' => username } } + let(:api) { double(GitlabNet) } + + before do + allow(GitlabNet).to receive(:new).and_return(api) + allow(api).to receive(:discover).with(key_id).and_return(discover_payload) + end + + subject do + described_class.new(key_id) + end + + describe '#execute' do + context 'with an invalid repsonse' do + it 'returns nil' do + expect($stdin).to receive(:gets).and_return("meh\n") + + expect do + expect(subject.execute(nil, nil)).to be_nil + end.to output(/New recovery codes have \*not\* been generated/).to_stdout + end + end + + context 'with a negative response' do + before do + expect(subject).to receive(:continue?).and_return(false) + end + + it 'returns nil' do + expect do + expect(subject.execute(nil, nil)).to be_nil + end.to output(/New recovery codes have \*not\* been generated/).to_stdout + end + end + + + context 'with an affirmative response' do + let(:recovery_codes) { %w{ 8dfe0f433208f40b289904c6072e4a72 c33cee7fd0a73edb56e61b785e49af03 } } + + before do + expect(subject).to receive(:continue?).and_return(true) + expect(api).to receive(:two_factor_recovery_codes).with(key_id).and_return(response) + end + + context 'with a unsuccessful response' do + let(:response) { { 'success' => false } } + + it 'puts error message to stdout' do + expect do + expect(subject.execute(nil, nil)).to be_falsey + end.to output(/An error occurred while trying to generate new recovery codes/).to_stdout + end + end + + context 'with a successful response' do + let(:response) { { 'success' => true, 'recovery_codes' => recovery_codes } } + + it 'puts information message including recovery codes to stdout' do + expect do + expect(subject.execute(nil, nil)).to be_truthy + end.to output(Regexp.new(recovery_codes.join("\n"))).to_stdout + end + end + end + end +end diff --git a/spec/action/base_spec.rb b/spec/action/base_spec.rb new file mode 100644 index 0000000..e986378 --- /dev/null +++ b/spec/action/base_spec.rb @@ -0,0 +1,12 @@ +require_relative '../spec_helper' +require_relative '../../lib/action/base' + +describe Action::Base do + describe '.create_from_json' do + it 'raises a NotImplementedError exeption' do + expect do + described_class.create_from_json('nomatter') + end.to raise_error(NotImplementedError) + end + end +end diff --git a/spec/action/git_lfs_authenticate_spec.rb b/spec/action/git_lfs_authenticate_spec.rb new file mode 100644 index 0000000..f9a0791 --- /dev/null +++ b/spec/action/git_lfs_authenticate_spec.rb @@ -0,0 +1,47 @@ +require_relative '../spec_helper' +require_relative '../../lib/action/git_lfs_authenticate' + +describe Action::GitLFSAuthenticate do + let(:key_id) { "key-#{rand(100) + 100}" } + let(:repo_name) { 'gitlab-ci.git' } + let(:username) { 'testuser' } + let(:discover_payload) { { 'username' => username } } + let(:api) { double(GitlabNet) } + + before do + allow(GitlabNet).to receive(:new).and_return(api) + allow(api).to receive(:discover).with(key_id).and_return(discover_payload) + end + + subject do + described_class.new(key_id, repo_name) + end + + describe '#execute' do + context 'when response from API is not a success' do + before do + expect(api).to receive(:lfs_authenticate).with(key_id, repo_name).and_return(nil) + end + + it 'returns nil' do + expect(subject.execute(nil, nil)).to be_nil + end + end + + context 'when response from API is a success' do + let(:username) { 'testuser' } + let(:lfs_token) { '1234' } + let(:repository_http_path) { "/tmp/#{repo_name}" } + let(:gitlab_lfs_authentication) { GitlabLfsAuthentication.new(username, lfs_token, repository_http_path) } + + before do + expect(api).to receive(:lfs_authenticate).with(key_id, repo_name).and_return(gitlab_lfs_authentication) + end + + it 'puts payload to stdout' do + expect($stdout).to receive(:puts).with('{"header":{"Authorization":"Basic dGVzdHVzZXI6MTIzNA=="},"href":"/tmp/gitlab-ci.git/info/lfs/"}') + expect(subject.execute(nil, nil)).to be_truthy + end + end + end +end diff --git a/spec/action/gitaly_spec.rb b/spec/action/gitaly_spec.rb new file mode 100644 index 0000000..9c35b49 --- /dev/null +++ b/spec/action/gitaly_spec.rb @@ -0,0 +1,133 @@ +require_relative '../spec_helper' +require_relative '../../lib/action/gitaly' + +describe Action::Gitaly do + let(:git_trace_log_file_valid) { '/tmp/git_trace_performance.log' } + let(:git_trace_log_file_invalid) { "/bleep-bop#{git_trace_log_file_valid}" } + let(:git_trace_log_file_relative) { "..#{git_trace_log_file_valid}" } + let(:key_id) { "key-#{rand(100) + 100}" } + let(:gl_repository) { 'project-1' } + let(:gl_username) { 'testuser' } + let(:tmp_repos_path) { File.join(ROOT_PATH, 'tmp', 'repositories') } + let(:repo_name) { 'gitlab-ci.git' } + let(:repository_path) { File.join(tmp_repos_path, repo_name) } + let(:gitaly_address) { 'unix:gitaly.socket' } + let(:gitaly_token) { '123456' } + let(:gitaly) do + { + 'repository' => { 'relative_path' => repo_name, 'storage_name' => 'default' }, + 'address' => gitaly_address, + 'token' => gitaly_token + } + end + + describe '.create_from_json' do + it 'returns an instance of Action::Gitaly' do + json = { + "gl_repository" => gl_repository, + "gl_username" => gl_username, + "repository_path" => repository_path, + "gitaly" => gitaly + } + expect(described_class.create_from_json(key_id, json)).to be_instance_of(Action::Gitaly) + end + end + + subject do + described_class.new(key_id, gl_repository, gl_username, repository_path, gitaly) + end + + describe '#execute' do + let(:args) { [ repository_path ] } + let(:base_exec_env) do + { + 'HOME' => ENV['HOME'], + 'PATH' => ENV['PATH'], + 'LD_LIBRARY_PATH' => ENV['LD_LIBRARY_PATH'], + 'LANG' => ENV['LANG'], + 'GL_ID' => key_id, + 'GL_PROTOCOL' => GitlabNet::GL_PROTOCOL, + 'GL_REPOSITORY' => gl_repository, + 'GL_USERNAME' => gl_username, + 'GITALY_TOKEN' => gitaly_token, + } + end + let(:with_trace_exec_env) do + base_exec_env.merge({ + 'GIT_TRACE' => git_trace_log_file, + 'GIT_TRACE_PACKET' => git_trace_log_file, + 'GIT_TRACE_PERFORMANCE' => git_trace_log_file + }) + end + let(:gitaly_request) do + { + 'repository' => gitaly['repository'], + 'gl_repository' => gl_repository, + 'gl_id' => key_id, + 'gl_username' => gl_username + } + end + + context 'for migrated commands' do + context 'such as git-upload-pack' do + let(:git_trace_log_file) { nil } + let(:command) { 'git-upload-pack' } + + before do + allow_any_instance_of(GitlabConfig).to receive(:git_trace_log_file).and_return(git_trace_log_file) + end + + context 'with an invalid config.git_trace_log_file' do + let(:git_trace_log_file) { git_trace_log_file_invalid } + + it 'returns true' do + expect(Kernel).to receive(:exec).with( + base_exec_env, + described_class::MIGRATED_COMMANDS[command], + gitaly_address, + JSON.dump(gitaly_request), + unsetenv_others: true, + chdir: ROOT_PATH + ).and_return(true) + + expect(subject.execute(command, args)).to be_truthy + end + end + + context 'with an relative config.git_trace_log_file' do + let(:git_trace_log_file) { git_trace_log_file_relative } + + it 'returns true' do + expect(Kernel).to receive(:exec).with( + base_exec_env, + described_class::MIGRATED_COMMANDS[command], + gitaly_address, + JSON.dump(gitaly_request), + unsetenv_others: true, + chdir: ROOT_PATH + ).and_return(true) + + expect(subject.execute(command, args)).to be_truthy + end + end + + context 'with a valid config.git_trace_log_file' do + let(:git_trace_log_file) { git_trace_log_file_valid } + + it 'returns true' do + expect(Kernel).to receive(:exec).with( + with_trace_exec_env, + described_class::MIGRATED_COMMANDS[command], + gitaly_address, + JSON.dump(gitaly_request), + unsetenv_others: true, + chdir: ROOT_PATH + ).and_return(true) + + expect(subject.execute(command, args)).to be_truthy + end + end + end + end + end +end |