diff --git a/lib/travis/api/enqueue/services/restart_model.rb b/lib/travis/api/enqueue/services/restart_model.rb index 7ee56d1e8..c7af11db3 100644 --- a/lib/travis/api/enqueue/services/restart_model.rb +++ b/lib/travis/api/enqueue/services/restart_model.rb @@ -27,7 +27,7 @@ def push(event, payload) end def accept? - current_user && permission? && resetable? && billing? + current_user && permission? && resetable? && billing? && custom_image_create_allowed? && custom_image_use_allowed? end def billing? @@ -61,12 +61,64 @@ def billing? end end + def custom_image_create_allowed? + return true if !!Travis.config.enterprise + + @_custom_image_create_allowed ||= begin + jobs = target.is_a?(Job) ? [target] : target.matrix + jobs.map do |job| + next unless job.config + + create_name = job.config.dig(:vm, :create, :name) + if create_name + return false unless !!artifact_manager.create(owner: repository.owner, image_name: create_name, job_restart: true) + end + end + true + rescue Travis::API::V3::Error + false + end + end + + + def custom_image_use_allowed? + return true if !!Travis.config.enterprise + + @_custom_image_use_allowed ||= begin + jobs = target.is_a?(Job) ? [target] : target.matrix + + jobs.map do |job| + next unless job.config + + use_name = job.config.dig(:vm, :use) + use_name = use_name[:name] if use_name.is_a?(Hash) + + if use_name + return false unless can_use_custom_image?(owner: repository.owner, image_name: use_name) + end + end + true + rescue Travis::API::V3::Error + false + end + end + + def can_use_custom_image?(owner:, image_name:) + !!artifact_manager.use(owner: , image_name:) + rescue Travis::API::V3::NotFound + true + rescue Travis::API::V3::Error + false + end + def messages messages = [] messages << { notice: "The #{type} was successfully restarted." } if accept? messages << { error: 'You do not seem to have sufficient permissions.' } unless permission? messages << { error: 'You do not have enough credits.' } unless billing? messages << { error: "This #{type} currently can not be restarted." } unless resetable? + messages << { error: "Image creation build restart not allowed." } unless custom_image_create_allowed? + messages << { error: "Can't use the custom image. Make sure it's already created" } unless custom_image_use_allowed? messages end @@ -88,6 +140,10 @@ def target private + def artifact_manager + @_artifact_manger = Travis::API::V3::ArtifactManagerClient.new(current_user&.id) + end + def subscription Subscription.where(owner: repository.owner)&.first end diff --git a/lib/travis/api/v3/artifact_manager_client.rb b/lib/travis/api/v3/artifact_manager_client.rb new file mode 100644 index 000000000..05e6368fe --- /dev/null +++ b/lib/travis/api/v3/artifact_manager_client.rb @@ -0,0 +1,99 @@ +module Travis::API::V3 + class ArtifactManagerClient + class ConfigurationError < StandardError; end + + def initialize(user_id) + @user_id = user_id + end + + def create(owner:, image_name:, job_restart: false) + params = { + owner_type: owner.class.name.downcase, + id: owner.id, + name: image_name, + job_restart: + } + handle_errors_and_respond(connection.post("/create", params)) do |body| + body.include?('image_id') ? body['image_id'] : false + end + rescue Faraday::Error + raise ArtifactManagerConnectionError + end + + def use(owner:, image_name:) + handle_errors_and_respond(connection.get("/image/#{owner.class.name.downcase}/#{owner.id}/#{image_name}")) do |body| + return body['image_id'] if body.include?('image_id') + + return body['image']['id'] if body.include?('image') + + false + end + rescue Faraday::Error + raise ArtifactManagerConnectionError + end + + def images(owner_type, owner_id) + response = connection.get("/images?owner_type=#{owner_type}&id=#{owner_id}") + handle_images_response(response) + end + + def delete_images(owner_type, owner_id, image_ids) + image_ids.each do |image_id| + response = connection.delete("/image/#{owner_type.downcase}/#{owner_id}/#{image_id}") + handle_errors_and_respond(response) + end + end + + private + + def handle_images_response(response) + handle_errors_and_respond(response) do |r| + r['images'].map { |image_data| Travis::API::V3::Models::CustomImage.new(image_data) } + end + end + + def handle_errors_and_respond(response) + body = response.body.is_a?(String) && response.body.length.positive? ? JSON.parse(response.body) : response.body + + case response.status + when 200, 201 + yield(body) if block_given? + when 202 + true + when 204 + true + when 400 + raise Travis::API::V3::ClientError, body['error'] + when 403 + raise Travis::API::V3::InsufficientAccess, body['rejection_code'] + when 404 + raise Travis::API::V3::NotFound, body['error'] + when 422 + raise Travis::API::V3::UnprocessableEntity, body['error'] + else + raise Travis::API::V3::ServerError, 'Artifact manager failed' + end + end + + def connection(timeout: 10) + @connection ||= Faraday.new(url: artifact_manager_url, ssl: { ca_path: '/usr/lib/ssl/certs' }) do |conn| + conn.request(:authorization, :basic, '_', artifact_manager_auth_key) + conn.headers['X-Travis-User-Id'] = @user_id.to_s + conn.headers['Content-Type'] = 'application/json' + conn.request :json + conn.response :json + conn.options[:open_timeout] = timeout + conn.options[:timeout] = timeout + conn.adapter :net_http + end + end + + def artifact_manager_url + Travis.config.artifact_manager&.url || raise(ConfigurationError, 'No artifact manager url configured') + end + + def artifact_manager_auth_key + Travis.config.artifact_manager&.auth_key || raise(ConfigurationError, 'No artifact manager auth key configured') + end + end +end diff --git a/lib/travis/api/v3/models/custom_image.rb b/lib/travis/api/v3/models/custom_image.rb index b0763a940..bfe3c30ce 100644 --- a/lib/travis/api/v3/models/custom_image.rb +++ b/lib/travis/api/v3/models/custom_image.rb @@ -7,7 +7,7 @@ class Models::CustomImage < Model scope :available, -> { where(state: 'available') } def created_by - user_id = custom_image_logs.where(action: 'created').first&.sender_id + user_id = custom_image_logs.created.first&.sender_id return unless user_id User.find(user_id) diff --git a/lib/travis/api/v3/models/custom_image_log.rb b/lib/travis/api/v3/models/custom_image_log.rb index 255763935..26de4ba02 100644 --- a/lib/travis/api/v3/models/custom_image_log.rb +++ b/lib/travis/api/v3/models/custom_image_log.rb @@ -2,5 +2,6 @@ module Travis::API::V3 class Models::CustomImageLog < Model after_initialize :readonly! belongs_to :custom_image + enum action: { created: 'created', used: 'used', deleted: 'deleted', other: 'other' } end end diff --git a/lib/travis/api/v3/queries/custom_images.rb b/lib/travis/api/v3/queries/custom_images.rb index 4ceaf0066..c95441494 100644 --- a/lib/travis/api/v3/queries/custom_images.rb +++ b/lib/travis/api/v3/queries/custom_images.rb @@ -1,7 +1,12 @@ module Travis::API::V3 class Queries::CustomImages < Query def for_owner(owner) - Models::CustomImage.where(owner_id: owner.id, owner_type: owner_type(owner)) + Models::CustomImage.available.where(owner_id: owner.id, owner_type: owner_type(owner)).order('created_at DESC') + end + + def delete(image_ids, owner, sender) + client = ArtifactManagerClient.new(sender.id) + client.delete_images(owner_type(owner), owner.id, image_ids) end private diff --git a/lib/travis/api/v3/renderer/membership.rb b/lib/travis/api/v3/renderer/membership.rb new file mode 100644 index 000000000..af3c66c30 --- /dev/null +++ b/lib/travis/api/v3/renderer/membership.rb @@ -0,0 +1,6 @@ +module Travis::API::V3 + class Renderer::Membership < ModelRenderer + representation(:minimal, :organization_id, :build_permission) + representation(:standard, :user_id, :organization_id, :role, :build_permission) + end +end diff --git a/lib/travis/api/v3/renderer/user.rb b/lib/travis/api/v3/renderer/user.rb index a336297c4..6fcc34c2b 100644 --- a/lib/travis/api/v3/renderer/user.rb +++ b/lib/travis/api/v3/renderer/user.rb @@ -2,7 +2,7 @@ module Travis::API::V3 class Renderer::User < Renderer::Owner - representation(:standard, :email, :is_syncing, :synced_at, :recently_signed_up, :secure_user_hash, :ro_mode, :confirmed_at, :custom_keys, :internal, :last_activity_at) + representation(:standard, :email, :is_syncing, :synced_at, :recently_signed_up, :secure_user_hash, :ro_mode, :confirmed_at, :custom_keys, :internal, :last_activity_at, :memberships) representation(:additional, :emails, :collaborator) def email diff --git a/lib/travis/api/v3/routes.rb b/lib/travis/api/v3/routes.rb index ceebf1ba6..0ca0e725e 100644 --- a/lib/travis/api/v3/routes.rb +++ b/lib/travis/api/v3/routes.rb @@ -173,6 +173,7 @@ module Routes resource :custom_images do route '/custom_images' get :for_owner + delete :delete end end diff --git a/lib/travis/api/v3/services/custom_images/delete.rb b/lib/travis/api/v3/services/custom_images/delete.rb new file mode 100644 index 000000000..af2051efc --- /dev/null +++ b/lib/travis/api/v3/services/custom_images/delete.rb @@ -0,0 +1,21 @@ +module Travis::API::V3 + class Services::CustomImages::Delete < Service + params :image_ids + + def run! + raise LoginRequired unless access_control.full_access_or_logged_in? + + owner = query(:owner).find + raise NotFound unless owner + + if owner.is_a?(Travis::API::V3::Models::User) + access_control.permissions(owner).sync! + else + access_control.permissions(owner).admin! + end + + query.delete(params['image_ids'], owner, access_control.user) + deleted + end + end +end diff --git a/lib/travis/api/v3/services/custom_images/for_owner.rb b/lib/travis/api/v3/services/custom_images/for_owner.rb index 5fa4b75f8..3187f23b9 100644 --- a/lib/travis/api/v3/services/custom_images/for_owner.rb +++ b/lib/travis/api/v3/services/custom_images/for_owner.rb @@ -9,9 +9,16 @@ def run! owner = query(:owner).find raise NotFound unless owner - repo = owner.repositories.first - raise InsufficientAccess unless repo - access_control.permissions(repo).build_create! + + if owner.is_a?(Travis::API::V3::Models::User) + raise InsufficientAccess unless access_control.user.id == owner.id + else + membership = Models::Membership.where(organization_id: owner.id).joins(:user).includes(:user).first + raise NotFound unless membership + + build_permission = membership.build_permission.nil? ? true : membership.build_permission + raise InsufficientAccess unless build_permission + end results = query(:custom_images).for_owner(owner) result results diff --git a/lib/travis/config/defaults.rb b/lib/travis/config/defaults.rb index 543113b28..f1d012360 100644 --- a/lib/travis/config/defaults.rb +++ b/lib/travis/config/defaults.rb @@ -106,7 +106,8 @@ def fallback_logs_api_auth_token recaptcha: { endpoint: 'https://www.google.com', secret: ENV['RECAPTCHA_SECRET_KEY'] || '' }, antifraud: { captcha_max_failed_attempts: 3, captcha_block_duration: 24, credit_card_max_failed_attempts: 3, credit_card_block_duration: 24 }, legacy_roles: false, - internal_users: [{id: 0, login: 'cron'}] + internal_users: [{id: 0, login: 'cron'}], + artifact_manager: { url: 'http://artifact_manager:3434', auth_key: 'secret' } default :_access => [:key] diff --git a/lib/travis/testing/factories.rb b/lib/travis/testing/factories.rb index 2b4a02ca8..81a3a4494 100644 --- a/lib/travis/testing/factories.rb +++ b/lib/travis/testing/factories.rb @@ -244,4 +244,31 @@ owner_type { 'User' } accepted_at { nil } end + + factory :custom_image, class: Travis::API::V3::Models::CustomImage do + name { 'custom_image_name' } + description { 'custom_image_description' } + state { 'available' } + owner { User.first || FactoryBot.create(:user) } + owner_type { 'User' } + architecture { 'x86' } + size_bytes { 1024 } + created_at { Time.now.utc } + updated_at { Time.now.utc } + + after(:build) do |custom_image| + custom_image.define_singleton_method(:readonly?) { false } + end + end + + factory :custom_image_log, class: Travis::API::V3::Models::CustomImageLog do + custom_image { CustomImage.first || FactoryBot.create(:custom_image) } + action { 'created' } + sender_id { User.first.id || FactoryBot.create(:user).id } + created_at { Time.now.utc } + + after(:build) do |custom_image_log| + custom_image_log.define_singleton_method(:readonly?) { false } + end + end end diff --git a/spec/lib/travis/api/enqueue/services/restart_model_spec.rb b/spec/lib/travis/api/enqueue/services/restart_model_spec.rb index 1f167d64c..fda57f1f0 100644 --- a/spec/lib/travis/api/enqueue/services/restart_model_spec.rb +++ b/spec/lib/travis/api/enqueue/services/restart_model_spec.rb @@ -96,6 +96,30 @@ end end end + + context 'when trying to restart a custom image build' do + let(:job) { FactoryBot.create(:job, repository: repository, state: 'canceled', config: { vm: { create: { name: 'testimg1'} } }) } + + before do + repository.permissions.create(user: user, build: true) + Travis.config.artifact_manager = { url: 'http://localhost:9911' , auth_key: 'test_test'} + stub_request(:post, "http://localhost:9911/create").to_return(status: 200, body: { image_id: 1}.to_json) + end + + include_examples 'restarts the job' + end + + context 'when trying to restart a custom image build without permission' do + let(:job) { FactoryBot.create(:job, repository: repository, state: 'canceled', config: { vm: { create: { name: 'testimg1'} } }) } + + before do + repository.permissions.create(user: user, build: true) + Travis.config.artifact_manager = { url: 'http://localhost:9911' , auth_key: 'test_test'} + stub_request(:post, "http://localhost:9911/create").to_return(status: 401) + end + + include_examples 'does not restart the job' + end end end diff --git a/spec/v3/artifact_manager_client_spec.rb b/spec/v3/artifact_manager_client_spec.rb new file mode 100644 index 000000000..687ee8a41 --- /dev/null +++ b/spec/v3/artifact_manager_client_spec.rb @@ -0,0 +1,30 @@ +describe Travis::API::V3::ArtifactManagerClient do + let(:client) { described_class.new(user_id) } + let(:url) { 'https://artifact-manager.travis-ci.com' } + let(:user_id) { rand(999) } + let(:auth_key) { 'super_secret' } + + before do + Travis.config.artifact_manager.url = url + Travis.config.artifact_manager.auth_key = auth_key + end + + describe '#delete_images' do + let(:image_ids) { [1, 2, 3] } + subject { client.delete_images('user', user_id, image_ids) } + + it 'sends a DELETE request to the artifact manager' do + stub_request(:delete, %r{#{url}/image/user/#{user_id}/\d+}) + .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }) + + subject + end + + it 'raises an error if the request fails' do + stub_request(:delete, %r{#{url}/image/user/#{user_id}/\d+}) + .to_return(status: 400, body: { error: 'Bad Request' }.to_json, headers: { 'Content-Type' => 'application/json' }) + + expect { subject }.to raise_error(Travis::API::V3::ClientError, 'Bad Request') + end + end +end diff --git a/spec/v3/services/custom_images/delete_spec.rb b/spec/v3/services/custom_images/delete_spec.rb new file mode 100644 index 000000000..dda61cd52 --- /dev/null +++ b/spec/v3/services/custom_images/delete_spec.rb @@ -0,0 +1,45 @@ +describe Travis::API::V3::Services::CustomImages::Delete, set_app: true do + let(:user) { FactoryBot.create(:user) } + let(:token) { Travis::Api::App::AccessToken.create(user: user, app_id: 1) } + let(:headers) {{ 'HTTP_AUTHORIZATION' => "token #{token}" }} + let(:parsed_body) { JSON.load(body) } + let(:url) { 'https://artifact-manager.travis-ci.com' } + let(:auth_key) { 'super_secret' } + + before do + Travis.config.artifact_manager.url = url + Travis.config.artifact_manager.auth_key = auth_key + Travis.config.host = 'travis-ci.com' + end + + context 'authenticated' do + describe "deleting custom images by id list" do + before do + stub_request(:delete, %r{#{Travis.config.artifact_manager.url}/image/user/#{user.id}/\d+}) + .to_return(status: 204, headers: { 'Content-Type' => 'application/json' }) + end + + it 'makes call to artifact manager' do + delete("/v3/owner/#{user.login}/custom_images", { image_ids: [1, 2, 3] }, headers) + + expect(last_response.status).to eq(204) + expect(last_response.body).to be_empty + end + end + + describe "deleting custom images with no permissions" do + let(:organization) { FactoryBot.create(:org) } + + before { stub_request(:get, %r((.+)/roles/org/(.+))).to_return(status: 200, body: JSON.generate({ 'roles' => [] })) } + + it 'returns an error' do + delete("/v3/owner/GitHub/#{organization.name}/custom_images", { image_ids: [1, 2, 3] }, headers) + + expect(last_response.status).to eq(403) + expect(parsed_body).to include( + 'error_type' => 'insufficient_access' + ) + end + end + end +end diff --git a/spec/v3/services/custom_images/for_owner_spec.rb b/spec/v3/services/custom_images/for_owner_spec.rb new file mode 100644 index 000000000..36412debc --- /dev/null +++ b/spec/v3/services/custom_images/for_owner_spec.rb @@ -0,0 +1,47 @@ +require 'spec_helper' + +RSpec.describe Travis::API::V3::Services::CustomImages::ForOwner, set_app: true do + let(:json_headers) { { 'HTTP_ACCEPT' => 'application/json' } } + let(:authorization) { { 'permissions' => [ 'repository_state_update', 'repository_build_create' ] } } + + before do + Travis.config.host = 'travis-ci.com' + stub_request(:get, %r((.+)/permissions/repo/(.+))).to_return(status: 200, body: JSON.generate(authorization)) + end + + context 'authenticated' do + let(:user) { FactoryBot.create(:user, name: 'Joe', login: 'joe') } + let(:user_token) { Travis::Api::App::AccessToken.create(user: user, app_id: 1) } + let!(:repository) { FactoryBot.create(:repository, owner: user) } + let!(:custom_image) { FactoryBot.create(:custom_image, owner: user) } + let!(:custom_image_log) { FactoryBot.create(:custom_image_log, custom_image: custom_image, sender_id: user.id) } + + context 'when user has custom images' do + it 'returns custom images' do + get("/v3/owner/#{user.login}/custom_images", {}, json_headers.merge('HTTP_AUTHORIZATION' => "token #{user_token}")) + + expect(last_response).to be_ok + expect(JSON.parse(last_response.body)['custom_images'].first).to include( + 'id' => custom_image.id, + 'name' => custom_image.name, + 'size_bytes' => custom_image.size_bytes + ) + end + end + + context 'when user has no build create permission' do + let(:other_user) { FactoryBot.create(:user, name: 'Jane', login: 'jane') } + let(:other_user_token) { Travis::Api::App::AccessToken.create(user: other_user, app_id: 1) } + let!(:repository) { FactoryBot.create(:repository, owner: other_user) } + let(:authorization) { { 'permissions' => [ 'repository_state_update' ] } } + + it 'returns an empty list' do + get("/v3/owner/#{user.login}/custom_images", {}, json_headers.merge('HTTP_AUTHORIZATION' => "token #{other_user_token}")) + + expect(JSON.parse(last_response.body)).to include( + 'error_type' => 'insufficient_access' + ) + end + end + end +end diff --git a/spec/v3/services/installation/find_spec.rb b/spec/v3/services/installation/find_spec.rb index 884e0db9f..1ebb672f3 100644 --- a/spec/v3/services/installation/find_spec.rb +++ b/spec/v3/services/installation/find_spec.rb @@ -61,6 +61,7 @@ "vcs_type" => user.vcs_type, "avatar_url" => "https://0.gravatar.com/avatar/07fb84848e68b96b69022d333ca8a3e2", "is_syncing" => nil, + "memberships" => [], "synced_at" => nil, "education" => nil, "allowance" => { diff --git a/spec/v3/services/owner/find_spec.rb b/spec/v3/services/owner/find_spec.rb index ade9a5861..e21785b1a 100644 --- a/spec/v3/services/owner/find_spec.rb +++ b/spec/v3/services/owner/find_spec.rb @@ -442,6 +442,7 @@ "vcs_type" => user.vcs_type, "avatar_url" => nil, "is_syncing" => nil, + "memberships" => [], "synced_at" => nil, "education" => nil, "allow_migration"=> false, @@ -480,6 +481,7 @@ "avatar_url" => nil, "education" => nil, "is_syncing" => nil, + "memberships" => [], "synced_at" => nil, "allow_migration"=> false, "allowance" => { @@ -517,6 +519,7 @@ "avatar_url" => nil, "education" => nil, "is_syncing" => nil, + "memberships" => [], "synced_at" => nil, "allow_migration" => false, "allowance" => { @@ -561,6 +564,7 @@ "avatar_url" => nil, "education" => nil, "is_syncing" => nil, + "memberships" => [], "synced_at" => nil, "allow_migration" => false, "allowance" => { diff --git a/spec/v3/services/v2_subscription/executions_spec.rb b/spec/v3/services/v2_subscription/executions_spec.rb index ef5ea0b84..532cec919 100644 --- a/spec/v3/services/v2_subscription/executions_spec.rb +++ b/spec/v3/services/v2_subscription/executions_spec.rb @@ -211,6 +211,7 @@ "account_env_vars" => [], "email"=>"sven@fuchs.com", "is_syncing"=>nil, + "memberships"=>[], "synced_at"=>nil, "recently_signed_up"=>false, "secure_user_hash"=>nil, @@ -271,6 +272,7 @@ "account_env_vars" => [], "email"=>"sven@fuchs.com", "is_syncing"=>nil, + "memberships"=>[], "synced_at"=>nil, "recently_signed_up"=>false, "secure_user_hash"=>nil, @@ -310,6 +312,7 @@ "account_env_vars" => [], "email"=>"sven@fuchs.com", "is_syncing"=>nil, + "memberships"=>[], "synced_at"=>nil, "recently_signed_up"=>false, "secure_user_hash"=>nil,