Commit 6d61497d authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 5410e709 2c39ebca
......@@ -34,7 +34,7 @@ export default {
<span v-if="discoverProjectSecurityPath">
<gl-button
ref="discoverProjectSecurity"
icon="information-o"
icon="question-o"
category="tertiary"
:aria-label="$options.i18n.upgradeToManageVulnerabilities"
/>
......
......@@ -286,7 +286,7 @@
- if project_nav_tab? :clusters
- show_cluster_hint = show_gke_cluster_integration_callout?(@project)
= nav_link(controller: [:clusters, :user, :gcp]) do
= nav_link(controller: [:cluster_agents, :clusters]) do
= link_to project_clusters_path(@project), title: _('Kubernetes'), class: 'shortcuts-kubernetes' do
%span
= _('Kubernetes')
......
---
title: Use more common help icon in security report MR widget
merge_request: 55741
author:
type: other
---
title: Add DORA daily metrics modeling
merge_request: 55473
author:
type: added
......@@ -38,6 +38,7 @@
- design_management
- devops_reports
- disaster_recovery
- dora_metrics
- dynamic_application_security_testing
- editor_extension
- epics
......
---
name: dora_daily_metrics
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55473
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/291746
milestone: '13.10'
type: development
group: group::release
default_enabled: false
......@@ -100,6 +100,8 @@
- 1
- - disallow_two_factor_for_subgroups
- 1
- - dora_metrics
- 1
- - elastic_association_indexer
- 1
- - elastic_commit_indexer
......
# frozen_string_literal: true
class CreateDoraDailyMetrics < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
with_lock_retries do
create_table :dora_daily_metrics, if_not_exists: true do |t|
t.references :environment, null: false, foreign_key: { on_delete: :cascade }, index: false
t.date :date, null: false
t.integer :deployment_frequency
t.integer :lead_time_for_changes_in_seconds
t.index [:environment_id, :date], unique: true
end
end
add_check_constraint :dora_daily_metrics, "deployment_frequency >= 0", 'dora_daily_metrics_deployment_frequency_positive'
add_check_constraint :dora_daily_metrics, "lead_time_for_changes_in_seconds >= 0", 'dora_daily_metrics_lead_time_for_changes_in_seconds_positive'
end
def down
with_lock_retries do
drop_table :dora_daily_metrics
end
end
end
# frozen_string_literal: true
class AddIndexForSucceededDeployments < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
INDEX_NAME = 'index_deployments_on_environment_id_status_and_finished_at'
disable_ddl_transaction!
def up
add_concurrent_index(:deployments, %i[environment_id status finished_at], name: INDEX_NAME)
end
def down
remove_concurrent_index_by_name(:deployments, INDEX_NAME)
end
end
65f27401a76856d6cb284078204bb1b80797fa344e1a4ef3d9638c296f2d72d7
\ No newline at end of file
0a5d306735047101692bbdb37aa829bf70a225af6db7213a8c2eb8168f9a30e9
\ No newline at end of file
......@@ -12025,6 +12025,25 @@ CREATE SEQUENCE diff_note_positions_id_seq
ALTER SEQUENCE diff_note_positions_id_seq OWNED BY diff_note_positions.id;
CREATE TABLE dora_daily_metrics (
id bigint NOT NULL,
environment_id bigint NOT NULL,
date date NOT NULL,
deployment_frequency integer,
lead_time_for_changes_in_seconds integer,
CONSTRAINT dora_daily_metrics_deployment_frequency_positive CHECK ((deployment_frequency >= 0)),
CONSTRAINT dora_daily_metrics_lead_time_for_changes_in_seconds_positive CHECK ((lead_time_for_changes_in_seconds >= 0))
);
CREATE SEQUENCE dora_daily_metrics_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE dora_daily_metrics_id_seq OWNED BY dora_daily_metrics.id;
CREATE TABLE draft_notes (
id bigint NOT NULL,
merge_request_id integer NOT NULL,
......@@ -19007,6 +19026,8 @@ ALTER TABLE ONLY design_user_mentions ALTER COLUMN id SET DEFAULT nextval('desig
ALTER TABLE ONLY diff_note_positions ALTER COLUMN id SET DEFAULT nextval('diff_note_positions_id_seq'::regclass);
ALTER TABLE ONLY dora_daily_metrics ALTER COLUMN id SET DEFAULT nextval('dora_daily_metrics_id_seq'::regclass);
ALTER TABLE ONLY draft_notes ALTER COLUMN id SET DEFAULT nextval('draft_notes_id_seq'::regclass);
ALTER TABLE ONLY elastic_reindexing_subtasks ALTER COLUMN id SET DEFAULT nextval('elastic_reindexing_subtasks_id_seq'::regclass);
......@@ -20215,6 +20236,9 @@ ALTER TABLE ONLY design_user_mentions
ALTER TABLE ONLY diff_note_positions
ADD CONSTRAINT diff_note_positions_pkey PRIMARY KEY (id);
ALTER TABLE ONLY dora_daily_metrics
ADD CONSTRAINT dora_daily_metrics_pkey PRIMARY KEY (id);
ALTER TABLE ONLY draft_notes
ADD CONSTRAINT draft_notes_pkey PRIMARY KEY (id);
......@@ -22095,6 +22119,8 @@ CREATE INDEX index_deployments_on_environment_id_and_id ON deployments USING btr
CREATE INDEX index_deployments_on_environment_id_and_iid_and_project_id ON deployments USING btree (environment_id, iid, project_id);
CREATE INDEX index_deployments_on_environment_id_status_and_finished_at ON deployments USING btree (environment_id, status, finished_at);
CREATE INDEX index_deployments_on_environment_status_sha ON deployments USING btree (environment_id, status, sha);
CREATE INDEX index_deployments_on_id_and_status_and_created_at ON deployments USING btree (id, status, created_at);
......@@ -22149,6 +22175,8 @@ CREATE UNIQUE INDEX index_design_user_mentions_on_note_id ON design_user_mention
CREATE UNIQUE INDEX index_diff_note_positions_on_note_id_and_diff_type ON diff_note_positions USING btree (note_id, diff_type);
CREATE UNIQUE INDEX index_dora_daily_metrics_on_environment_id_and_date ON dora_daily_metrics USING btree (environment_id, date);
CREATE INDEX index_draft_notes_on_author_id ON draft_notes USING btree (author_id);
CREATE INDEX index_draft_notes_on_discussion_id ON draft_notes USING btree (discussion_id);
......@@ -25137,6 +25165,9 @@ ALTER TABLE ONLY boards_epic_board_positions
ALTER TABLE ONLY geo_repository_created_events
ADD CONSTRAINT fk_rails_1f49e46a61 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
ALTER TABLE ONLY dora_daily_metrics
ADD CONSTRAINT fk_rails_1fd07aff6f FOREIGN KEY (environment_id) REFERENCES environments(id) ON DELETE CASCADE;
ALTER TABLE ONLY boards_epic_lists
ADD CONSTRAINT fk_rails_1fe6b54909 FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE CASCADE;
......@@ -246,8 +246,9 @@ have been corrupted, you should reinstall the omnibus package.
## Check TCP connectivity to a remote site
Sometimes you need to know if your GitLab installation can connect to a TCP
service on another machine - perhaps a PostgreSQL or HTTPS server. A Rake task
is included to help you with this:
service on another machine (for example a PostgreSQL or web server)
in order to troubleshoot proxy issues.
A Rake task is included to help you with this.
**Omnibus Installation**
......
......@@ -2349,16 +2349,6 @@ as soon as possible.
</a>
</div>
## Troubleshooting
See the [troubleshooting documentation](troubleshooting.md).
<div align="right">
<a type="button" class="btn btn-default" href="#setup-components">
Back to setup components <i class="fa fa-angle-double-up" aria-hidden="true"></i>
</a>
</div>
## Cloud Native Deployment (optional)
Hybrid installations leverage the benefits of both cloud native and traditional
......@@ -2411,3 +2401,9 @@ Webservice pods. Expand available resources using the ratio of 1 vCPU to 1.25 GB
_per each worker process_ for each additional Webservice pod.
For further information on resource usage, see the [Webservice resources](https://docs.gitlab.com/charts/charts/gitlab/webservice/#resources).
<div align="right">
<a type="button" class="btn btn-default" href="#setup-components">
Back to setup components <i class="fa fa-angle-double-up" aria-hidden="true"></i>
</a>
</div>
......@@ -2352,13 +2352,3 @@ as soon as possible.
Back to setup components <i class="fa fa-angle-double-up" aria-hidden="true"></i>
</a>
</div>
## Troubleshooting
See the [troubleshooting documentation](troubleshooting.md).
<div align="right">
<a type="button" class="btn btn-default" href="#setup-components">
Back to setup components <i class="fa fa-angle-double-up" aria-hidden="true"></i>
</a>
</div>
......@@ -966,13 +966,3 @@ as soon as possible.
Back to setup components <i class="fa fa-angle-double-up" aria-hidden="true"></i>
</a>
</div>
## Troubleshooting
See the [troubleshooting documentation](troubleshooting.md).
<div align="right">
<a type="button" class="btn btn-default" href="#setup-components">
Back to setup components <i class="fa fa-angle-double-up" aria-hidden="true"></i>
</a>
</div>
......@@ -2032,13 +2032,3 @@ as soon as possible.
Back to setup components <i class="fa fa-angle-double-up" aria-hidden="true"></i>
</a>
</div>
## Troubleshooting
See the [troubleshooting documentation](troubleshooting.md).
<div align="right">
<a type="button" class="btn btn-default" href="#setup-components">
Back to setup components <i class="fa fa-angle-double-up" aria-hidden="true"></i>
</a>
</div>
......@@ -2366,13 +2366,3 @@ as soon as possible.
Back to setup components <i class="fa fa-angle-double-up" aria-hidden="true"></i>
</a>
</div>
## Troubleshooting
See the [troubleshooting documentation](troubleshooting.md).
<div align="right">
<a type="button" class="btn btn-default" href="#setup-components">
Back to setup components <i class="fa fa-angle-double-up" aria-hidden="true"></i>
</a>
</div>
......@@ -2027,13 +2027,3 @@ as soon as possible.
Back to setup components <i class="fa fa-angle-double-up" aria-hidden="true"></i>
</a>
</div>
## Troubleshooting
See the [troubleshooting documentation](troubleshooting.md).
<div align="right">
<a type="button" class="btn btn-default" href="#setup-components">
Back to setup components <i class="fa fa-angle-double-up" aria-hidden="true"></i>
</a>
</div>
......@@ -903,6 +903,7 @@ Autogenerated return type of CiCdSettingsUpdate.
| `project` | Project | The project this cluster agent is associated with. |
| `tokens` | ClusterAgentTokenConnection | Tokens associated with the cluster agent. |
| `updatedAt` | Time | Timestamp the cluster agent was updated. |
| `webPath` | String | Web path of the cluster agent. |
### ClusterAgentDeletePayload
......
......@@ -287,9 +287,9 @@ In GitLab 12.1 and later, only PostgreSQL is supported. In GitLab 13.0 and later
```shell
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
RELEASE=$(lsb_release -cs) echo "deb http://apt.postgresql.org/pub/repos/apt/ ${RELEASE}"-pgdg main | sudo tee /etc/apt/sources.list.d/pgdg.list
sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
sudo apt update
sudo apt -y install postgresql-11 postgresql-client-11 libpq-dev
sudo apt -y install postgresql-12 postgresql-client-12 libpq-dev
```
1. Verify the PostgreSQL version you have is supported by the version of GitLab you're
......
<script>
import { GlAlert, GlBadge, GlLoadingIcon, GlSprintf, GlTab, GlTabs } from '@gitlab/ui';
import { s__ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql';
import TokenTable from './token_table.vue';
export default {
i18n: {
installedInfo: s__('ClusterAgents|Created by %{name} %{time}'),
loadingError: s__('ClusterAgents|An error occurred while loading your agent'),
tokens: s__('ClusterAgents|Access tokens'),
unknownUser: s__('ClusterAgents|Unknown user'),
},
apollo: {
clusterAgent: {
query: getClusterAgentQuery,
variables() {
return {
agentName: this.agentName,
projectPath: this.projectPath,
};
},
update: (data) => data?.project?.clusterAgent,
error() {
this.clusterAgent = null;
},
},
},
components: {
GlAlert,
GlBadge,
GlLoadingIcon,
GlSprintf,
GlTab,
GlTabs,
TimeAgoTooltip,
TokenTable,
},
props: {
agentName: {
required: true,
type: String,
},
projectPath: {
required: true,
type: String,
},
},
computed: {
createdAt() {
return this.clusterAgent?.createdAt;
},
createdBy() {
return this.clusterAgent?.createdByUser?.name || this.$options.i18n.unknownUser;
},
isLoading() {
return this.$apollo.queries.clusterAgent.loading;
},
tokenCount() {
return this.clusterAgent?.tokens?.count;
},
tokens() {
return this.clusterAgent?.tokens?.nodes || [];
},
},
};
</script>
<template>
<section>
<h2>{{ agentName }}</h2>
<gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" />
<div v-else-if="clusterAgent">
<p data-testid="cluster-agent-create-info">
<gl-sprintf :message="$options.i18n.installedInfo">
<template #name>
{{ createdBy }}
</template>
<template #time>
<time-ago-tooltip :time="createdAt" />
</template>
</gl-sprintf>
</p>
<gl-tabs>
<gl-tab>
<template slot="title">
<span data-testid="cluster-agent-token-count">
{{ $options.i18n.tokens }}
<gl-badge v-if="tokenCount" size="sm" class="gl-tab-counter-badge">{{
tokenCount
}}</gl-badge>
</span>
</template>
<TokenTable :tokens="tokens" />
</gl-tab>
</gl-tabs>
</div>
<gl-alert v-else variant="danger" :dismissible="false">
{{ $options.i18n.loadingError }}
</gl-alert>
</section>
</template>
<script>
import { GlEmptyState, GlLink, GlTable, GlTooltip, GlTruncate } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
components: {
GlEmptyState,
GlLink,
GlTable,
GlTooltip,
GlTruncate,
TimeAgoTooltip,
},
i18n: {
createdBy: s__('ClusterAgents|Created by'),
createToken: s__('ClusterAgents|You will need to create a token to connect to your agent'),
dateCreated: s__('ClusterAgents|Date created'),
description: s__('ClusterAgents|Description'),
learnMore: s__('ClusterAgents|Learn how to create an agent access token'),
noTokens: s__('ClusterAgents|This agent has no tokens'),
unknownUser: s__('ClusterAgents|Unknown user'),
},
props: {
tokens: {
required: true,
type: Array,
},
},
computed: {
fields() {
return [
{
key: 'createdAt',
label: this.$options.i18n.dateCreated,
tdAttr: { 'data-testid': 'agent-token-created-time' },
},
{
key: 'createdBy',
label: this.$options.i18n.createdBy,
tdAttr: { 'data-testid': 'agent-token-created-user' },
},
{
key: 'description',
label: this.$options.i18n.description,
tdAttr: { 'data-testid': 'agent-token-description' },
},
];
},
learnMoreUrl() {
return helpPagePath('user/clusters/agent/index.md', {
anchor: 'create-an-agent-record-in-gitlab',
});
},
},
methods: {
createdByName(token) {
return token?.createdByUser?.name || this.$options.i18n.unknownUser;
},
},
};
</script>
<template>
<div v-if="tokens.length">
<div class="gl-text-right gl-my-5">
<gl-link target="_blank" :href="learnMoreUrl">
{{ $options.i18n.learnMore }}
</gl-link>
</div>
<gl-table :items="tokens" :fields="fields" fixed stacked="md">
<template #cell(createdAt)="{ item }">
<time-ago-tooltip :time="item.createdAt" />
</template>
<template #cell(createdBy)="{ item }">
<span>{{ createdByName(item) }}</span>
</template>
<template #cell(description)="{ item }">
<div v-if="item.description" :id="`tooltip-description-container-${item.id}`">
<gl-truncate :id="`tooltip-description-${item.id}`" :text="item.description" />
<gl-tooltip
:container="`tooltip-description-container-${item.id}`"
:target="`tooltip-description-${item.id}`"
placement="top"
>
{{ item.description }}
</gl-tooltip>
</div>
</template>
</gl-table>
</div>
<gl-empty-state
v-else
:title="$options.i18n.noTokens"
:primary-button-link="learnMoreUrl"
:primary-button-text="$options.i18n.learnMore"
/>
</template>
fragment Token on ClusterAgentToken {
id
createdAt
description
createdByUser {
name
}
}
#import "../fragments/cluster_agent_token.fragment.graphql"
query getClusterAgent($projectPath: ID!, $agentName: String!) {
project(fullPath: $projectPath) {
clusterAgent(name: $agentName) {
id
createdAt
createdByUser {
name
}
tokens {
count
nodes {
...Token
}
}
}
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import AgentShowPage from './components/show.vue';
Vue.use(VueApollo);
export default () => {
const el = document.querySelector('#js-cluster-agent-details');
if (!el) {
return null;
}
const defaultClient = createDefaultClient();
const { agentName, projectPath } = el.dataset;
return new Vue({
el,
apolloProvider: new VueApollo({ defaultClient }),
render(createElement) {
return createElement(AgentShowPage, {
props: {
agentName,
projectPath,
},
});
},
});
};
import loadClusterAgentVues from 'ee/clusters/agents';
loadClusterAgentVues();
# frozen_string_literal: true
class Projects::ClusterAgentsController < Projects::ApplicationController
before_action :authorize_can_read_cluster_agent!
feature_category :kubernetes_management
def show
@agent_name = params[:name]
end
private
def authorize_can_read_cluster_agent!
return if can?(current_user, :admin_cluster, project) && project.feature_available?(:cluster_agents)
access_denied!
end
end
......@@ -43,9 +43,18 @@ module Types
null: true,
description: 'Timestamp the cluster agent was updated.'
field :web_path,
GraphQL::STRING_TYPE,
null: true,
description: 'Web path of the cluster agent.'
def project
Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find
end
def web_path
::Gitlab::Routing.url_helpers.project_cluster_agent_path(object.project, object.name)
end
end
end
end
......@@ -19,6 +19,7 @@ module EE
override :sidebar_operations_paths
def sidebar_operations_paths
super + %w[
cluster_agents
oncall_schedules
]
end
......
# frozen_string_literal: true
module Projects::ClusterAgentsHelper
def js_cluster_agent_details_data(agent_name, project)
{
agent_name: agent_name,
project_path: project.full_path
}
end
end
# frozen_string_literal: true
module Dora
# DevOps Research and Assessment (DORA) key metrics. Deployment Frequency,
# Lead Time for Changes, Change Failure Rate and Time to Restore Service
# are tracked as daily summary.
# Reference: https://cloud.google.com/blog/products/devops-sre/using-the-four-keys-to-measure-your-devops-performance
class DailyMetrics < ApplicationRecord
belongs_to :environment
self.table_name = 'dora_daily_metrics'
class << self
def refresh!(environment, date)
raise ArgumentError unless environment.is_a?(::Environment) && date.is_a?(Date)
deployment_frequency = deployment_frequency(environment, date)
lead_time_for_changes = lead_time_for_changes(environment, date)
# This query is concurrent safe upsert with the unique index.
connection.execute(<<~SQL)
INSERT INTO #{table_name} (
environment_id,
date,
deployment_frequency,
lead_time_for_changes_in_seconds
)
VALUES (
#{environment.id},
#{ActiveRecord::Base.connection.quote(date.to_s)},
(#{deployment_frequency}),
(#{lead_time_for_changes})
)
ON CONFLICT (environment_id, date)
DO UPDATE SET
deployment_frequency = (#{deployment_frequency}),
lead_time_for_changes_in_seconds = (#{lead_time_for_changes})
SQL
end
private
# Compose a query to calculate "Deployment Frequency" of the date
def deployment_frequency(environment, date)
deployments = Deployment.arel_table
deployments
.project(deployments[:id].count)
.where(eligible_deployments(environment, date))
.to_sql
end
# Compose a query to calculate "Lead Time for Changes" of the date
def lead_time_for_changes(environment, date)
deployments = Deployment.arel_table
deployment_merge_requests = DeploymentMergeRequest.arel_table
merge_request_metrics = MergeRequest::Metrics.arel_table
deployments
.project(
Arel.sql(
'PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY EXTRACT(EPOCH FROM (deployments.finished_at - merge_request_metrics.merged_at)))'
)
)
.join(deployment_merge_requests).on(
deployment_merge_requests[:deployment_id].eq(deployments[:id])
)
.join(merge_request_metrics).on(
merge_request_metrics[:merge_request_id].eq(deployment_merge_requests[:merge_request_id])
)
.where(eligible_deployments(environment, date))
.to_sql
end
def eligible_deployments(environment, date)
deployments = Deployment.arel_table
[deployments[:environment_id].eq(environment.id),
deployments[:finished_at].gteq(date.beginning_of_day),
deployments[:finished_at].lteq(date.end_of_day),
deployments[:status].eq(Deployment.statuses[:success])].reduce(&:and)
end
end
end
end
......@@ -10,6 +10,22 @@ module EE
prepended do
include UsageStatistics
state_machine :status do
after_transition any => :success do |deployment|
next unless ::Feature.enabled?(:dora_daily_metrics, deployment.project, default_enabled: :yaml)
deployment.run_after_commit do
# Schedule to refresh the DORA daily metrics.
# It has 5 minutes delay due to waiting for the other async processes
# (e.g. `LinkMergeRequestWorker`) to be finished before collecting metrics.
::Dora::DailyMetrics::RefreshWorker
.perform_in(5.minutes,
deployment.environment_id,
Time.current.to_date.to_s)
end
end
end
end
end
end
......@@ -7,6 +7,8 @@ module EE
include ::Gitlab::Utils::StrongMemoize
prepended do
has_many :dora_daily_metrics, class_name: 'Dora::DailyMetrics'
# Returns environments where its latest deployment is to a cluster
scope :deployed_to_cluster, -> (cluster) do
environments = model.arel_table
......
- add_to_breadcrumbs _('Kubernetes'), project_clusters_path(@project)
- page_title @agent_name
#js-cluster-agent-details{ data: js_cluster_agent_details_data(@agent_name, @project) }
......@@ -339,6 +339,14 @@
:weight: 3
:idempotent: true
:tags: []
- :name: dora_metrics:dora_daily_metrics_refresh
:feature_category: :dora_metrics
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
:idempotent: true
:tags: []
- :name: epics:epics_update_epics_dates
:feature_category: :epics
:has_external_dependencies:
......
# frozen_string_literal: true
module Dora
class DailyMetrics
class RefreshWorker
include ApplicationWorker
deduplicate :until_executing
idempotent!
queue_namespace :dora_metrics
feature_category :dora_metrics
def perform(environment_id, date)
Environment.find_by_id(environment_id).try do |environment|
::Dora::DailyMetrics.refresh!(environment, Date.parse(date))
end
end
end
end
end
---
title: Added cluster agent details page
merge_request: 54106
author:
type: added
---
title: Only set note visibility_level if associated to a project
merge_request: 55111
author:
type: fixed
......@@ -127,6 +127,8 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
namespace :incident_management, path: '' do
resources :oncall_schedules, only: [:index], path: 'oncall_schedules'
end
resources :cluster_agents, only: [:show], param: :name
end
# End of the /-/ scope.
......
......@@ -54,9 +54,11 @@ module Elastic
nil
end
# protect against missing project_feature and set visibility to PRIVATE
# protect against missing project and project_feature and set visibility to PRIVATE
# if the project_feature is missing on a project
def safely_read_project_feature_for_elasticsearch(feature)
return unless target.project
if target.project.project_feature
target.project.project_feature.access_level(feature)
else
......
......@@ -22,9 +22,10 @@ module Elastic
}
end
# only attempt to set project permissions if associated to a project
# do not add the permission fields unless the `remove_permissions_data_from_notes_documents`
# migration has completed otherwise the migration will never finish
if Elastic::DataMigrationService.migration_has_finished?(:remove_permissions_data_from_notes_documents)
if target.project && Elastic::DataMigrationService.migration_has_finished?(:remove_permissions_data_from_notes_documents)
data['visibility_level'] = target.project.visibility_level
merge_project_feature_access_level(data, noteable)
end
......
# frozen_string_literal: true
FactoryBot.define do
factory :dora_daily_metrics, class: 'Dora::DailyMetrics' do
environment
date { Time.current.to_date }
end
end
......@@ -3,8 +3,9 @@
require 'spec_helper'
RSpec.describe 'ClusterAgents', :js do
let_it_be(:agent) { create(:cluster_agent) }
let_it_be(:token) { create(:cluster_agent_token, description: 'feature test token')}
let(:agent) { token.agent }
let(:project) { agent.project }
let(:user) { project.creator }
......@@ -27,6 +28,17 @@ RSpec.describe 'ClusterAgents', :js do
expect(page).not_to have_content('GitLab Agent managed clusters')
end
end
context 'when user visits agents show page' do
before do
visit project_cluster_agent_path(project, agent.name)
end
it 'displays not found' do
expect(page).to have_title('Not Found')
expect(page).to have_content('Page Not Found')
end
end
end
context 'premium user' do
......@@ -44,20 +56,35 @@ RSpec.describe 'ClusterAgents', :js do
it 'displays empty state', :aggregate_failures do
click_link 'GitLab Agent managed clusters'
expect(page).to have_link('Integrate with the GitLab Agent')
expect(page).to have_selector('.empty-state')
end
end
context 'when user has an agent and visits the index page' do
before do
visit project_clusters_path(project)
context 'when user has an agent' do
context 'when visiting the index page' do
before do
visit project_clusters_path(project)
end
it 'displays a table with agent', :aggregate_failures do
click_link 'GitLab Agent managed clusters'
expect(page).to have_content(agent.name)
expect(page).to have_selector('[data-testid="cluster-agent-list-table"] tbody tr', count: 1)
end
end
it 'displays a table with agent', :aggregate_failures do
click_link 'GitLab Agent managed clusters'
expect(page).to have_content(agent.name)
expect(page).to have_selector('[data-testid="cluster-agent-list-table"] tbody tr', count: 1)
context 'when visiting the show page' do
before do
visit project_cluster_agent_path(project, agent.name)
end
it 'displays agent and token information', :aggregate_failures do
expect(page).to have_content(agent.name)
expect(page).to have_content(token.description)
end
end
end
end
......
import { GlAlert, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import ClusterAgentShow from 'ee/clusters/agents/components/show.vue';
import TokenTable from 'ee/clusters/agents/components/token_table.vue';
import getAgentQuery from 'ee/clusters/agents/graphql/queries/get_cluster_agent.query.graphql';
import { useFakeDate } from 'helpers/fake_date';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('ClusterAgentShow', () => {
let wrapper;
useFakeDate([2021, 2, 15]);
const propsData = {
agentName: 'cluster-agent',
projectPath: 'path/to/project',
};
const defaultClusterAgent = {
id: '1',
createdAt: '2021-02-13T00:00:00Z',
createdByUser: {
name: 'user-1',
},
tokens: {
count: 1,
nodes: [],
},
};
const createWrapper = ({ clusterAgent, queryResponse = null }) => {
const agentQueryResponse =
queryResponse || jest.fn().mockResolvedValue({ data: { project: { clusterAgent } } });
const apolloProvider = createMockApollo([[getAgentQuery, agentQueryResponse]]);
wrapper = shallowMount(ClusterAgentShow, {
localVue,
apolloProvider,
propsData,
stubs: { GlSprintf, TimeAgoTooltip },
});
};
const findCreatedText = () => wrapper.find('[data-testid="cluster-agent-create-info"]').text();
const findTokenCount = () => wrapper.find('[data-testid="cluster-agent-token-count"]').text();
beforeEach(() => {
return createWrapper({ clusterAgent: defaultClusterAgent });
});
afterEach(() => {
wrapper.destroy();
});
it('displays the agent name', () => {
expect(wrapper.text()).toContain(propsData.agentName);
});
it('displays agent create information', () => {
expect(findCreatedText()).toMatchInterpolatedText('Created by user-1 2 days ago');
});
describe('when create user is unknown', () => {
const missingUser = {
...defaultClusterAgent,
createdByUser: null,
};
beforeEach(() => {
return createWrapper({ clusterAgent: missingUser });
});
it('displays agent create information with unknown user', () => {
expect(findCreatedText()).toMatchInterpolatedText('Created by Unknown user 2 days ago');
});
});
it('displays token count', () => {
expect(findTokenCount()).toMatchInterpolatedText(
`${ClusterAgentShow.i18n.tokens} ${defaultClusterAgent.tokens.count}`,
);
});
describe('when token count is missing', () => {
const missingTokens = {
...defaultClusterAgent,
tokens: null,
};
beforeEach(() => {
return createWrapper({ clusterAgent: missingTokens });
});
it('displays token header with no count', () => {
expect(findTokenCount()).toMatchInterpolatedText(`${ClusterAgentShow.i18n.tokens}`);
});
});
it('renders token table', () => {
expect(wrapper.find(TokenTable).exists()).toBe(true);
});
describe('when the agent query is loading', () => {
beforeEach(() => {
return createWrapper({
clusterAgent: null,
queryResponse: jest.fn().mockReturnValue(new Promise(() => {})),
});
});
it('displays a loading icon', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
});
describe('when the agent query has errored', () => {
beforeEach(() => {
createWrapper({ clusterAgent: null, queryResponse: jest.fn().mockRejectedValue() });
return waitForPromises();
});
it('displays an alert message', () => {
expect(wrapper.find(GlAlert).exists()).toBe(true);
expect(wrapper.text()).toContain(ClusterAgentShow.i18n.loadingError);
});
});
});
import { GlEmptyState, GlLink, GlTooltip, GlTruncate } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import TokenTable from 'ee/clusters/agents/components/token_table.vue';
import { useFakeDate } from 'helpers/fake_date';
describe('ClusterAgentTokenTable', () => {
let wrapper;
useFakeDate([2021, 2, 15]);
const defaultTokens = [
{
id: '1',
createdAt: '2021-02-13T00:00:00Z',
description: 'Description of token 1',
createdByUser: {
name: 'user-1',
},
},
{
id: '2',
createdAt: '2021-02-10T00:00:00Z',
description: null,
createdByUser: null,
},
];
const createComponent = (tokens) => {
wrapper = mount(TokenTable, { propsData: { tokens } });
return wrapper.vm.$nextTick();
};
const findEmptyState = () => wrapper.find(GlEmptyState);
const findLink = () => wrapper.find(GlLink);
beforeEach(() => {
return createComponent(defaultTokens);
});
afterEach(() => {
wrapper.destroy();
});
it('displays a learn more link', () => {
const learnMoreLink = findLink();
expect(learnMoreLink.exists()).toBe(true);
expect(learnMoreLink.text()).toBe(TokenTable.i18n.learnMore);
});
it.each`
createdText | lineNumber
${'2 days ago'} | ${0}
${'5 days ago'} | ${1}
`(
'displays created information "$createdText" for line "$lineNumber"',
({ createdText, lineNumber }) => {
const tokens = wrapper.findAll('[data-testid="agent-token-created-time"]');
const token = tokens.at(lineNumber);
expect(token.text()).toBe(createdText);
},
);
it.each`
createdBy | lineNumber
${'user-1'} | ${0}
${'Unknown user'} | ${1}
`(
'displays creator information "$createdBy" for line "$lineNumber"',
({ createdBy, lineNumber }) => {
const tokens = wrapper.findAll('[data-testid="agent-token-created-user"]');
const token = tokens.at(lineNumber);
expect(token.text()).toBe(createdBy);
},
);
it.each`
description | truncatesText | hasTooltip | lineNumber
${'Description of token 1'} | ${true} | ${true} | ${0}
${''} | ${false} | ${false} | ${1}
`(
'displays description information "$description" for line "$lineNumber"',
({ description, truncatesText, hasTooltip, lineNumber }) => {
const tokens = wrapper.findAll('[data-testid="agent-token-description"]');
const token = tokens.at(lineNumber);
expect(token.text()).toContain(description);
expect(token.find(GlTruncate).exists()).toBe(truncatesText);
expect(token.find(GlTooltip).exists()).toBe(hasTooltip);
},
);
describe('when there are no tokens', () => {
beforeEach(() => {
return createComponent([]);
});
it('displays an empty state', () => {
const emptyState = findEmptyState();
expect(emptyState.exists()).toBe(true);
expect(emptyState.text()).toContain(TokenTable.i18n.noTokens);
});
});
});
......@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe GitlabSchema.types['ClusterAgent'] do
let(:fields) { %i[created_at created_by_user id name project updated_at tokens] }
let(:fields) { %i[created_at created_by_user id name project updated_at tokens web_path] }
it { expect(described_class.graphql_name).to eq('ClusterAgent') }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::ClusterAgentsHelper do
describe '#js_cluster_agent_details_data' do
let_it_be(:project) { create(:project) }
let(:agent_name) { 'agent-name' }
subject { helper.js_cluster_agent_details_data(agent_name, project) }
it 'returns name' do
expect(subject[:agent_name]).to eq(agent_name)
end
it 'returns project path' do
expect(subject[:project_path]).to eq(project.full_path)
end
end
end
......@@ -128,10 +128,10 @@ RSpec.describe Note, :elastic do
expect { note.__elasticsearch__.as_indexed_json }.not_to raise_error
end
where(:note_type, :permission, :access_level) do
where(:note_type, :project_permission, :access_level) do
:note_on_issue | ProjectFeature::ENABLED | 'issues_access_level'
:note_on_project_snippet | ProjectFeature::DISABLED | 'snippets_access_level'
:note_on_personal_snippet | ProjectFeature::DISABLED | 'snippets_access_level'
:note_on_personal_snippet | nil | false
:note_on_merge_request | ProjectFeature::PUBLIC | 'merge_requests_access_level'
:note_on_commit | ProjectFeature::PRIVATE | 'repository_access_level'
:diff_note_on_merge_request | ProjectFeature::PUBLIC | 'merge_requests_access_level'
......@@ -141,25 +141,26 @@ RSpec.describe Note, :elastic do
:legacy_diff_note_on_commit | ProjectFeature::PRIVATE | 'repository_access_level'
:note_on_alert | ProjectFeature::PRIVATE | false
:note_on_design | ProjectFeature::ENABLED | false
:note_on_epic | ProjectFeature::ENABLED | false
:note_on_epic | nil | false
:note_on_vulnerability | ProjectFeature::PRIVATE | false
:discussion_note_on_vulnerability | ProjectFeature::PRIVATE | false
:discussion_note_on_merge_request | ProjectFeature::PUBLIC | 'merge_requests_access_level'
:discussion_note_on_issue | ProjectFeature::ENABLED | 'issues_access_level'
:discussion_note_on_project_snippet | ProjectFeature::DISABLED | 'snippets_access_level'
:discussion_note_on_personal_snippet | ProjectFeature::DISABLED | 'snippets_access_level'
:discussion_note_on_personal_snippet | nil | false
:note_on_merge_request | ProjectFeature::PUBLIC | 'merge_requests_access_level'
:discussion_note_on_commit | ProjectFeature::PRIVATE | 'repository_access_level'
:track_mr_picking_note | ProjectFeature::PUBLIC | 'merge_requests_access_level'
end
with_them do
let_it_be(:project) { create(:project, :repository) }
let!(:note) { create(note_type, project: project) } # rubocop:disable Rails/SaveBang
let!(:note) { create(note_type) } # rubocop:disable Rails/SaveBang
let(:project) { note.project }
let(:note_json) { note.__elasticsearch__.as_indexed_json }
before do
project.project_feature.update_attribute(access_level.to_sym, permission) if access_level.present?
project.project_feature.update_attribute(access_level.to_sym, project_permission) if access_level.present?
end
it 'does not contain permissions if remove_permissions_data_from_notes_documents is not finished' do
......@@ -174,11 +175,15 @@ RSpec.describe Note, :elastic do
it 'contains the correct permissions', :aggregate_failures do
if access_level
expect(note_json).to have_key(access_level)
expect(note_json[access_level]).to eq(permission)
expect(note_json[access_level]).to eq(project_permission)
end
expect(note_json).to have_key('visibility_level')
expect(note_json['visibility_level']).to eq(project.visibility_level)
if project_permission
expect(note_json).to have_key('visibility_level')
expect(note_json['visibility_level']).to eq(project.visibility_level)
else
expect(note_json).not_to have_key('visibility_level')
end
end
end
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Deployment do
describe 'state machine' do
context 'when deployment succeeded' do
let(:deployment) { create(:deployment, :running) }
it 'schedules Dora::DailyMetrics::RefreshWorker' do
freeze_time do
expect(::Dora::DailyMetrics::RefreshWorker)
.to receive(:perform_in).with(
5.minutes,
deployment.environment_id,
Time.current.to_date.to_s)
deployment.succeed!
end
end
context 'when dora_daily_metrics feature flag is disabled' do
before do
stub_feature_flags(dora_daily_metrics: false)
end
it 'does not schedule Dora::DailyMetrics::RefreshWorker' do
expect(::Dora::DailyMetrics::RefreshWorker).not_to receive(:perform_in)
deployment.succeed!
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Dora::DailyMetrics, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:environment) }
end
describe '.refresh!' do
subject { described_class.refresh!(environment, date) }
around do |example|
freeze_time { example.run }
end
let_it_be(:project) { create(:project, :repository) }
let_it_be(:environment) { create(:environment, project: project) }
let(:date) { 1.day.ago.to_date }
context 'with finished deployments' do
before do
# Deployment finished before the date
previous_date = date - 1.day
create(:deployment, :success, environment: environment, finished_at: previous_date)
create(:deployment, :failed, environment: environment, finished_at: previous_date)
# Deployment finished on the date
create(:deployment, :success, environment: environment, finished_at: date)
create(:deployment, :failed, environment: environment, finished_at: date)
# Deployment finished after the date
next_date = date + 1.day
create(:deployment, :success, environment: environment, finished_at: next_date)
create(:deployment, :failed, environment: environment, finished_at: next_date)
end
it 'inserts the daily metrics' do
expect { subject }.to change { Dora::DailyMetrics.count }.by(1)
metrics = environment.dora_daily_metrics.find_by_date(date)
expect(metrics.deployment_frequency).to eq(1)
expect(metrics.lead_time_for_changes_in_seconds).to be_nil
end
context 'when there is an existing daily metric' do
before do
create(:dora_daily_metrics, environment: environment, date: date, deployment_frequency: 0)
end
it 'updates the daily metrics' do
expect { subject }.not_to change { Dora::DailyMetrics.count }
metrics = environment.dora_daily_metrics.find_by_date(date)
expect(metrics.deployment_frequency).to eq(1)
end
end
end
context 'with finished deployments and merged MRs' do
before do
merge_requests = []
# Merged 1 day ago
merge_requests << create(:merge_request, :with_merged_metrics, project: project).tap do |merge_request|
merge_request.metrics.update!(merged_at: date - 1.day)
end
# Merged 2 days ago
merge_requests << create(:merge_request, :with_merged_metrics, project: project).tap do |merge_request|
merge_request.metrics.update!(merged_at: date - 2.days)
end
# Merged 3 days ago
merge_requests << create(:merge_request, :with_merged_metrics, project: project).tap do |merge_request|
merge_request.metrics.update!(merged_at: date - 3.days)
end
# Deployment finished on the date
create(:deployment, :success, environment: environment, finished_at: date, merge_requests: merge_requests)
end
it 'inserts the daily metrics' do
subject
metrics = environment.dora_daily_metrics.find_by_date(date)
expect(metrics.lead_time_for_changes_in_seconds).to eq(2.days.to_i) # median
end
context 'when there is an existing daily metric' do
let!(:dora_daily_metrics) { create(:dora_daily_metrics, environment: environment, date: date, lead_time_for_changes_in_seconds: nil) }
it 'updates the daily metrics' do
expect { subject }
.to change { dora_daily_metrics.reload.lead_time_for_changes_in_seconds }
.from(nil)
.to(2.days.to_i)
end
end
end
context 'when date is invalid type' do
let(:date) { '2021-02-03' }
it 'raises an error' do
expect { subject }.to raise_error(ArgumentError)
end
end
end
end
......@@ -8,6 +8,8 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
let(:project) { create(:project, :repository) }
let(:environment) { create(:environment, project: project) }
it { is_expected.to have_many(:dora_daily_metrics) }
describe '.deployed_to_cluster' do
let!(:environment) { create(:environment) }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::ClusterAgentsController do
let_it_be(:cluster_agent) { create(:cluster_agent) }
let(:project) { cluster_agent.project }
describe 'GET #show' do
subject { get project_cluster_agent_path(project, cluster_agent.name) }
context 'when user is unauthorized' do
let_it_be(:user) { create(:user) }
before do
project.add_developer(user)
sign_in(user)
subject
end
it 'shows 404' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when user is authorized' do
let(:user) { project.creator }
context 'without premium plan' do
before do
sign_in(user)
subject
end
it 'shows 404' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'with premium plan' do
before do
stub_licensed_features(cluster_agents: true)
sign_in(user)
subject
end
it 'renders content' do
expect(response).to be_successful
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Dora::DailyMetrics::RefreshWorker do
let_it_be(:environment) { create(:environment) }
let(:worker) { described_class.new }
describe '#perform' do
subject { worker.perform(environment_id, date.to_s) }
let(:environment_id) { environment.id }
let(:date) { Time.current.to_date }
it 'refreshes the DORA metrics on the environment and date' do
expect(::Dora::DailyMetrics).to receive(:refresh!).with(environment, date)
subject
end
context 'when the date is not parsable' do
let(:date) { 'abc' }
it 'raises an error' do
expect { subject }.to raise_error(Date::Error)
end
end
context 'when an environment does not exist' do
let(:environment_id) { non_existing_record_id }
it 'does not refresh' do
expect(::Dora::DailyMetrics).not_to receive(:refresh!)
subject
end
end
end
end
......@@ -6317,21 +6317,42 @@ msgstr ""
msgid "Cluster type must be specificed for Stages::ClusterEndpointInserter"
msgstr ""
msgid "ClusterAgents|Access tokens"
msgstr ""
msgid "ClusterAgents|An error occurred while loading your GitLab Agents"
msgstr ""
msgid "ClusterAgents|An error occurred while loading your agent"
msgstr ""
msgid "ClusterAgents|Configuration"
msgstr ""
msgid "ClusterAgents|Connect your cluster with the GitLab Agent"
msgstr ""
msgid "ClusterAgents|Created by"
msgstr ""
msgid "ClusterAgents|Created by %{name} %{time}"
msgstr ""
msgid "ClusterAgents|Date created"
msgstr ""
msgid "ClusterAgents|Description"
msgstr ""
msgid "ClusterAgents|Integrate Kubernetes with a GitLab Agent"
msgstr ""
msgid "ClusterAgents|Integrate with the GitLab Agent"
msgstr ""
msgid "ClusterAgents|Learn how to create an agent access token"
msgstr ""
msgid "ClusterAgents|Name"
msgstr ""
......@@ -6341,6 +6362,15 @@ msgstr ""
msgid "ClusterAgents|The GitLab Kubernetes Agent allows an Infrastructure as Code, GitOps approach to integrating Kubernetes clusters with GitLab. %{linkStart}Learn more.%{linkEnd}"
msgstr ""
msgid "ClusterAgents|This agent has no tokens"
msgstr ""
msgid "ClusterAgents|Unknown user"
msgstr ""
msgid "ClusterAgents|You will need to create a token to connect to your agent"
msgstr ""
msgid "ClusterAgent|This feature is only available for premium plans"
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment