From 1badc95c1aa4fd60520eeaefa95771e66b75ee79 Mon Sep 17 00:00:00 2001 From: Alishan Ladhani <aladhani@gitlab.com> Date: Mon, 13 Dec 2021 14:40:20 -0500 Subject: [PATCH] Add Deployment Approvals API endpoint Part of the deployment approvals MVC --- doc/api/deployments.md | 35 ++++++++ ee/lib/api/entities/deployments/approval.rb | 12 +++ ee/lib/ee/api/deployments.rb | 37 +++++++++ .../public_api/v4/deployment_approval.json | 19 +++++ .../api/entities/deployments/approval_spec.rb | 13 +++ ee/spec/requests/api/deployments_spec.rb | 80 +++++++++++++++++++ lib/api/deployments.rb | 2 + 7 files changed, 198 insertions(+) create mode 100644 ee/lib/api/entities/deployments/approval.rb create mode 100644 ee/lib/ee/api/deployments.rb create mode 100644 ee/spec/fixtures/api/schemas/public_api/v4/deployment_approval.json create mode 100644 ee/spec/lib/api/entities/deployments/approval_spec.rb diff --git a/doc/api/deployments.md b/doc/api/deployments.md index 253bc76737b3ddf2..19656098f4b6d5c0 100644 --- a/doc/api/deployments.md +++ b/doc/api/deployments.md @@ -375,3 +375,38 @@ It supports the same parameters as the [Merge Requests API](merge_requests.md#li ```shell curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/deployments/42/merge_requests" ``` + +## Approve or Reject a blocked Deployment + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/343864) in GitLab 14.7 [with a flag](../administration/feature_flags.md) named `deployment_approvals`. Disabled by default. This feature is not ready for production use. + +```plaintext +POST /projects/:id/deployments/:deployment_id/approval +``` + +| Attribute | Type | Required | Description | +|-----------------|----------------|----------|-----------------------------------------------------------------------------------------------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](index.md#namespaced-path-encoding) owned by the authenticated user. | +| `deployment_id` | integer | yes | The ID of the deployment. | +| `status` | string | yes | The status of the approval (either `approved` or `rejected`). | + +```shell +curl --data "status=approved" \ + --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/deployments/1/approval" +``` + +Example response: + +```json +{ + "user": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "status": "approved" +} +``` diff --git a/ee/lib/api/entities/deployments/approval.rb b/ee/lib/api/entities/deployments/approval.rb new file mode 100644 index 0000000000000000..102e237f568fcdf3 --- /dev/null +++ b/ee/lib/api/entities/deployments/approval.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module API + module Entities + module Deployments + class Approval < Grape::Entity + expose :user, using: Entities::UserBasic + expose :status + end + end + end +end diff --git a/ee/lib/ee/api/deployments.rb b/ee/lib/ee/api/deployments.rb new file mode 100644 index 0000000000000000..c75b752421eb0027 --- /dev/null +++ b/ee/lib/ee/api/deployments.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module EE + module API + module Deployments + extend ActiveSupport::Concern + + prepended do + params do + requires :id, type: String, desc: 'The project ID' + end + resource :projects, requirements: ::API::API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + desc 'Approve or reject a blocked deployment' do + detail 'This feature is gated behind the :deployment_approvals feature flag.' + success ::API::Entities::Deployments::Approval + end + params do + requires :deployment_id, type: String, desc: 'The Deployment ID' + requires :status, type: String, values: ::Deployments::Approval.statuses.keys + end + post ':id/deployments/:deployment_id/approval' do + deployment = user_project.deployments.find(params[:deployment_id]) + + result = ::Deployments::ApprovalService.new(user_project, current_user) + .execute(deployment, params[:status]) + + if result[:status] == :success + present(result[:approval], with: ::API::Entities::Deployments::Approval, current_user: current_user) + else + render_api_error!(result[:message], 400) + end + end + end + end + end + end +end diff --git a/ee/spec/fixtures/api/schemas/public_api/v4/deployment_approval.json b/ee/spec/fixtures/api/schemas/public_api/v4/deployment_approval.json new file mode 100644 index 0000000000000000..517454d1709dc82c --- /dev/null +++ b/ee/spec/fixtures/api/schemas/public_api/v4/deployment_approval.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "required": [ + "status", + "user" + ], + "properties": { + "status": { + "type": "string" + }, + "user": { + "type": "object", + "items": { + "$ref": "../../../../../../../spec/fixtures/api/schemas/public_api/v4/user/basic.json" + } + } + }, + "additionalProperties": false +} diff --git a/ee/spec/lib/api/entities/deployments/approval_spec.rb b/ee/spec/lib/api/entities/deployments/approval_spec.rb new file mode 100644 index 0000000000000000..3c8dd1f07bc59045 --- /dev/null +++ b/ee/spec/lib/api/entities/deployments/approval_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Entities::Deployments::Approval do + subject { described_class.new(approval).as_json } + + let(:approval) { build(:deployment_approval) } + + it 'exposes correct attributes' do + expect(subject.keys).to contain_exactly(:user, :status) + end +end diff --git a/ee/spec/requests/api/deployments_spec.rb b/ee/spec/requests/api/deployments_spec.rb index c5aa328cf1f03836..ef80a845631b290e 100644 --- a/ee/spec/requests/api/deployments_spec.rb +++ b/ee/spec/requests/api/deployments_spec.rb @@ -180,4 +180,84 @@ end end end + + describe 'POST /projects/:id/deployments/:deployment_id/approval' do + shared_examples_for 'not created' do |approval_status: 'approved', response_status:, message:| + it 'does not create an approval' do + expect { post(api(path, user), params: { status: approval_status }) }.not_to change { Deployments::Approval.count } + + expect(response).to have_gitlab_http_status(response_status) + expect(response.body).to include(message) + end + end + + let(:deployment) { create(:deployment, :blocked, project: project, environment: environment, deployable: create(:ci_build, :manual, project: project)) } + let(:path) { "/projects/#{project.id}/deployments/#{deployment.id}/approval" } + + before do + create(:protected_environment, :maintainers_can_deploy, project: environment.project, name: environment.name, required_approval_count: 1) + end + + context 'when user is authorized to read project' do + before do + project.add_developer(user) + end + + context 'and Protected Environments feature is available' do + before do + stub_licensed_features(protected_environments: true) + end + + context 'and user is authorized to update deployment' do + before do + project.add_maintainer(user) + end + + it 'creates an approval' do + expect { post(api(path, user), params: { status: 'approved' }) }.to change { Deployments::Approval.count }.by(1) + + expect(response).to have_gitlab_http_status(:success) + expect(response).to match_response_schema('public_api/v4/deployment_approval', dir: 'ee') + expect(json_response['status']).to eq('approved') + expect(json_response.dig('user', 'id')).to eq(user.id) + end + end + + context 'and user is not authorized to update deployment' do + include_examples 'not created', response_status: :bad_request, message: 'You do not have permission to approve or reject this deployment' + end + + context 'with an invalid status' do + include_examples 'not created', approval_status: 'foo', response_status: :bad_request, message: 'status does not have a valid value' + end + + context 'with a deployment that does not belong to the project' do + let(:other_project) { create(:project, :repository) } + + let(:user) { other_project.creator } + let(:path) { "/projects/#{other_project.id}/deployments/#{deployment.id}/approval" } + + include_examples 'not created', response_status: :not_found, message: '404 Not found' + end + + context 'with a deployment that does not exist' do + let(:path) { "/projects/#{project.id}/deployments/0/approval" } + + include_examples 'not created', response_status: :not_found, message: '404 Not found' + end + end + + context 'when Protected Environments feature is not available' do + before do + stub_licensed_features(protected_environments: false) + end + + include_examples 'not created', response_status: :bad_request, message: 'This environment is not protected' + end + end + + context 'when user is not authorized to read project' do + include_examples 'not created', response_status: :not_found, message: '404 Project Not Found' + end + end end diff --git a/lib/api/deployments.rb b/lib/api/deployments.rb index 80a50ded5227353a..486ff5d89bc8e286 100644 --- a/lib/api/deployments.rb +++ b/lib/api/deployments.rb @@ -165,3 +165,5 @@ class Deployments < ::API::Base end end end + +API::Deployments.prepend_mod -- GitLab