Merge branch '42790-improve-feedback-for-internal-git-access-checks-timeouts' into 'master'

Adds trace of each access check when git push times out

Closes #42790

See merge request gitlab-org/gitlab-ce!22265
This commit is contained in:
Douwe Maan 2018-10-26 18:03:40 +00:00
commit f2e9148d18
16 changed files with 322 additions and 49 deletions

View file

@ -1 +1 @@
8.4.0 8.4.1

View file

@ -8,6 +8,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController
rescue_from Gitlab::GitAccess::UnauthorizedError, with: :render_403 rescue_from Gitlab::GitAccess::UnauthorizedError, with: :render_403
rescue_from Gitlab::GitAccess::NotFoundError, with: :render_404 rescue_from Gitlab::GitAccess::NotFoundError, with: :render_404
rescue_from Gitlab::GitAccess::ProjectCreationError, with: :render_422 rescue_from Gitlab::GitAccess::ProjectCreationError, with: :render_422
rescue_from Gitlab::GitAccess::TimeoutError, with: :render_503
# GET /foo/bar.git/info/refs?service=git-upload-pack (git pull) # GET /foo/bar.git/info/refs?service=git-upload-pack (git pull)
# GET /foo/bar.git/info/refs?service=git-receive-pack (git push) # GET /foo/bar.git/info/refs?service=git-receive-pack (git push)
@ -62,6 +63,10 @@ class Projects::GitHttpController < Projects::GitHttpClientController
render plain: exception.message, status: :unprocessable_entity render plain: exception.message, status: :unprocessable_entity
end end
def render_503(exception)
render plain: exception.message, status: :service_unavailable
end
def access def access
@access ||= access_klass.new(access_actor, project, @access ||= access_klass.new(access_actor, project,
'http', authentication_abilities: authentication_abilities, 'http', authentication_abilities: authentication_abilities,

View file

@ -0,0 +1,5 @@
---
title: Adds trace of each access check when git push times out
merge_request: 22265
author:
type: added

View file

@ -65,6 +65,8 @@ module API
result result
rescue Gitlab::GitAccess::UnauthorizedError => e rescue Gitlab::GitAccess::UnauthorizedError => e
break response_with_status(code: 401, success: false, message: e.message) break response_with_status(code: 401, success: false, message: e.message)
rescue Gitlab::GitAccess::TimeoutError => e
break response_with_status(code: 503, success: false, message: e.message)
rescue Gitlab::GitAccess::NotFoundError => e rescue Gitlab::GitAccess::NotFoundError => e
break response_with_status(code: 404, success: false, message: e.message) break response_with_status(code: 404, success: false, message: e.message)
end end

View file

@ -18,11 +18,24 @@ module Gitlab
lfs_objects_missing: 'LFS objects are missing. Ensure LFS is properly set up or try a manual "git lfs push --all".' lfs_objects_missing: 'LFS objects are missing. Ensure LFS is properly set up or try a manual "git lfs push --all".'
}.freeze }.freeze
attr_reader :user_access, :project, :skip_authorization, :skip_lfs_integrity_check, :protocol, :oldrev, :newrev, :ref, :branch_name, :tag_name LOG_MESSAGES = {
push_checks: "Checking if you are allowed to push...",
delete_default_branch_check: "Checking if default branch is being deleted...",
protected_branch_checks: "Checking if you are force pushing to a protected branch...",
protected_branch_push_checks: "Checking if you are allowed to push to the protected branch...",
protected_branch_deletion_checks: "Checking if you are allowed to delete the protected branch...",
tag_checks: "Checking if you are allowed to change existing tags...",
protected_tag_checks: "Checking if you are creating, updating or deleting a protected tag...",
lfs_objects_exist_check: "Scanning repository for blobs stored in LFS and verifying their files have been uploaded to GitLab...",
commits_check_file_paths_validation: "Validating commits' file paths...",
commits_check: "Validating commit contents..."
}.freeze
attr_reader :user_access, :project, :skip_authorization, :skip_lfs_integrity_check, :protocol, :oldrev, :newrev, :ref, :branch_name, :tag_name, :logger
def initialize( def initialize(
change, user_access:, project:, skip_authorization: false, change, user_access:, project:, skip_authorization: false,
skip_lfs_integrity_check: false, protocol: skip_lfs_integrity_check: false, protocol:, logger:
) )
@oldrev, @newrev, @ref = change.values_at(:oldrev, :newrev, :ref) @oldrev, @newrev, @ref = change.values_at(:oldrev, :newrev, :ref)
@branch_name = Gitlab::Git.branch_name(@ref) @branch_name = Gitlab::Git.branch_name(@ref)
@ -32,6 +45,9 @@ module Gitlab
@skip_authorization = skip_authorization @skip_authorization = skip_authorization
@skip_lfs_integrity_check = skip_lfs_integrity_check @skip_lfs_integrity_check = skip_lfs_integrity_check
@protocol = protocol @protocol = protocol
@logger = logger
@logger.append_message("Running checks for ref: #{@branch_name || @tag_name}")
end end
def exec(skip_commits_check: false) def exec(skip_commits_check: false)
@ -49,26 +65,32 @@ module Gitlab
protected protected
def push_checks def push_checks
unless can_push? logger.log_timed(LOG_MESSAGES[__method__]) do
raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:push_code] unless can_push?
raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:push_code]
end
end end
end end
def branch_checks def branch_checks
return unless branch_name return unless branch_name
if deletion? && branch_name == project.default_branch logger.log_timed(LOG_MESSAGES[:delete_default_branch_check]) do
raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:delete_default_branch] if deletion? && branch_name == project.default_branch
raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:delete_default_branch]
end
end end
protected_branch_checks protected_branch_checks
end end
def protected_branch_checks def protected_branch_checks
return unless ProtectedBranch.protected?(project, branch_name) logger.log_timed(LOG_MESSAGES[__method__]) do
return unless ProtectedBranch.protected?(project, branch_name) # rubocop:disable Cop/AvoidReturnFromBlocks
if forced_push? if forced_push?
raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:force_push_protected_branch] raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:force_push_protected_branch]
end
end end
if deletion? if deletion?
@ -79,23 +101,27 @@ module Gitlab
end end
def protected_branch_deletion_checks def protected_branch_deletion_checks
unless user_access.can_delete_branch?(branch_name) logger.log_timed(LOG_MESSAGES[__method__]) do
raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:non_master_delete_protected_branch] unless user_access.can_delete_branch?(branch_name)
end raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:non_master_delete_protected_branch]
end
unless updated_from_web? unless updated_from_web?
raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:non_web_delete_protected_branch] raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:non_web_delete_protected_branch]
end
end end
end end
def protected_branch_push_checks def protected_branch_push_checks
if matching_merge_request? logger.log_timed(LOG_MESSAGES[__method__]) do
unless user_access.can_merge_to_branch?(branch_name) || user_access.can_push_to_branch?(branch_name) if matching_merge_request?
raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:merge_protected_branch] unless user_access.can_merge_to_branch?(branch_name) || user_access.can_push_to_branch?(branch_name)
end raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:merge_protected_branch]
else end
unless user_access.can_push_to_branch?(branch_name) else
raise GitAccess::UnauthorizedError, push_to_protected_branch_rejected_message unless user_access.can_push_to_branch?(branch_name)
raise GitAccess::UnauthorizedError, push_to_protected_branch_rejected_message
end
end end
end end
end end
@ -103,21 +129,25 @@ module Gitlab
def tag_checks def tag_checks
return unless tag_name return unless tag_name
if tag_exists? && user_access.cannot_do_action?(:admin_project) logger.log_timed(LOG_MESSAGES[__method__]) do
raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:change_existing_tags] if tag_exists? && user_access.cannot_do_action?(:admin_project)
raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:change_existing_tags]
end
end end
protected_tag_checks protected_tag_checks
end end
def protected_tag_checks def protected_tag_checks
return unless ProtectedTag.protected?(project, tag_name) logger.log_timed(LOG_MESSAGES[__method__]) do
return unless ProtectedTag.protected?(project, tag_name) # rubocop:disable Cop/AvoidReturnFromBlocks
raise(GitAccess::UnauthorizedError, ERROR_MESSAGES[:update_protected_tag]) if update? raise(GitAccess::UnauthorizedError, ERROR_MESSAGES[:update_protected_tag]) if update?
raise(GitAccess::UnauthorizedError, ERROR_MESSAGES[:delete_protected_tag]) if deletion? raise(GitAccess::UnauthorizedError, ERROR_MESSAGES[:delete_protected_tag]) if deletion?
unless user_access.can_create_tag?(tag_name) unless user_access.can_create_tag?(tag_name)
raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:create_protected_tag] raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:create_protected_tag]
end
end end
end end
@ -125,14 +155,20 @@ module Gitlab
return if deletion? || newrev.nil? return if deletion? || newrev.nil?
return unless should_run_commit_validations? return unless should_run_commit_validations?
# n+1: https://gitlab.com/gitlab-org/gitlab-ee/issues/3593 logger.log_timed(LOG_MESSAGES[__method__]) do
::Gitlab::GitalyClient.allow_n_plus_1_calls do # n+1: https://gitlab.com/gitlab-org/gitlab-ee/issues/3593
commits.each do |commit| ::Gitlab::GitalyClient.allow_n_plus_1_calls do
commit_check.validate(commit, validations_for_commit(commit)) commits.each do |commit|
logger.check_timeout_reached
commit_check.validate(commit, validations_for_commit(commit))
end
end end
end end
commit_check.validate_file_paths logger.log_timed(LOG_MESSAGES[:commits_check_file_paths_validation]) do
commit_check.validate_file_paths
end
end end
# Method overwritten in EE to inject custom validations # Method overwritten in EE to inject custom validations
@ -194,10 +230,12 @@ module Gitlab
end end
def lfs_objects_exist_check def lfs_objects_exist_check
lfs_check = Checks::LfsIntegrity.new(project, newrev) logger.log_timed(LOG_MESSAGES[__method__]) do
lfs_check = Checks::LfsIntegrity.new(project, newrev, logger.time_left)
if lfs_check.objects_missing? if lfs_check.objects_missing?
raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:lfs_objects_missing] raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:lfs_objects_missing]
end
end end
end end

View file

@ -3,9 +3,10 @@
module Gitlab module Gitlab
module Checks module Checks
class LfsIntegrity class LfsIntegrity
def initialize(project, newrev) def initialize(project, newrev, time_left)
@project = project @project = project
@newrev = newrev @newrev = newrev
@time_left = time_left
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
@ -13,7 +14,7 @@ module Gitlab
return false unless @newrev && @project.lfs_enabled? return false unless @newrev && @project.lfs_enabled?
new_lfs_pointers = Gitlab::Git::LfsChanges.new(@project.repository, @newrev) new_lfs_pointers = Gitlab::Git::LfsChanges.new(@project.repository, @newrev)
.new_pointers(object_limit: ::Gitlab::Git::Repository::REV_LIST_COMMIT_LIMIT) .new_pointers(object_limit: ::Gitlab::Git::Repository::REV_LIST_COMMIT_LIMIT, dynamic_timeout: @time_left)
return false unless new_lfs_pointers.present? return false unless new_lfs_pointers.present?

View file

@ -0,0 +1,83 @@
# frozen_string_literal: true
module Gitlab
module Checks
class TimedLogger
TimeoutError = Class.new(StandardError)
attr_reader :start_time, :header, :log, :timeout
def initialize(start_time: Time.now, log: [], header: "", timeout:)
@start_time = start_time
@timeout = timeout
@header = header
@log = log
end
# Adds trace of method being tracked with
# the correspondent time it took to run it.
# We make use of the start default argument
# on unit tests related to this method
#
def log_timed(log_message, start = Time.now)
check_timeout_reached
timed = true
yield
append_message(log_message + time_suffix_message(start: start))
rescue GRPC::DeadlineExceeded, TimeoutError
args = { cancelled: true }
args[:start] = start if timed
append_message(log_message + time_suffix_message(args))
raise TimeoutError
end
def check_timeout_reached
return unless time_expired?
raise TimeoutError
end
def time_left
(start_time + timeout.seconds) - Time.now
end
def full_message
header + log.join("\n")
end
# We always want to append in-place on the log
def append_message(message)
log << message
end
private
def time_expired?
time_left <= 0
end
def time_suffix_message(cancelled: false, start: nil)
return " (#{elapsed_time(start)}ms)" unless cancelled
if start
" (cancelled after #{elapsed_time(start)}ms)"
else
" (cancelled)"
end
end
def elapsed_time(start)
to_ms(Time.now - start)
end
def to_ms(elapsed)
(elapsed.to_f * 1000).round(2)
end
end
end
end

View file

@ -6,8 +6,8 @@ module Gitlab
@newrev = newrev @newrev = newrev
end end
def new_pointers(object_limit: nil, not_in: nil) def new_pointers(object_limit: nil, not_in: nil, dynamic_timeout: nil)
@repository.gitaly_blob_client.get_new_lfs_pointers(@newrev, object_limit, not_in) @repository.gitaly_blob_client.get_new_lfs_pointers(@newrev, object_limit, not_in, dynamic_timeout)
end end
def all_pointers def all_pointers

View file

@ -9,6 +9,7 @@ module Gitlab
UnauthorizedError = Class.new(StandardError) UnauthorizedError = Class.new(StandardError)
NotFoundError = Class.new(StandardError) NotFoundError = Class.new(StandardError)
ProjectCreationError = Class.new(StandardError) ProjectCreationError = Class.new(StandardError)
TimeoutError = Class.new(StandardError)
ProjectMovedError = Class.new(NotFoundError) ProjectMovedError = Class.new(NotFoundError)
ERROR_MESSAGES = { ERROR_MESSAGES = {
@ -26,11 +27,18 @@ module Gitlab
cannot_push_to_read_only: "You can't push code to a read-only GitLab instance." cannot_push_to_read_only: "You can't push code to a read-only GitLab instance."
}.freeze }.freeze
INTERNAL_TIMEOUT = 50.seconds.freeze
LOG_HEADER = <<~MESSAGE
Push operation timed out
Timing information for debugging purposes:
MESSAGE
DOWNLOAD_COMMANDS = %w{git-upload-pack git-upload-archive}.freeze DOWNLOAD_COMMANDS = %w{git-upload-pack git-upload-archive}.freeze
PUSH_COMMANDS = %w{git-receive-pack}.freeze PUSH_COMMANDS = %w{git-receive-pack}.freeze
ALL_COMMANDS = DOWNLOAD_COMMANDS + PUSH_COMMANDS ALL_COMMANDS = DOWNLOAD_COMMANDS + PUSH_COMMANDS
attr_reader :actor, :project, :protocol, :authentication_abilities, :namespace_path, :project_path, :redirected_path, :auth_result_type, :changes attr_reader :actor, :project, :protocol, :authentication_abilities, :namespace_path, :project_path, :redirected_path, :auth_result_type, :changes, :logger
def initialize(actor, project, protocol, authentication_abilities:, namespace_path: nil, project_path: nil, redirected_path: nil, auth_result_type: nil) def initialize(actor, project, protocol, authentication_abilities:, namespace_path: nil, project_path: nil, redirected_path: nil, auth_result_type: nil)
@actor = actor @actor = actor
@ -44,6 +52,7 @@ module Gitlab
end end
def check(cmd, changes) def check(cmd, changes)
@logger = Checks::TimedLogger.new(timeout: INTERNAL_TIMEOUT, header: LOG_HEADER)
@changes = changes @changes = changes
check_protocol! check_protocol!
@ -269,14 +278,19 @@ module Gitlab
end end
def check_single_change_access(change, skip_lfs_integrity_check: false) def check_single_change_access(change, skip_lfs_integrity_check: false)
Checks::ChangeAccess.new( change_access = Checks::ChangeAccess.new(
change, change,
user_access: user_access, user_access: user_access,
project: project, project: project,
skip_authorization: deploy_key?, skip_authorization: deploy_key?,
skip_lfs_integrity_check: skip_lfs_integrity_check, skip_lfs_integrity_check: skip_lfs_integrity_check,
protocol: protocol protocol: protocol,
).exec logger: logger
)
change_access.exec
rescue Checks::TimedLogger::TimeoutError
raise TimeoutError, logger.full_message
end end
def deploy_key def deploy_key

View file

@ -72,7 +72,7 @@ module Gitlab
GitalyClient::BlobsStitcher.new(response) GitalyClient::BlobsStitcher.new(response)
end end
def get_new_lfs_pointers(revision, limit, not_in) def get_new_lfs_pointers(revision, limit, not_in, dynamic_timeout = nil)
request = Gitaly::GetNewLFSPointersRequest.new( request = Gitaly::GetNewLFSPointersRequest.new(
repository: @gitaly_repo, repository: @gitaly_repo,
revision: encode_binary(revision), revision: encode_binary(revision),
@ -85,7 +85,20 @@ module Gitlab
request.not_in_refs += not_in request.not_in_refs += not_in
end end
response = GitalyClient.call(@gitaly_repo.storage_name, :blob_service, :get_new_lfs_pointers, request, timeout: GitalyClient.medium_timeout) timeout =
if dynamic_timeout
[dynamic_timeout, GitalyClient.medium_timeout].min
else
GitalyClient.medium_timeout
end
response = GitalyClient.call(
@gitaly_repo.storage_name,
:blob_service,
:get_new_lfs_pointers,
request,
timeout: timeout
)
map_lfs_pointers(response) map_lfs_pointers(response)
end end

View file

@ -10,13 +10,16 @@ describe Gitlab::Checks::ChangeAccess do
let(:ref) { 'refs/heads/master' } let(:ref) { 'refs/heads/master' }
let(:changes) { { oldrev: oldrev, newrev: newrev, ref: ref } } let(:changes) { { oldrev: oldrev, newrev: newrev, ref: ref } }
let(:protocol) { 'ssh' } let(:protocol) { 'ssh' }
let(:timeout) { Gitlab::GitAccess::INTERNAL_TIMEOUT }
let(:logger) { Gitlab::Checks::TimedLogger.new(timeout: timeout) }
subject(:change_access) do subject(:change_access) do
described_class.new( described_class.new(
changes, changes,
project: project, project: project,
user_access: user_access, user_access: user_access,
protocol: protocol protocol: protocol,
logger: logger
) )
end end
@ -30,6 +33,19 @@ describe Gitlab::Checks::ChangeAccess do
end end
end end
context 'when time limit was reached' do
it 'raises a TimeoutError' do
logger = Gitlab::Checks::TimedLogger.new(start_time: timeout.ago, timeout: timeout)
access = described_class.new(changes,
project: project,
user_access: user_access,
protocol: protocol,
logger: logger)
expect { access.exec }.to raise_error(Gitlab::Checks::TimedLogger::TimeoutError)
end
end
context 'when the user is not allowed to push to the repo' do context 'when the user is not allowed to push to the repo' do
it 'raises an error' do it 'raises an error' do
expect(user_access).to receive(:can_do_action?).with(:push_code).and_return(false) expect(user_access).to receive(:can_do_action?).with(:push_code).and_return(false)

View file

@ -3,6 +3,7 @@ require 'spec_helper'
describe Gitlab::Checks::LfsIntegrity do describe Gitlab::Checks::LfsIntegrity do
include ProjectForksHelper include ProjectForksHelper
let!(:time_left) { 50 }
let(:project) { create(:project, :repository) } let(:project) { create(:project, :repository) }
let(:repository) { project.repository } let(:repository) { project.repository }
let(:newrev) do let(:newrev) do
@ -15,7 +16,7 @@ describe Gitlab::Checks::LfsIntegrity do
operations.commit_tree('8856a329dd38ca86dfb9ce5aa58a16d88cc119bd', "New LFS objects") operations.commit_tree('8856a329dd38ca86dfb9ce5aa58a16d88cc119bd', "New LFS objects")
end end
subject { described_class.new(project, newrev) } subject { described_class.new(project, newrev, time_left) }
describe '#objects_missing?' do describe '#objects_missing?' do
let(:blob_object) { repository.blob_at_branch('lfs', 'files/lfs/lfs_object.iso') } let(:blob_object) { repository.blob_at_branch('lfs', 'files/lfs/lfs_object.iso') }

View file

@ -0,0 +1,63 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Checks::TimedLogger do
let!(:timeout) { 50.seconds }
let!(:start) { Time.now }
let!(:ref) { "bar" }
let!(:logger) { described_class.new(start_time: start, timeout: timeout) }
let!(:log_messages) do
{
foo: "Foo message..."
}
end
before do
logger.append_message("Checking ref: #{ref}")
end
describe '#log_timed' do
it 'logs message' do
Timecop.freeze(start + 30.seconds) do
logger.log_timed(log_messages[:foo], start) { bar_check }
end
expect(logger.full_message).to eq("Checking ref: bar\nFoo message... (30000.0ms)")
end
context 'when time limit was reached' do
it 'cancels action' do
Timecop.freeze(start + 50.seconds) do
expect do
logger.log_timed(log_messages[:foo], start) do
bar_check
end
end.to raise_error(described_class::TimeoutError)
end
expect(logger.full_message).to eq("Checking ref: bar\nFoo message... (cancelled)")
end
it 'cancels action with time elapsed if work was performed' do
Timecop.freeze(start + 30.seconds) do
expect do
logger.log_timed(log_messages[:foo], start) do
grpc_check
end
end.to raise_error(described_class::TimeoutError)
expect(logger.full_message).to eq("Checking ref: bar\nFoo message... (cancelled after 30000.0ms)")
end
end
end
end
def bar_check
2 + 2
end
def grpc_check
raise GRPC::DeadlineExceeded
end
end

View file

@ -15,5 +15,9 @@ describe Gitlab::Git::LfsChanges do
it 'limits new_objects using object_limit' do it 'limits new_objects using object_limit' do
expect(subject.new_pointers(object_limit: 1)).to eq([]) expect(subject.new_pointers(object_limit: 1)).to eq([])
end end
it 'times out if given a small dynamic timeout' do
expect { subject.new_pointers(dynamic_timeout: 0.001) }.to raise_error(GRPC::DeadlineExceeded)
end
end end
end end

View file

@ -934,6 +934,16 @@ describe Gitlab::GitAccess do
# There is still an N+1 query with protected branches # There is still an N+1 query with protected branches
expect { access.check('git-receive-pack', changes) }.not_to exceed_query_limit(control_count).with_threshold(1) expect { access.check('git-receive-pack', changes) }.not_to exceed_query_limit(control_count).with_threshold(1)
end end
it 'raises TimeoutError when #check_single_change_access raises a timeout error' do
message = "Push operation timed out\n\nTiming information for debugging purposes:\nRunning checks for ref: wow"
expect_next_instance_of(Gitlab::Checks::ChangeAccess) do |check|
expect(check).to receive(:exec).and_raise(Gitlab::Checks::TimedLogger::TimeoutError)
end
expect { access.check('git-receive-pack', changes) }.to raise_error(described_class::TimeoutError, message)
end
end end
end end

View file

@ -494,6 +494,24 @@ describe API::Internal do
end end
end end
context 'request times out' do
context 'git push' do
it 'responds with a gateway timeout' do
personal_project = create(:project, namespace: user.namespace)
expect_next_instance_of(Gitlab::GitAccess) do |access|
expect(access).to receive(:check).and_raise(Gitlab::GitAccess::TimeoutError, "Foo")
end
push(key, personal_project)
expect(response).to have_gitlab_http_status(503)
expect(json_response['status']).to be_falsey
expect(json_response['message']).to eq("Foo")
expect(user.reload.last_activity_on).to be_nil
end
end
end
context "archived project" do context "archived project" do
before do before do
project.add_developer(user) project.add_developer(user)