Skip to content
Snippets Groups Projects
Commit c845aeb9 authored by Shinya Maeda's avatar Shinya Maeda
Browse files

Add GraphQL API to update Canary Ingress Weight

Canary Ingress weight is to control the traffic
between stable and canary tracks in Auto Deployed
environment.
parent d3f5f211
No related branches found
No related tags found
No related merge requests found
Showing
with 736 additions and 1 deletion
Loading
Loading
@@ -305,6 +305,10 @@ def has_opened_alert?
latest_opened_most_severe_alert.present?
end
 
def has_running_deployments?
all_deployments.running.exists?
end
def metrics
prometheus_adapter.query(:environment, self) if has_metrics_and_can_query?
end
Loading
Loading
Loading
Loading
@@ -4,6 +4,11 @@ class EnvironmentEntity < Grape::Entity
include RequestAwareEntity
 
expose :id
expose :global_id do |environment|
environment.to_global_id.to_s
end
expose :name
expose :state
expose :external_url
Loading
Loading
Loading
Loading
@@ -6618,6 +6618,41 @@ Identifier of Environment
"""
scalar EnvironmentID
 
"""
Autogenerated input type of EnvironmentsCanaryIngressUpdate
"""
input EnvironmentsCanaryIngressUpdateInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The global ID of the environment to update
"""
id: EnvironmentID!
"""
The weight of the Canary Ingress
"""
weight: Int!
}
"""
Autogenerated return type of EnvironmentsCanaryIngressUpdate
"""
type EnvironmentsCanaryIngressUpdatePayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
}
"""
Represents an epic
"""
Loading
Loading
@@ -13208,6 +13243,7 @@ type Mutation {
"""
discussionToggleResolve(input: DiscussionToggleResolveInput!): DiscussionToggleResolvePayload
dismissVulnerability(input: DismissVulnerabilityInput!): DismissVulnerabilityPayload @deprecated(reason: "Use vulnerabilityDismiss. Deprecated in 13.5")
environmentsCanaryIngressUpdate(input: EnvironmentsCanaryIngressUpdateInput!): EnvironmentsCanaryIngressUpdatePayload
epicAddIssue(input: EpicAddIssueInput!): EpicAddIssuePayload
epicSetSubscription(input: EpicSetSubscriptionInput!): EpicSetSubscriptionPayload
epicTreeReorder(input: EpicTreeReorderInput!): EpicTreeReorderPayload
Loading
Loading
Loading
Loading
@@ -18349,6 +18349,108 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "EnvironmentsCanaryIngressUpdateInput",
"description": "Autogenerated input type of EnvironmentsCanaryIngressUpdate",
"fields": null,
"inputFields": [
{
"name": "id",
"description": "The global ID of the environment to update",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "EnvironmentID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "weight",
"description": "The weight of the Canary Ingress",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "EnvironmentsCanaryIngressUpdatePayload",
"description": "Autogenerated return type of EnvironmentsCanaryIngressUpdate",
"fields": [
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "Epic",
Loading
Loading
@@ -37454,6 +37556,33 @@
"isDeprecated": true,
"deprecationReason": "Use vulnerabilityDismiss. Deprecated in 13.5"
},
{
"name": "environmentsCanaryIngressUpdate",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "EnvironmentsCanaryIngressUpdateInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "EnvironmentsCanaryIngressUpdatePayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "epicAddIssue",
"description": null,
Loading
Loading
@@ -1101,6 +1101,15 @@ Describes where code is deployed for a project.
| `path` | String! | The path to the environment. |
| `state` | String! | State of the environment, for example: available/stopped |
 
### EnvironmentsCanaryIngressUpdatePayload
Autogenerated return type of EnvironmentsCanaryIngressUpdate.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
### Epic
 
Represents an epic.
Loading
Loading
Loading
Loading
@@ -13,6 +13,7 @@ module MutationType
mount_mutation ::Mutations::Issues::SetIteration
mount_mutation ::Mutations::Issues::SetWeight
mount_mutation ::Mutations::Issues::SetEpic
mount_mutation ::Mutations::Environments::CanaryIngress::Update
mount_mutation ::Mutations::EpicTree::Reorder
mount_mutation ::Mutations::Epics::Update
mount_mutation ::Mutations::Epics::Create
Loading
Loading
# frozen_string_literal: true
module Mutations
module Environments
module CanaryIngress
class Update < ::Mutations::BaseMutation
graphql_name 'EnvironmentsCanaryIngressUpdate'
authorize :update_environment
argument :id,
::Types::GlobalIDType[::Environment],
required: true,
description: 'The global ID of the environment to update'
argument :weight,
GraphQL::INT_TYPE,
required: true,
description: 'The weight of the Canary Ingress'
def resolve(id:, **kwargs)
environment = authorized_find!(id: id)
result = ::Environments::CanaryIngress::UpdateService
.new(environment.project, current_user, kwargs)
.execute(environment)
{ errors: Array.wrap(result[:message]) }
end
def find_object(id:)
# TODO: remove as part of https://gitlab.com/gitlab-org/gitlab/-/issues/257883
id = ::Types::GlobalIDType[::Environment].coerce_isolated_input(id)
GitlabSchema.find_by_gid(id)
end
end
end
end
end
Loading
Loading
@@ -38,6 +38,15 @@ def rollout_status(environment, data)
::Gitlab::Kubernetes::RolloutStatus.from_deployments(*deployments, pods_attrs: pods, ingresses: ingresses)
end
 
def ingresses(namespace)
ingresses = read_ingresses(namespace)
ingresses.map { |ingress| ::Gitlab::Kubernetes::Ingress.new(ingress) }
end
def patch_ingress(namespace, ingress, data)
kubeclient.patch_ingress(ingress.name, data, namespace)
end
private
 
def read_deployments(namespace)
Loading
Loading
Loading
Loading
@@ -75,6 +75,18 @@ def rollout_status
result || ::Gitlab::Kubernetes::RolloutStatus.loading
end
 
def ingresses
return unless rollout_status_available?
deployment_platform.ingresses(deployment_namespace)
end
def patch_ingress(ingress, data)
return unless rollout_status_available?
deployment_platform.patch_ingress(deployment_namespace, ingress, data)
end
private
 
def rollout_status_available?
Loading
Loading
# frozen_string_literal: true
module Environments
module CanaryIngress
class UpdateService < ::BaseService
def execute(environment)
result = validate(environment)
return result unless result[:status] == :success
canary_ingress = environment.ingresses&.find(&:canary?)
unless canary_ingress.present?
return error(_('Canary Ingress does not exist in the environment.'))
end
if environment.patch_ingress(canary_ingress, patch_data)
success
else
error(_('Failed to update the Canary Ingress.'), :bad_request)
end
end
private
def validate(environment)
unless Feature.enabled?(:canary_ingress_weight_control, environment.project)
return error(_("Feature flag is not enabled on the environment's project."))
end
unless can?(current_user, :update_environment, environment)
return error(_('You do not have permission to update the environment.'))
end
unless environment.project.feature_available?(:deploy_board)
return error(_('The license for Deploy Board is required to use this feature.'))
end
unless params[:weight].is_a?(Integer) && (0..100).cover?(params[:weight])
return error(_('Canary weight must be specified and valid range (0..100).'))
end
if environment.has_running_deployments?
return error(_('There are running deployments on the environment. Please retry later.'))
end
if ::Gitlab::ApplicationRateLimiter.throttled?(:update_environment_canary_ingress, scope: [environment])
return error(_("This environment's canary ingress has been updated recently. Please retry later."))
end
success
end
def patch_data
{
metadata: {
annotations: {
Gitlab::Kubernetes::Ingress::ANNOTATION_KEY_CANARY_WEIGHT => params[:weight].to_s
}
}
}
end
end
end
end
Loading
Loading
@@ -28,6 +28,10 @@ def canary_weight
annotations[ANNOTATION_KEY_CANARY_WEIGHT].to_i
end
 
def name
metadata['name']
end
private
 
def metadata
Loading
Loading
Loading
Loading
@@ -14,6 +14,7 @@
"id": {
"type": "integer"
},
"global_id": { "type": "string" },
"name": {
"type": "string"
},
Loading
Loading
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Environments::CanaryIngress::Update do
let_it_be(:project) { create(:project) }
let_it_be(:environment) { create(:environment, project: project) }
let_it_be(:maintainer) { create(:user) }
let_it_be(:reporter) { create(:user) }
let(:user) { maintainer }
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
before_all do
project.add_maintainer(maintainer)
project.add_reporter(reporter)
end
describe '#resolve' do
subject { mutation.resolve(id: environment_id, weight: weight) }
let(:environment_id) { environment.to_global_id.to_s }
let(:weight) { 50 }
let(:update_service) { double('update_service') }
before do
allow(Environments::CanaryIngress::UpdateService).to receive(:new) { update_service }
end
context 'when service execution succeeded' do
before do
allow(update_service).to receive(:execute) { { status: :success } }
end
it 'returns no errors' do
expect(subject[:errors]).to be_empty
end
end
context 'when service encounters a problem' do
before do
allow(update_service).to receive(:execute) { { status: :error, message: 'something went wrong' } }
end
it 'returns an error' do
expect(subject[:errors]).to eq(['something went wrong'])
end
end
context 'when environment is not found' do
let(:environment_id) { non_existing_record_id.to_s }
it 'raises an error' do
expect { subject }.to raise_error(GraphQL::CoercionError)
end
end
context 'when user is reporter who does not have permission to access the environment' do
let(:user) { reporter }
it 'raises an error' do
expect { subject }.to raise_error("The resource that you are attempting to access does not exist or you don't have permission to perform this action")
end
end
end
end
Loading
Loading
@@ -39,6 +39,14 @@
end
end
 
describe '#name' do
subject { ingress.name }
let(:params) { stable_metadata }
it { is_expected.to eq('production-auto-deploy') }
end
def stable_metadata
kube_ingress(track: :stable)
end
Loading
Loading
Loading
Loading
@@ -380,4 +380,62 @@
it { is_expected.to include(deployments: [], ingresses: []) }
end
end
describe '#ingresses' do
subject { service.ingresses(namespace) }
let(:service) { create(:cluster_platform_kubernetes, :configured) }
let(:namespace) { 'project-namespace' }
context 'when there is an ingress in the namespace' do
before do
stub_kubeclient_ingresses(namespace)
end
it 'returns an ingress' do
expect(subject.count).to eq(1)
expect(subject.first).to be_kind_of(::Gitlab::Kubernetes::Ingress)
expect(subject.first.name).to eq('production-auto-deploy')
end
end
context 'when there are no ingresss in the namespace' do
before do
allow(service.kubeclient).to receive(:get_ingresses) { raise Kubeclient::ResourceNotFoundError.new(404, 'Not found', nil) }
end
it 'returns nothing' do
is_expected.to be_empty
end
end
end
describe '#patch_ingress' do
subject { service.patch_ingress(namespace, ingress, data) }
let(:service) { create(:cluster_platform_kubernetes, :configured) }
let(:namespace) { 'project-namespace' }
let(:ingress) { Gitlab::Kubernetes::Ingress.new(kube_ingress) }
let(:data) { { metadata: { annotations: { name: 'test' } } } }
context 'when there is an ingress in the namespace' do
before do
stub_kubeclient_ingresses(namespace, method: :patch, resource_path: "/#{ingress.name}")
end
it 'returns an ingress' do
expect(subject[:items][0][:metadata][:name]).to eq('production-auto-deploy')
end
end
context 'when there are no ingresss in the namespace' do
before do
allow(service.kubeclient).to receive(:patch_ingress) { raise Kubeclient::ResourceNotFoundError.new(404, 'Not found', nil) }
end
it 'raises an error' do
expect { subject }.to raise_error(Kubeclient::ResourceNotFoundError)
end
end
end
end
Loading
Loading
@@ -229,4 +229,78 @@
end
end
end
describe '#ingresses' do
subject { environment.ingresses }
let(:deployment_platform) { double(:deployment_platform) }
let(:deployment_namespace) { 'production' }
before do
allow(environment).to receive(:deployment_platform) { deployment_platform }
allow(environment).to receive(:deployment_namespace) { deployment_namespace }
end
context 'when rollout status is available' do
before do
allow(environment).to receive(:rollout_status_available?) { true }
end
it 'fetches ingresses from the deployment platform' do
expect(deployment_platform).to receive(:ingresses).with(deployment_namespace)
subject
end
end
context 'when rollout status is not available' do
before do
allow(environment).to receive(:rollout_status_available?) { false }
end
it 'does nothing' do
expect(deployment_platform).not_to receive(:ingresses)
subject
end
end
end
describe '#patch_ingress' do
subject { environment.patch_ingress(ingress, data) }
let(:ingress) { double(:ingress) }
let(:data) { double(:data) }
let(:deployment_platform) { double(:deployment_platform) }
let(:deployment_namespace) { 'production' }
before do
allow(environment).to receive(:deployment_platform) { deployment_platform }
allow(environment).to receive(:deployment_namespace) { deployment_namespace }
end
context 'when rollout status is available' do
before do
allow(environment).to receive(:rollout_status_available?) { true }
end
it 'fetches ingresses from the deployment platform' do
expect(deployment_platform).to receive(:patch_ingress).with(deployment_namespace, ingress, data)
subject
end
end
context 'when rollout status is not available' do
before do
allow(environment).to receive(:rollout_status_available?) { false }
end
it 'does nothing' do
expect(deployment_platform).not_to receive(:patch_ingress)
subject
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Update Environment Canary Ingress', :clean_gitlab_redis_cache do
include GraphqlHelpers
include KubernetesHelpers
let_it_be(:project) { create(:project, :repository) }
let_it_be(:cluster) { create(:cluster, :project, projects: [project]) }
let_it_be(:service) { create(:cluster_platform_kubernetes, :configured, cluster: cluster) }
let_it_be(:environment) { create(:environment, project: project) }
let_it_be(:deployment) { create(:deployment, :success, environment: environment, project: project) }
let_it_be(:maintainer) { create(:user) }
let_it_be(:developer) { create(:user) }
let(:environment_id) { environment.to_global_id.to_s }
let(:weight) { 25 }
let(:actor) { developer }
let(:mutation) do
graphql_mutation(:environments_canary_ingress_update, id: environment_id, weight: weight)
end
before_all do
project.add_maintainer(maintainer)
project.add_developer(developer)
end
before do
stub_licensed_features(deploy_board: true, protected_environments: true)
stub_kubeclient_ingresses(environment.deployment_namespace, response: kube_ingresses_response(with_canary: true))
end
context 'when kubernetes accepted the patch request' do
before do
stub_kubeclient_ingresses(environment.deployment_namespace, method: :patch, resource_path: "/production-auto-deploy")
end
it 'updates successfully' do
post_graphql_mutation(mutation, current_user: actor)
expect(graphql_mutation_response(:environments_canary_ingress_update)['errors'])
.to be_empty
end
context 'when environment is protected and allowed to be deployed by only operator' do
before do
create(:protected_environment, :maintainers_can_deploy, name: environment.name, project: project)
end
it 'fails to update' do
post_graphql_mutation(mutation, current_user: actor)
expect(graphql_errors.first)
.to include('message' => "The resource that you are attempting to access does not exist or you don't have permission to perform this action")
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Environments::CanaryIngress::UpdateService, :clean_gitlab_redis_cache do
include KubernetesHelpers
let_it_be(:project, refind: true) { create(:project) }
let_it_be(:maintainer) { create(:user) }
let_it_be(:reporter) { create(:user) }
let(:user) { maintainer }
let(:params) { {} }
let(:service) { described_class.new(project, user, params) }
before_all do
project.add_maintainer(maintainer)
project.add_reporter(reporter)
end
before do
stub_licensed_features(deploy_board: true)
end
describe '#execute' do
subject { service.execute(environment) }
let(:environment) { create(:environment, project: project) }
let(:params) { { weight: 50 } }
let(:canary_ingress) { ::Gitlab::Kubernetes::Ingress.new(kube_ingress(track: :canary)) }
shared_examples_for 'failed request' do
it 'returns an error' do
expect(subject[:status]).to eq(:error)
expect(subject[:message]).to eq(message)
end
end
context 'when canary ingress is present in the environment' do
before do
allow(environment).to receive(:ingresses) { [canary_ingress] }
end
context 'when patch request succeeds' do
let(:patch_data) do
{
metadata: {
annotations: {
Gitlab::Kubernetes::Ingress::ANNOTATION_KEY_CANARY_WEIGHT => params[:weight].to_s
}
}
}
end
before do
allow(environment).to receive(:patch_ingress).with(canary_ingress, patch_data) { true }
end
it 'returns success' do
expect(subject[:status]).to eq(:success)
expect(subject[:message]).to be_nil
end
end
context 'when patch request does not succeed' do
before do
allow(environment).to receive(:patch_ingress) { false }
end
it_behaves_like 'failed request' do
let(:message) { 'Failed to update the Canary Ingress.' }
end
end
end
context 'when canary ingress is not present in the environment' do
it_behaves_like 'failed request' do
let(:message) { 'Canary Ingress does not exist in the environment.' }
end
end
context 'when canary_ingress_weight_control feature flag is disabled' do
before do
stub_feature_flags(canary_ingress_weight_control: false)
end
it_behaves_like 'failed request' do
let(:message) { "Feature flag is not enabled on the environment's project." }
end
end
context 'when the actor does not have permission to update environment' do
let(:user) { reporter }
it_behaves_like 'failed request' do
let(:message) { "You do not have permission to update the environment." }
end
end
context 'when project does not have an sufficient license' do
before do
stub_licensed_features(deploy_board: false)
end
it_behaves_like 'failed request' do
let(:message) { 'The license for Deploy Board is required to use this feature.' }
end
end
context 'when weight parameter is invalid' do
let(:params) { { weight: 'unknown' } }
it_behaves_like 'failed request' do
let(:message) { 'Canary weight must be specified and valid range (0..100).' }
end
end
context 'when no parameters exist' do
let(:params) { {} }
it_behaves_like 'failed request' do
let(:message) { 'Canary weight must be specified and valid range (0..100).' }
end
end
context 'when environment has a running deployment' do
before do
allow(environment).to receive(:has_running_deployments?) { true }
end
it_behaves_like 'failed request' do
let(:message) { 'There are running deployments on the environment. Please retry later.' }
end
end
context 'when canary ingress was updated recently' do
before do
allow(::Gitlab::ApplicationRateLimiter).to receive(:throttled?) { true }
end
it_behaves_like 'failed request' do
let(:message) { "This environment's canary ingress has been updated recently. Please retry later." }
end
end
end
end
Loading
Loading
@@ -33,7 +33,8 @@ def rate_limits
group_import: { threshold: -> { application_settings.group_import_limit }, interval: 1.minute },
group_testing_hook: { threshold: 5, interval: 1.minute },
profile_add_new_email: { threshold: 5, interval: 1.minute },
profile_resend_email_confirmation: { threshold: 5, interval: 1.minute }
profile_resend_email_confirmation: { threshold: 5, interval: 1.minute },
update_environment_canary_ingress: { threshold: 1, interval: 1.minute }
}.freeze
end
 
Loading
Loading
Loading
Loading
@@ -175,6 +175,16 @@ def get_ingresses(**args)
end
end
 
def patch_ingress(*args)
extensions_client.discover unless extensions_client.discovered
if extensions_client.respond_to?(:patch_ingress)
extensions_client.patch_ingress(*args)
else
networking_client.patch_ingress(*args)
end
end
def create_or_update_cluster_role_binding(resource)
update_cluster_role_binding(resource)
end
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment