diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 3064e8338b7..d35a2f26fd1 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -d92a2acbdcc9e20cac9e64692564556314f6e476 +bfd3175bf92587f21d17e2107e1e7e2ee0fa69bc diff --git a/app/assets/javascripts/admin/statistics_panel/components/app.vue b/app/assets/javascripts/admin/statistics_panel/components/app.vue index f250bdae4f5..347d5f0229c 100644 --- a/app/assets/javascripts/admin/statistics_panel/components/app.vue +++ b/app/assets/javascripts/admin/statistics_panel/components/app.vue @@ -1,10 +1,11 @@ diff --git a/app/assets/javascripts/groups/settings/components/access_dropdown.vue b/app/assets/javascripts/groups/settings/components/access_dropdown.vue index b3813636631..28f059fa23e 100644 --- a/app/assets/javascripts/groups/settings/components/access_dropdown.vue +++ b/app/assets/javascripts/groups/settings/components/access_dropdown.vue @@ -173,21 +173,23 @@ export default { - +
+ +
diff --git a/app/assets/javascripts/pages/groups/runners/index.js b/app/assets/javascripts/pages/groups/runners/index/index.js similarity index 100% rename from app/assets/javascripts/pages/groups/runners/index.js rename to app/assets/javascripts/pages/groups/runners/index/index.js diff --git a/app/assets/javascripts/pages/groups/runners/show/index.js b/app/assets/javascripts/pages/groups/runners/show/index.js new file mode 100644 index 00000000000..c59e3b80dc1 --- /dev/null +++ b/app/assets/javascripts/pages/groups/runners/show/index.js @@ -0,0 +1,3 @@ +import { initGroupRunnerShow } from '~/runner/group_runner_show'; + +initGroupRunnerShow(); diff --git a/app/assets/javascripts/releases/components/release_block_footer.vue b/app/assets/javascripts/releases/components/release_block_footer.vue index e7eaf5a49bf..3881c83b5c2 100644 --- a/app/assets/javascripts/releases/components/release_block_footer.vue +++ b/app/assets/javascripts/releases/components/release_block_footer.vue @@ -66,8 +66,11 @@ export default { diff --git a/app/assets/javascripts/runner/group_runner_show/index.js b/app/assets/javascripts/runner/group_runner_show/index.js index d1b87c8e427..62a0dab9211 100644 --- a/app/assets/javascripts/runner/group_runner_show/index.js +++ b/app/assets/javascripts/runner/group_runner_show/index.js @@ -1,21 +1,18 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage'; import GroupRunnerShowApp from './group_runner_show_app.vue'; Vue.use(VueApollo); -export const initAdminRunnerShow = (selector = '#js-group-runner-show') => { - showAlertFromLocalStorage(); - +export const initGroupRunnerShow = (selector = '#js-group-runner-show') => { const el = document.querySelector(selector); if (!el) { return null; } - const { runnerId, runnersPath } = el.dataset; + const { runnerId, runnersPath, editGroupRunnerPath } = el.dataset; const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), @@ -29,6 +26,7 @@ export const initAdminRunnerShow = (selector = '#js-group-runner-show') => { props: { runnerId, runnersPath, + editGroupRunnerPath, }, }); }, diff --git a/app/models/awareness_session.rb b/app/models/awareness_session.rb new file mode 100644 index 00000000000..faadb7e0ba9 --- /dev/null +++ b/app/models/awareness_session.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +# A Redis backed session store for real-time collaboration. A session is defined +# by its documents and the users that join this session. An online user can have +# two states within the session: "active" and "away". +# +# By design, session must eventually be cleaned up. If this doesn't happen +# explicitly, all keys used within the session model must have an expiry +# timestamp set. +class AwarenessSession # rubocop:disable Gitlab/NamespacedClass + # An awareness session expires automatically after 1 hour of no activity + SESSION_LIFETIME = 1.hour + private_constant :SESSION_LIFETIME + + # Expire user awareness keys after some time of inactivity + USER_LIFETIME = 1.hour + private_constant :USER_LIFETIME + + PRESENCE_LIFETIME = 10.minutes + private_constant :PRESENCE_LIFETIME + + KEY_NAMESPACE = "gitlab:awareness" + private_constant :KEY_NAMESPACE + + class << self + def for(value = nil) + # Creates a unique value for situations where we have no unique value to + # create a session with. This could be when creating a new issue, a new + # merge request, etc. + value = SecureRandom.uuid unless value.present? + + # We use SHA-256 based session identifiers (similar to abbreviated git + # hashes). There is always a chance for Hash collisions (birthday + # problem), we therefore have to pick a good tradeoff between the amount + # of data stored and the probability of a collision. + # + # The approximate probability for a collision can be calculated: + # + # p ~= n^2 / 2m + # ~= (2^18)^2 / (2 * 16^15) + # ~= 2^36 / 2^61 + # + # n is the number of awareness sessions and m the number of possibilities + # for each item. For a hex number, this is 16^c, where c is the number of + # characters. With 260k (~2^18) sessions, the probability for a collision + # is ~2^-25. + # + # The number of 15 is selected carefully. The integer representation fits + # nicely into a signed 64 bit integer and eventually allows Redis to + # optimize its memory usage. 16 chars would exceed the space for + # this datatype. + id = Digest::SHA256.hexdigest(value.to_s)[0, 15] + + AwarenessSession.new(id) + end + end + + def initialize(id) + @id = id + end + + def join(user) + user_key = user_sessions_key(user.id) + + with_redis do |redis| + redis.pipelined do |pipeline| + pipeline.sadd(user_key, id_i) + pipeline.expire(user_key, USER_LIFETIME.to_i) + + pipeline.zadd(users_key, timestamp.to_f, user.id) + + # We also mark for expiry when a session key is created (first user joins), + # because some users might never actively leave a session and the key could + # therefore become stale, w/o us noticing. + reset_session_expiry(pipeline) + end + end + + nil + end + + def leave(user) + user_key = user_sessions_key(user.id) + + with_redis do |redis| + redis.pipelined do |pipeline| + pipeline.srem(user_key, id_i) + pipeline.zrem(users_key, user.id) + end + + # cleanup orphan sessions and users + # + # this needs to be a second pipeline due to the delete operations being + # dependent on the result of the cardinality checks + user_sessions_count, session_users_count = redis.pipelined do |pipeline| + pipeline.scard(user_key) + pipeline.zcard(users_key) + end + + redis.pipelined do |pipeline| + pipeline.del(user_key) unless user_sessions_count > 0 + + unless session_users_count > 0 + pipeline.del(users_key) + @id = nil + end + end + end + + nil + end + + def present?(user, threshold: PRESENCE_LIFETIME) + with_redis do |redis| + user_timestamp = redis.zscore(users_key, user.id) + break false unless user_timestamp.present? + + timestamp - user_timestamp < threshold + end + end + + def away?(user, threshold: PRESENCE_LIFETIME) + !present?(user, threshold: threshold) + end + + # Updates the last_activity timestamp for a user in this session + def touch!(user) + with_redis do |redis| + redis.pipelined do |pipeline| + pipeline.zadd(users_key, timestamp.to_f, user.id) + + # extend the session lifetime due to user activity + reset_session_expiry(pipeline) + end + end + + nil + end + + def size + with_redis do |redis| + redis.zcard(users_key) + end + end + + def users + User.where(id: user_ids) + end + + def users_with_last_activity + user_ids, last_activities = user_ids_with_last_activity.transpose + users = User.where(id: user_ids) + users.zip(last_activities) + end + + private + + attr_reader :id + + # converts session id from hex to integer representation + def id_i + Integer(id, 16) if id.present? + end + + def users_key + "#{KEY_NAMESPACE}:session:#{id}:users" + end + + def user_sessions_key(user_id) + "#{KEY_NAMESPACE}:user:#{user_id}:sessions" + end + + def with_redis + Gitlab::Redis::SharedState.with do |redis| + yield redis if block_given? + end + end + + def timestamp + Time.now.to_i + end + + def user_ids + with_redis do |redis| + redis.zrange(users_key, 0, -1) + end + end + + # Returns an array of tuples, where the first element in the tuple represents + # the user ID and the second part the last_activity timestamp. + def user_ids_with_last_activity + pairs = with_redis do |redis| + redis.zrange(users_key, 0, -1, with_scores: true) + end + + # map data type of score (float) to Time + pairs.map do |user_id, score| + [user_id, Time.zone.at(score.to_i)] + end + end + + # We want sessions to cleanup automatically after a certain period of + # inactivity. This sets the expiry timestamp for this session to + # [SESSION_LIFETIME]. + def reset_session_expiry(redis) + redis.expire(users_key, SESSION_LIFETIME) + + nil + end +end diff --git a/app/models/concerns/awareness.rb b/app/models/concerns/awareness.rb new file mode 100644 index 00000000000..da87d87e838 --- /dev/null +++ b/app/models/concerns/awareness.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Awareness + extend ActiveSupport::Concern + + KEY_NAMESPACE = "gitlab:awareness" + private_constant :KEY_NAMESPACE + + def join(session) + session.join(self) + + nil + end + + def leave(session) + session.leave(self) + + nil + end + + def session_ids + with_redis do |redis| + redis + .smembers(user_sessions_key) + # converts session ids from (internal) integer to hex presentation + .map { |key| key.to_i.to_s(16) } + end + end + + private + + def user_sessions_key + "#{KEY_NAMESPACE}:user:#{id}:sessions" + end + + def with_redis + Gitlab::Redis::SharedState.with do |redis| + yield redis if block_given? + end + end +end diff --git a/app/models/concerns/integrations/slack_mattermost_notifier.rb b/app/models/concerns/integrations/slack_mattermost_notifier.rb index 3bdaa852ddf..142e62bb501 100644 --- a/app/models/concerns/integrations/slack_mattermost_notifier.rb +++ b/app/models/concerns/integrations/slack_mattermost_notifier.rb @@ -34,7 +34,7 @@ module Integrations class HTTPClient def self.post(uri, params = {}) params.delete(:http_options) # these are internal to the client and we do not want them - Gitlab::HTTP.post(uri, body: params, use_read_total_timeout: true) + Gitlab::HTTP.post(uri, body: params) end end end diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb index 4e30c1ccc69..230dc6bb336 100644 --- a/app/models/integrations/bamboo.rb +++ b/app/models/integrations/bamboo.rb @@ -155,7 +155,6 @@ module Integrations query_params[:os_authType] = 'basic' params[:basic_auth] = basic_auth - params[:use_read_total_timeout] = true params end diff --git a/app/models/integrations/base_issue_tracker.rb b/app/models/integrations/base_issue_tracker.rb index bffe87c21ee..fe4a2f43b13 100644 --- a/app/models/integrations/base_issue_tracker.rb +++ b/app/models/integrations/base_issue_tracker.rb @@ -94,7 +94,7 @@ module Integrations result = false begin - response = Gitlab::HTTP.head(self.project_url, verify: true, use_read_total_timeout: true) + response = Gitlab::HTTP.head(self.project_url, verify: true) if response message = "#{self.type} received response #{response.code} when attempting to connect to #{self.project_url}" diff --git a/app/models/integrations/drone_ci.rb b/app/models/integrations/drone_ci.rb index 35524503dea..b1f72b7144e 100644 --- a/app/models/integrations/drone_ci.rb +++ b/app/models/integrations/drone_ci.rb @@ -60,8 +60,7 @@ module Integrations response = Gitlab::HTTP.try_get( commit_status_path(sha, ref), verify: enable_ssl_verification, - extra_log_info: { project_id: project_id }, - use_read_total_timeout: true + extra_log_info: { project_id: project_id } ) status = diff --git a/app/models/integrations/external_wiki.rb b/app/models/integrations/external_wiki.rb index c16ae9926f1..bc2ea193a84 100644 --- a/app/models/integrations/external_wiki.rb +++ b/app/models/integrations/external_wiki.rb @@ -29,7 +29,7 @@ module Integrations end def execute(_data) - response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true, use_read_total_timeout: true) + response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true) response.body if response.code == 200 rescue StandardError nil diff --git a/app/models/integrations/mock_ci.rb b/app/models/integrations/mock_ci.rb index 0b3a9bc5405..2d8e26d409f 100644 --- a/app/models/integrations/mock_ci.rb +++ b/app/models/integrations/mock_ci.rb @@ -49,7 +49,7 @@ module Integrations # # => 'running' # def commit_status(sha, ref) - response = Gitlab::HTTP.get(commit_status_path(sha), verify: enable_ssl_verification, use_read_total_timeout: true) + response = Gitlab::HTTP.get(commit_status_path(sha), verify: enable_ssl_verification) read_commit_status(response) rescue Errno::ECONNREFUSED :error diff --git a/app/models/integrations/shimo.rb b/app/models/integrations/shimo.rb index dd25a0bc558..350ee61ad11 100644 --- a/app/models/integrations/shimo.rb +++ b/app/models/integrations/shimo.rb @@ -25,7 +25,7 @@ module Integrations # support for `test` method def execute(_data) - response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true, use_read_total_timeout: true) + response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true) response.body if response.code == 200 rescue StandardError nil diff --git a/app/models/integrations/teamcity.rb b/app/models/integrations/teamcity.rb index a23aa5f783d..e0299c9ac5f 100644 --- a/app/models/integrations/teamcity.rb +++ b/app/models/integrations/teamcity.rb @@ -156,7 +156,7 @@ module Integrations end def get_path(path) - Gitlab::HTTP.try_get(build_url(path), verify: enable_ssl_verification, basic_auth: basic_auth, extra_log_info: { project_id: project_id }, use_read_total_timeout: true) + Gitlab::HTTP.try_get(build_url(path), verify: enable_ssl_verification, basic_auth: basic_auth, extra_log_info: { project_id: project_id }) end def post_to_build_queue(data, branch) @@ -167,8 +167,7 @@ module Integrations '', headers: { 'Content-type' => 'application/xml' }, verify: enable_ssl_verification, - basic_auth: basic_auth, - use_read_total_timeout: true + basic_auth: basic_auth ) end diff --git a/app/models/integrations/unify_circuit.rb b/app/models/integrations/unify_circuit.rb index 646c2e75b03..f10a75fac5d 100644 --- a/app/models/integrations/unify_circuit.rb +++ b/app/models/integrations/unify_circuit.rb @@ -46,8 +46,7 @@ module Integrations response = Gitlab::HTTP.post(webhook, body: { subject: message.project_name, text: message.summary, - markdown: true, - use_read_total_timeout: true + markdown: true }.to_json) response if response.success? diff --git a/app/models/integrations/webex_teams.rb b/app/models/integrations/webex_teams.rb index 54d6f51ee17..75be457dcf5 100644 --- a/app/models/integrations/webex_teams.rb +++ b/app/models/integrations/webex_teams.rb @@ -44,7 +44,7 @@ module Integrations def notify(message, opts) header = { 'Content-Type' => 'application/json' } - response = Gitlab::HTTP.post(webhook, headers: header, body: { markdown: message.summary }.to_json, use_read_total_timeout: true) + response = Gitlab::HTTP.post(webhook, headers: header, body: { markdown: message.summary }.to_json) response if response.success? end diff --git a/app/models/user.rb b/app/models/user.rb index 2afd64358ef..cd4cc3537bb 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -9,6 +9,7 @@ class User < ApplicationRecord include Gitlab::SQL::Pattern include AfterCommitQueue include Avatarable + include Awareness include Referable include Sortable include CaseSensitivity diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index f2f94563e56..b68269fcf72 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -48,7 +48,6 @@ class WebHookService @force = force @request_options = { timeout: Gitlab.config.gitlab.webhook_timeout, - use_read_total_timeout: true, allow_local_requests: hook.allow_local_requests? } end diff --git a/app/views/groups/runners/show.html.haml b/app/views/groups/runners/show.html.haml index 5a9d2ca858e..828e8278127 100644 --- a/app/views/groups/runners/show.html.haml +++ b/app/views/groups/runners/show.html.haml @@ -1,6 +1,10 @@ - add_to_breadcrumbs _('Runners'), group_runners_path(@group) - if Feature.enabled?(:group_runner_view_ui) - #js-group-runner-show{ data: {runner_id: @runner.id, runners_path: group_runners_path(@group)} } + - title = "##{@runner.id} (#{@runner.short_sha})" + - breadcrumb_title title + - page_title title + + #js-group-runner-show{ data: {runner_id: @runner.id, runners_path: group_runners_path(@group), edit_group_runner_path: edit_group_runner_path(@group, @runner)} } - else = render 'shared/runners/runner_details', runner: @runner diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index ed1684b48a5..38d58c93774 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -1293,6 +1293,9 @@ production: &base prometheus: # enabled: true # server_address: 'localhost:9090' + snowplow_micro: + enabled: true + address: '127.0.0.1:9091' ## Consul settings consul: diff --git a/doc/administration/geo/replication/troubleshooting.md b/doc/administration/geo/replication/troubleshooting.md index b5b06f56f35..fa2a92428a6 100644 --- a/doc/administration/geo/replication/troubleshooting.md +++ b/doc/administration/geo/replication/troubleshooting.md @@ -519,7 +519,7 @@ To solve this: curl --request GET --header "PRIVATE-TOKEN: " "https://gitlab.example.com/api/v4/projects/" ``` -1. Enter the [Rails console](../../troubleshooting/navigating_gitlab_via_rails_console.md) and run: +1. Enter the [Rails console](../../operations/rails_console.md) and run: ```ruby failed_geo_syncs = Geo::ProjectRegistry.failed.pluck(:id) @@ -805,7 +805,7 @@ You can work around this by marking the objects as synced and succeeded verifica be aware that can also mark objects that may be [missing from the primary](#missing-files-on-the-geo-primary-site). -To do that, enter the [Rails console](../../troubleshooting/navigating_gitlab_via_rails_console.md) +To do that, enter the [Rails console](../../operations/rails_console.md) and run: ```ruby diff --git a/doc/administration/index.md b/doc/administration/index.md index 29fb65e2deb..6b2910a2d62 100644 --- a/doc/administration/index.md +++ b/doc/administration/index.md @@ -211,7 +211,7 @@ Learn how to install, configure, update, and maintain your GitLab instance. - [Log system](logs.md): Where to look for logs. - [Sidekiq Troubleshooting](troubleshooting/sidekiq.md): Debug when Sidekiq appears hung and is not processing jobs. - [Troubleshooting Elasticsearch](troubleshooting/elasticsearch.md) -- [Navigating GitLab via Rails console](troubleshooting/navigating_gitlab_via_rails_console.md) +- [Navigating GitLab via Rails console](operations/rails_console.md) - [GitLab application limits](instance_limits.md) - [Responding to security incidents](../security/responding_to_security_incidents.md) diff --git a/doc/administration/operations/rails_console.md b/doc/administration/operations/rails_console.md index df039ee734f..8778b2a8a36 100644 --- a/doc/administration/operations/rails_console.md +++ b/doc/administration/operations/rails_console.md @@ -6,8 +6,10 @@ info: To determine the technical writer assigned to the Stage/Group associated w # Rails console **(FREE SELF)** +At the heart of GitLab is a web application [built using the Ruby on Rails +framework](https://about.gitlab.com/blog/2018/10/29/why-we-use-rails-to-build-gitlab/). The [Rails console](https://guides.rubyonrails.org/command_line.html#rails-console). -provides a way to interact with your GitLab instance from the command line. +provides a way to interact with your GitLab instance from the command line, and also grants access to the amazing tools built right into Rails. WARNING: The Rails console interacts directly with GitLab. In many cases, @@ -17,7 +19,9 @@ with no consequences, you are strongly advised to do so in a test environment. The Rails console is for GitLab system administrators who are troubleshooting a problem or need to retrieve some data that can only be done through direct -access of the GitLab application. +access of the GitLab application. Basic knowledge of Ruby is needed (try [this +30-minute tutorial](https://try.ruby-lang.org/) for a quick introduction). +Rails experience is useful but not required. ## Starting a Rails console session @@ -168,3 +172,435 @@ sudo chown -R git:git /scripts sudo chmod 700 /scripts sudo gitlab-rails runner /scripts/helloworld.rb ``` + +## Active Record objects + +### Looking up database-persisted objects + +Under the hood, Rails uses [Active Record](https://guides.rubyonrails.org/active_record_basics.html), +an object-relational mapping system, to read, write, and map application objects +to the PostgreSQL database. These mappings are handled by Active Record models, +which are Ruby classes defined in a Rails app. For GitLab, the model classes +can be found at `/opt/gitlab/embedded/service/gitlab-rails/app/models`. + +Let's enable debug logging for Active Record so we can see the underlying +database queries made: + +```ruby +ActiveRecord::Base.logger = Logger.new($stdout) +``` + +Now, let's try retrieving a user from the database: + +```ruby +user = User.find(1) +``` + +Which would return: + +```ruby +D, [2020-03-05T16:46:25.571238 #910] DEBUG -- : User Load (1.8ms) SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1 +=> # +``` + +We can see that we've queried the `users` table in the database for a row whose +`id` column has the value `1`, and Active Record has translated that database +record into a Ruby object that we can interact with. Try some of the following: + +- `user.username` +- `user.created_at` +- `user.admin` + +By convention, column names are directly translated into Ruby object attributes, +so you should be able to do `user.` to view the attribute's value. + +Also by convention, Active Record class names (singular and in camel case) map +directly onto table names (plural and in snake case) and vice versa. For example, +the `users` table maps to the `User` class, while the `application_settings` +table maps to the `ApplicationSetting` class. + +You can find a list of tables and column names in the Rails database schema, +available at `/opt/gitlab/embedded/service/gitlab-rails/db/schema.rb`. + +You can also look up an object from the database by attribute name: + +```ruby +user = User.find_by(username: 'root') +``` + +Which would return: + +```ruby +D, [2020-03-05T17:03:24.696493 #910] DEBUG -- : User Load (2.1ms) SELECT "users".* FROM "users" WHERE "users"."username" = 'root' LIMIT 1 +=> # +``` + +Give the following a try: + +- `User.find_by(email: 'admin@example.com')` +- `User.where.not(admin: true)` +- `User.where('created_at < ?', 7.days.ago)` + +Did you notice that the last two commands returned an `ActiveRecord::Relation` +object that appeared to contain multiple `User` objects? + +Up to now, we've been using `.find` or `.find_by`, which are designed to return +only a single object (notice the `LIMIT 1` in the generated SQL query?). +`.where` is used when it is desirable to get a collection of objects. + +Let's get a collection of non-administrator users and see what we can do with it: + +```ruby +users = User.where.not(admin: true) +``` + +Which would return: + +```ruby +D, [2020-03-05T17:11:16.845387 #910] DEBUG -- : User Load (2.8ms) SELECT "users".* FROM "users" WHERE "users"."admin" != TRUE LIMIT 11 +=> #, #, #, #, #]> +``` + +Now, try the following: + +- `users.count` +- `users.order(created_at: :desc)` +- `users.where(username: 'support-bot')` + +In the last command, we see that we can chain `.where` statements to generate +more complex queries. Notice also that while the collection returned contains +only a single object, we cannot directly interact with it: + +```ruby +users.where(username: 'support-bot').username +``` + +Which would return: + +```ruby +Traceback (most recent call last): + 1: from (irb):37 +D, [2020-03-05T17:18:25.637607 #910] DEBUG -- : User Load (1.6ms) SELECT "users".* FROM "users" WHERE "users"."admin" != TRUE AND "users"."username" = 'support-bot' LIMIT 11 +NoMethodError (undefined method `username' for #]>) +Did you mean? by_username +``` + +Let's retrieve the single object from the collection by using the `.first` +method to get the first item in the collection: + +```ruby +users.where(username: 'support-bot').first.username +``` + +We now get the result we wanted: + +```ruby +D, [2020-03-05T17:18:30.406047 #910] DEBUG -- : User Load (2.6ms) SELECT "users".* FROM "users" WHERE "users"."admin" != TRUE AND "users"."username" = 'support-bot' ORDER BY "users"."id" ASC LIMIT 1 +=> "support-bot" +``` + +For more on different ways to retrieve data from the database using Active +Record, please see the [Active Record Query Interface documentation](https://guides.rubyonrails.org/active_record_querying.html). + +### Modifying Active Record objects + +In the previous section, we learned about retrieving database records using +Active Record. Now, let's learn how to write changes to the database. + +First, let's retrieve the `root` user: + +```ruby +user = User.find_by(username: 'root') +``` + +Next, let's try updating the user's password: + +```ruby +user.password = 'password' +user.save +``` + +Which would return: + +```ruby +Enqueued ActionMailer::MailDeliveryJob (Job ID: 05915c4e-c849-4e14-80bb-696d5ae22065) to Sidekiq(mailers) with arguments: "DeviseMailer", "password_change", "deliver_now", #> +=> true +``` + +Here, we see that the `.save` command returned `true`, indicating that the +password change was successfully saved to the database. + +We also see that the save operation triggered some other action -- in this case +a background job to deliver an email notification. This is an example of an +[Active Record callback](https://guides.rubyonrails.org/active_record_callbacks.html) +-- code which is designated to run in response to events in the Active Record +object life cycle. This is also why using the Rails console is preferred when +direct changes to data is necessary as changes made via direct database queries +do not trigger these callbacks. + +It's also possible to update attributes in a single line: + +```ruby +user.update(password: 'password') +``` + +Or update multiple attributes at once: + +```ruby +user.update(password: 'password', email: 'hunter2@example.com') +``` + +Now, let's try something different: + +```ruby +# Retrieve the object again so we get its latest state +user = User.find_by(username: 'root') +user.password = 'password' +user.password_confirmation = 'hunter2' +user.save +``` + +This returns `false`, indicating that the changes we made were not saved to the +database. You can probably guess why, but let's find out for sure: + +```ruby +user.save! +``` + +This should return: + +```ruby +Traceback (most recent call last): + 1: from (irb):64 +ActiveRecord::RecordInvalid (Validation failed: Password confirmation doesn't match Password) +``` + +Aha! We've tripped an [Active Record Validation](https://guides.rubyonrails.org/active_record_validations.html). +Validations are business logic put in place at the application-level to prevent +unwanted data from being saved to the database and in most cases come with +helpful messages letting you know how to fix the problem inputs. + +We can also add the bang (Ruby speak for `!`) to `.update`: + +```ruby +user.update!(password: 'password', password_confirmation: 'hunter2') +``` + +In Ruby, method names ending with `!` are commonly known as "bang methods". By +convention, the bang indicates that the method directly modifies the object it +is acting on, as opposed to returning the transformed result and leaving the +underlying object untouched. For Active Record methods that write to the +database, bang methods also serve an additional function: they raise an +explicit exception whenever an error occurs, instead of just returning `false`. + +We can also skip validations entirely: + +```ruby +# Retrieve the object again so we get its latest state +user = User.find_by(username: 'root') +user.password = 'password' +user.password_confirmation = 'hunter2' +user.save!(validate: false) +``` + +This is not recommended, as validations are usually put in place to ensure the +integrity and consistency of user-provided data. + +A validation error prevents the entire object from being saved to +the database. You can see a little of this in the section below. If you're getting +a mysterious red banner in the GitLab UI when submitting a form, this can often +be the fastest way to get to the root of the problem. + +### Interacting with Active Record objects + +At the end of the day, Active Record objects are just normal Ruby objects. As +such, we can define methods on them which perform arbitrary actions. + +For example, GitLab developers have added some methods which help with +two-factor authentication: + +```ruby +def disable_two_factor! + transaction do + update( + otp_required_for_login: false, + encrypted_otp_secret: nil, + encrypted_otp_secret_iv: nil, + encrypted_otp_secret_salt: nil, + otp_grace_period_started_at: nil, + otp_backup_codes: nil + ) + self.u2f_registrations.destroy_all # rubocop: disable DestroyAll + end +end + +def two_factor_enabled? + two_factor_otp_enabled? || two_factor_u2f_enabled? +end +``` + +(See: `/opt/gitlab/embedded/service/gitlab-rails/app/models/user.rb`) + +We can then use these methods on any user object: + +```ruby +user = User.find_by(username: 'root') +user.two_factor_enabled? +user.disable_two_factor! +``` + +Some methods are defined by gems, or Ruby software packages, which GitLab uses. +For example, the [StateMachines](https://github.com/state-machines/state_machines-activerecord) +gem which GitLab uses to manage user state: + +```ruby +state_machine :state, initial: :active do + event :block do + + ... + + event :activate do + + ... + +end +``` + +Give it a try: + +```ruby +user = User.find_by(username: 'root') +user.state +user.block +user.state +user.activate +user.state +``` + +Earlier, we mentioned that a validation error prevents the entire object +from being saved to the database. Let's see how this can have unexpected +interactions: + +```ruby +user.password = 'password' +user.password_confirmation = 'hunter2' +user.block +``` + +We get `false` returned! Let's find out what happened by adding a bang as we did +earlier: + +```ruby +user.block! +``` + +Which would return: + +```ruby +Traceback (most recent call last): + 1: from (irb):87 +StateMachines::InvalidTransition (Cannot transition state via :block from :active (Reason(s): Password confirmation doesn't match Password)) +``` + +We see that a validation error from what feels like a completely separate +attribute comes back to haunt us when we try to update the user in any way. + +In practical terms, we sometimes see this happen with GitLab administration settings -- +validations are sometimes added or changed in a GitLab update, resulting in +previously saved settings now failing validation. Because you can only update +a subset of settings at once through the UI, in this case the only way to get +back to a good state is direct manipulation via Rails console. + +### Commonly used Active Record models and how to look up objects + +**Get a user by primary email address or username:** + +```ruby +User.find_by(email: 'admin@example.com') +User.find_by(username: 'root') +``` + +**Get a user by primary OR secondary email address:** + +```ruby +User.find_by_any_email('user@example.com') +``` + +The `find_by_any_email` method is a custom method added by GitLab developers rather +than a Rails-provided default method. + +**Get a collection of administrator users:** + +```ruby +User.admins +``` + +`admins` is a [scope convenience method](https://guides.rubyonrails.org/active_record_querying.html#scopes) +which does `where(admin: true)` under the hood. + +**Get a project by its path:** + +```ruby +Project.find_by_full_path('group/subgroup/project') +``` + +`find_by_full_path` is a custom method added by GitLab developers rather +than a Rails-provided default method. + +**Get a project's issue or merge request by its numeric ID:** + +```ruby +project = Project.find_by_full_path('group/subgroup/project') +project.issues.find_by(iid: 42) +project.merge_requests.find_by(iid: 42) +``` + +`iid` means "internal ID" and is how we keep issue and merge request IDs +scoped to each GitLab project. + +**Get a group by its path:** + +```ruby +Group.find_by_full_path('group/subgroup') +``` + +**Get a group's related groups:** + +```ruby +group = Group.find_by_full_path('group/subgroup') + +# Get a group's parent group +group.parent + +# Get a group's child groups +group.children +``` + +**Get a group's projects:** + +```ruby +group = Group.find_by_full_path('group/subgroup') + +# Get group's immediate child projects +group.projects + +# Get group's child projects, including those in subgroups +group.all_projects +``` + +**Get CI pipeline or builds:** + +```ruby +Ci::Pipeline.find(4151) +Ci::Build.find(66124) +``` + +The pipeline and job ID numbers increment globally across your GitLab +instance, so there's no requirement to use an internal ID attribute to look them up, +unlike with issues or merge requests. + +**Get the current application settings object:** + +```ruby +ApplicationSetting.current +``` diff --git a/doc/administration/packages/container_registry.md b/doc/administration/packages/container_registry.md index d6cadbab6a6..a6c97bbe3f5 100644 --- a/doc/administration/packages/container_registry.md +++ b/doc/administration/packages/container_registry.md @@ -887,7 +887,7 @@ administrators can clean up image tags and [run garbage collection](#container-registry-garbage-collection). To remove image tags by running the cleanup policy, run the following commands in the -[GitLab Rails console](../troubleshooting/navigating_gitlab_via_rails_console.md): +[GitLab Rails console](../operations/rails_console.md): ```ruby # Numeric ID of the project whose container registry should be cleaned up @@ -1738,7 +1738,7 @@ In this case, follow these steps: 1. Try the removal again. If you still can't remove the repository using the common methods, you can use the -[GitLab Rails console](../troubleshooting/navigating_gitlab_via_rails_console.md) +[GitLab Rails console](../operations/rails_console.md) to remove the project by force: ```ruby diff --git a/doc/administration/troubleshooting/debug.md b/doc/administration/troubleshooting/debug.md index 81ca1bda5d0..b88a96a560d 100644 --- a/doc/administration/troubleshooting/debug.md +++ b/doc/administration/troubleshooting/debug.md @@ -18,7 +18,6 @@ Your type of GitLab installation determines how See also: - [GitLab Rails Console Cheat Sheet](gitlab_rails_cheat_sheet.md). -- [Navigating GitLab via Rails console](navigating_gitlab_via_rails_console.md). ### Enabling Active Record logging diff --git a/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md b/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md index aec81514a0e..8d86d987f12 100644 --- a/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md +++ b/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md @@ -10,7 +10,7 @@ This is the GitLab Support Team's collection of information regarding the GitLab console, for use while troubleshooting. It is listed here for transparency, and it may be useful for users with experience with these tools. If you are currently having an issue with GitLab, it is highly recommended that you first check -our guide on [navigating our Rails console](navigating_gitlab_via_rails_console.md), +our guide on [our Rails console](../operations/rails_console.md), and your [support options](https://about.gitlab.com/support/), before attempting to use this information. @@ -517,7 +517,7 @@ If this all runs successfully, you see an output like the following before being The exported project is located within a `.tar.gz` file in `/var/opt/gitlab/gitlab-rails/uploads/-/system/import_export_upload/export_file/`. -If this fails, [enable verbose logging](navigating_gitlab_via_rails_console.md#looking-up-database-persisted-objects), +If this fails, [enable verbose logging](../operations/rails_console.md#looking-up-database-persisted-objects), repeat the above procedure after, and report the output to [GitLab Support](https://about.gitlab.com/support/). @@ -1114,7 +1114,7 @@ License.select(&TYPE).each(&:destroy!) As a GitLab administrator, you may need to reduce disk space consumption. A common culprit is Docker Registry images that are no longer in use. To find the storage broken down by each project, run the following in the -[GitLab Rails console](../troubleshooting/navigating_gitlab_via_rails_console.md): +[GitLab Rails console](../operations/rails_console.md): ```ruby projects_and_size = [["project_id", "creator_id", "registry_size_bytes", "project path"]] diff --git a/doc/administration/troubleshooting/index.md b/doc/administration/troubleshooting/index.md index 7d40a9e9683..12f86cfa78c 100644 --- a/doc/administration/troubleshooting/index.md +++ b/doc/administration/troubleshooting/index.md @@ -20,7 +20,6 @@ installation. - [Kubernetes cheat sheet](kubernetes_cheat_sheet.md) - [Linux cheat sheet](linux_cheat_sheet.md) - [Parsing GitLab logs with `jq`](log_parsing.md) -- [Navigating GitLab via Rails console](navigating_gitlab_via_rails_console.md) - [Diagnostics tools](diagnostics_tools.md) - [Debugging tips](debug.md) - [Tracing requests with correlation ID](tracing_correlation_id.md) diff --git a/doc/administration/troubleshooting/navigating_gitlab_via_rails_console.md b/doc/administration/troubleshooting/navigating_gitlab_via_rails_console.md index 51ef3d95a4e..09a5cb8d185 100644 --- a/doc/administration/troubleshooting/navigating_gitlab_via_rails_console.md +++ b/doc/administration/troubleshooting/navigating_gitlab_via_rails_console.md @@ -1,465 +1,11 @@ --- -stage: Systems -group: Distribution -info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments +redirect_to: '../operations/rails_console.md' +remove_date: '2022-10-05' --- -# Navigating GitLab via Rails console **(FREE SELF)** +This document was moved to [another location](../operations/rails_console.md). -At the heart of GitLab is a web application [built using the Ruby on Rails -framework](https://about.gitlab.com/blog/2018/10/29/why-we-use-rails-to-build-gitlab/). -Thanks to this, we also get access to the amazing tools built right into Rails. -This guide introduces the [Rails console](../operations/rails_console.md#starting-a-rails-console-session) -and the basics of interacting with your GitLab instance from the command line. - -WARNING: -The Rails console interacts directly with your GitLab instance. In many cases, -there are no handrails to prevent you from permanently modifying, corrupting -or destroying production data. If you would like to explore the Rails console -with no consequences, you are strongly advised to do so in a test environment. - -This guide is targeted at GitLab system administrators who are troubleshooting -a problem or must retrieve some data that can only be done through direct -access of the GitLab application. Basic knowledge of Ruby is needed (try [this -30-minute tutorial](https://try.ruby-lang.org/) for a quick introduction). -Rails experience is helpful to have but not a must. - -## Starting a Rails console session - -Your type of GitLab installation determines how -[to start a rails console](../operations/rails_console.md). - -The following code examples take place inside the Rails console and also -assume an Omnibus GitLab installation. - -## Active Record objects - -### Looking up database-persisted objects - -Under the hood, Rails uses [Active Record](https://guides.rubyonrails.org/active_record_basics.html), -an object-relational mapping system, to read, write, and map application objects -to the PostgreSQL database. These mappings are handled by Active Record models, -which are Ruby classes defined in a Rails app. For GitLab, the model classes -can be found at `/opt/gitlab/embedded/service/gitlab-rails/app/models`. - -Let's enable debug logging for Active Record so we can see the underlying -database queries made: - -```ruby -ActiveRecord::Base.logger = Logger.new($stdout) -``` - -Now, let's try retrieving a user from the database: - -```ruby -user = User.find(1) -``` - -Which would return: - -```ruby -D, [2020-03-05T16:46:25.571238 #910] DEBUG -- : User Load (1.8ms) SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1 -=> # -``` - -We can see that we've queried the `users` table in the database for a row whose -`id` column has the value `1`, and Active Record has translated that database -record into a Ruby object that we can interact with. Try some of the following: - -- `user.username` -- `user.created_at` -- `user.admin` - -By convention, column names are directly translated into Ruby object attributes, -so you should be able to do `user.` to view the attribute's value. - -Also by convention, Active Record class names (singular and in camel case) map -directly onto table names (plural and in snake case) and vice versa. For example, -the `users` table maps to the `User` class, while the `application_settings` -table maps to the `ApplicationSetting` class. - -You can find a list of tables and column names in the Rails database schema, -available at `/opt/gitlab/embedded/service/gitlab-rails/db/schema.rb`. - -You can also look up an object from the database by attribute name: - -```ruby -user = User.find_by(username: 'root') -``` - -Which would return: - -```ruby -D, [2020-03-05T17:03:24.696493 #910] DEBUG -- : User Load (2.1ms) SELECT "users".* FROM "users" WHERE "users"."username" = 'root' LIMIT 1 -=> # -``` - -Give the following a try: - -- `User.find_by(email: 'admin@example.com')` -- `User.where.not(admin: true)` -- `User.where('created_at < ?', 7.days.ago)` - -Did you notice that the last two commands returned an `ActiveRecord::Relation` -object that appeared to contain multiple `User` objects? - -Up to now, we've been using `.find` or `.find_by`, which are designed to return -only a single object (notice the `LIMIT 1` in the generated SQL query?). -`.where` is used when it is desirable to get a collection of objects. - -Let's get a collection of non-administrator users and see what we can do with it: - -```ruby -users = User.where.not(admin: true) -``` - -Which would return: - -```ruby -D, [2020-03-05T17:11:16.845387 #910] DEBUG -- : User Load (2.8ms) SELECT "users".* FROM "users" WHERE "users"."admin" != TRUE LIMIT 11 -=> #, #, #, #, #]> -``` - -Now, try the following: - -- `users.count` -- `users.order(created_at: :desc)` -- `users.where(username: 'support-bot')` - -In the last command, we see that we can chain `.where` statements to generate -more complex queries. Notice also that while the collection returned contains -only a single object, we cannot directly interact with it: - -```ruby -users.where(username: 'support-bot').username -``` - -Which would return: - -```ruby -Traceback (most recent call last): - 1: from (irb):37 -D, [2020-03-05T17:18:25.637607 #910] DEBUG -- : User Load (1.6ms) SELECT "users".* FROM "users" WHERE "users"."admin" != TRUE AND "users"."username" = 'support-bot' LIMIT 11 -NoMethodError (undefined method `username' for #]>) -Did you mean? by_username -``` - -Let's retrieve the single object from the collection by using the `.first` -method to get the first item in the collection: - -```ruby -users.where(username: 'support-bot').first.username -``` - -We now get the result we wanted: - -```ruby -D, [2020-03-05T17:18:30.406047 #910] DEBUG -- : User Load (2.6ms) SELECT "users".* FROM "users" WHERE "users"."admin" != TRUE AND "users"."username" = 'support-bot' ORDER BY "users"."id" ASC LIMIT 1 -=> "support-bot" -``` - -For more on different ways to retrieve data from the database using Active -Record, please see the [Active Record Query Interface documentation](https://guides.rubyonrails.org/active_record_querying.html). - -### Modifying Active Record objects - -In the previous section, we learned about retrieving database records using -Active Record. Now, let's learn how to write changes to the database. - -First, let's retrieve the `root` user: - -```ruby -user = User.find_by(username: 'root') -``` - -Next, let's try updating the user's password: - -```ruby -user.password = 'password' -user.save -``` - -Which would return: - -```ruby -Enqueued ActionMailer::MailDeliveryJob (Job ID: 05915c4e-c849-4e14-80bb-696d5ae22065) to Sidekiq(mailers) with arguments: "DeviseMailer", "password_change", "deliver_now", #> -=> true -``` - -Here, we see that the `.save` command returned `true`, indicating that the -password change was successfully saved to the database. - -We also see that the save operation triggered some other action -- in this case -a background job to deliver an email notification. This is an example of an -[Active Record callback](https://guides.rubyonrails.org/active_record_callbacks.html) --- code which is designated to run in response to events in the Active Record -object life cycle. This is also why using the Rails console is preferred when -direct changes to data is necessary as changes made via direct database queries -do not trigger these callbacks. - -It's also possible to update attributes in a single line: - -```ruby -user.update(password: 'password') -``` - -Or update multiple attributes at once: - -```ruby -user.update(password: 'password', email: 'hunter2@example.com') -``` - -Now, let's try something different: - -```ruby -# Retrieve the object again so we get its latest state -user = User.find_by(username: 'root') -user.password = 'password' -user.password_confirmation = 'hunter2' -user.save -``` - -This returns `false`, indicating that the changes we made were not saved to the -database. You can probably guess why, but let's find out for sure: - -```ruby -user.save! -``` - -This should return: - -```ruby -Traceback (most recent call last): - 1: from (irb):64 -ActiveRecord::RecordInvalid (Validation failed: Password confirmation doesn't match Password) -``` - -Aha! We've tripped an [Active Record Validation](https://guides.rubyonrails.org/active_record_validations.html). -Validations are business logic put in place at the application-level to prevent -unwanted data from being saved to the database and in most cases come with -helpful messages letting you know how to fix the problem inputs. - -We can also add the bang (Ruby speak for `!`) to `.update`: - -```ruby -user.update!(password: 'password', password_confirmation: 'hunter2') -``` - -In Ruby, method names ending with `!` are commonly known as "bang methods". By -convention, the bang indicates that the method directly modifies the object it -is acting on, as opposed to returning the transformed result and leaving the -underlying object untouched. For Active Record methods that write to the -database, bang methods also serve an additional function: they raise an -explicit exception whenever an error occurs, instead of just returning `false`. - -We can also skip validations entirely: - -```ruby -# Retrieve the object again so we get its latest state -user = User.find_by(username: 'root') -user.password = 'password' -user.password_confirmation = 'hunter2' -user.save!(validate: false) -``` - -This is not recommended, as validations are usually put in place to ensure the -integrity and consistency of user-provided data. - -A validation error prevents the entire object from being saved to -the database. You can see a little of this in the section below. If you're getting -a mysterious red banner in the GitLab UI when submitting a form, this can often -be the fastest way to get to the root of the problem. - -### Interacting with Active Record objects - -At the end of the day, Active Record objects are just normal Ruby objects. As -such, we can define methods on them which perform arbitrary actions. - -For example, GitLab developers have added some methods which help with -two-factor authentication: - -```ruby -def disable_two_factor! - transaction do - update( - otp_required_for_login: false, - encrypted_otp_secret: nil, - encrypted_otp_secret_iv: nil, - encrypted_otp_secret_salt: nil, - otp_grace_period_started_at: nil, - otp_backup_codes: nil - ) - self.u2f_registrations.destroy_all # rubocop: disable DestroyAll - end -end - -def two_factor_enabled? - two_factor_otp_enabled? || two_factor_u2f_enabled? -end -``` - -(See: `/opt/gitlab/embedded/service/gitlab-rails/app/models/user.rb`) - -We can then use these methods on any user object: - -```ruby -user = User.find_by(username: 'root') -user.two_factor_enabled? -user.disable_two_factor! -``` - -Some methods are defined by gems, or Ruby software packages, which GitLab uses. -For example, the [StateMachines](https://github.com/state-machines/state_machines-activerecord) -gem which GitLab uses to manage user state: - -```ruby -state_machine :state, initial: :active do - event :block do - - ... - - event :activate do - - ... - -end -``` - -Give it a try: - -```ruby -user = User.find_by(username: 'root') -user.state -user.block -user.state -user.activate -user.state -``` - -Earlier, we mentioned that a validation error prevents the entire object -from being saved to the database. Let's see how this can have unexpected -interactions: - -```ruby -user.password = 'password' -user.password_confirmation = 'hunter2' -user.block -``` - -We get `false` returned! Let's find out what happened by adding a bang as we did -earlier: - -```ruby -user.block! -``` - -Which would return: - -```ruby -Traceback (most recent call last): - 1: from (irb):87 -StateMachines::InvalidTransition (Cannot transition state via :block from :active (Reason(s): Password confirmation doesn't match Password)) -``` - -We see that a validation error from what feels like a completely separate -attribute comes back to haunt us when we try to update the user in any way. - -In practical terms, we sometimes see this happen with GitLab administration settings -- -validations are sometimes added or changed in a GitLab update, resulting in -previously saved settings now failing validation. Because you can only update -a subset of settings at once through the UI, in this case the only way to get -back to a good state is direct manipulation via Rails console. - -### Commonly used Active Record models and how to look up objects - -**Get a user by primary email address or username:** - -```ruby -User.find_by(email: 'admin@example.com') -User.find_by(username: 'root') -``` - -**Get a user by primary OR secondary email address:** - -```ruby -User.find_by_any_email('user@example.com') -``` - -The `find_by_any_email` method is a custom method added by GitLab developers rather -than a Rails-provided default method. - -**Get a collection of administrator users:** - -```ruby -User.admins -``` - -`admins` is a [scope convenience method](https://guides.rubyonrails.org/active_record_querying.html#scopes) -which does `where(admin: true)` under the hood. - -**Get a project by its path:** - -```ruby -Project.find_by_full_path('group/subgroup/project') -``` - -`find_by_full_path` is a custom method added by GitLab developers rather -than a Rails-provided default method. - -**Get a project's issue or merge request by its numeric ID:** - -```ruby -project = Project.find_by_full_path('group/subgroup/project') -project.issues.find_by(iid: 42) -project.merge_requests.find_by(iid: 42) -``` - -`iid` means "internal ID" and is how we keep issue and merge request IDs -scoped to each GitLab project. - -**Get a group by its path:** - -```ruby -Group.find_by_full_path('group/subgroup') -``` - -**Get a group's related groups:** - -```ruby -group = Group.find_by_full_path('group/subgroup') - -# Get a group's parent group -group.parent - -# Get a group's child groups -group.children -``` - -**Get a group's projects:** - -```ruby -group = Group.find_by_full_path('group/subgroup') - -# Get group's immediate child projects -group.projects - -# Get group's child projects, including those in subgroups -group.all_projects -``` - -**Get CI pipeline or builds:** - -```ruby -Ci::Pipeline.find(4151) -Ci::Build.find(66124) -``` - -The pipeline and job ID numbers increment globally across your GitLab -instance, so there's no requirement to use an internal ID attribute to look them up, -unlike with issues or merge requests. - -**Get the current application settings object:** - -```ruby -ApplicationSetting.current -``` + + + + diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index a9edc301bc4..c6714459643 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -450,6 +450,7 @@ Parameters: | `assignee_id` | integer | no | Returns merge requests assigned to the given user `id`. `None` returns unassigned merge requests. `Any` returns merge requests with an assignee. | | `approver_ids` **(PREMIUM)** | integer array | no | Returns merge requests which have specified all the users with the given `id`s as individual approvers. `None` returns merge requests without approvers. `Any` returns merge requests with an approver. | | `approved_by_ids` **(PREMIUM)** | integer array | no | Returns merge requests which have been approved by all the users with the given `id`s (Max: 5). `None` returns merge requests with no approvals. `Any` returns merge requests with an approval. | +| `approved_by_usernames` **(PREMIUM)** | string array | no | Returns merge requests which have been approved by all the users with the given `username`s (Max: 5). `None` returns merge requests with no approvals. `Any` returns merge requests with an approval. | | `reviewer_id` | integer | no | Returns merge requests which have the user as a [reviewer](../user/project/merge_requests/getting_started.md#reviewer) with the given user `id`. `None` returns merge requests with no reviewers. `Any` returns merge requests with any reviewer. Mutually exclusive with `reviewer_username`. | | `reviewer_username` | string | no | Returns merge requests which have the user as a [reviewer](../user/project/merge_requests/getting_started.md#reviewer) with the given `username`. `None` returns merge requests with no reviewers. `Any` returns merge requests with any reviewer. Mutually exclusive with `reviewer_id`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/49341) in GitLab 13.8. | | `my_reaction_emoji` | string | no | Return merge requests reacted by the authenticated user by the given `emoji`. `None` returns issues not given a reaction. `Any` returns issues given at least one reaction. | diff --git a/doc/ci/testing/unit_test_reports.md b/doc/ci/testing/unit_test_reports.md index 8aa41cd6fc0..1328a8a0d0f 100644 --- a/doc/ci/testing/unit_test_reports.md +++ b/doc/ci/testing/unit_test_reports.md @@ -163,3 +163,14 @@ report format XML files contain an `attachment` tag, GitLab parses the attachmen A link to the test case attachment appears in the test case details in [the pipeline test report](#view-unit-test-reports-on-gitlab). + +## Troubleshooting + +### Test report appears empty + +A unit test report can appear to be empty when [viewed in a merge request](#view-unit-test-reports-on-gitlab) +if the artifact that contained the report [expires](../yaml/index.md#artifactsexpire_in). +If the artifact frequently expires too early, set a longer `expire_in` value for +the report artifact. + +Alternatively, you can run a new pipeline to generate a new report. diff --git a/doc/development/contributing/index.md b/doc/development/contributing/index.md index 182d00d52ab..12fd7c3dc12 100644 --- a/doc/development/contributing/index.md +++ b/doc/development/contributing/index.md @@ -240,4 +240,4 @@ For information on how to contribute documentation, see GitLab ## Getting an Enterprise Edition License If you need a license for contributing to an EE-feature, see -[relevant information](https://about.gitlab.com/handbook/marketing/community-relations/code-contributor-program/#for-contributors-to-the-gitlab-enterprise-edition-ee). +[relevant information](https://about.gitlab.com/handbook/marketing/community-relations/code-contributor-program/#contributing-to-the-gitlab-enterprise-edition-ee). diff --git a/doc/development/snowplow/implementation.md b/doc/development/snowplow/implementation.md index ff12b82bbdb..f8e37aee1e0 100644 --- a/doc/development/snowplow/implementation.md +++ b/doc/development/snowplow/implementation.md @@ -505,16 +505,22 @@ To install and run Snowplow Micro, complete these steps to modify the 1. Set the environment variable to tell the GDK to use Snowplow Micro in development. This overrides two `application_settings` options: - `snowplow_enabled` setting will instead return `true` from `Gitlab::Tracking.enabled?` - - `snowplow_collector_hostname` setting will instead always return `localhost:9090` (or whatever is set for `SNOWPLOW_MICRO_URI`) from `Gitlab::Tracking.collector_hostname`. + - `snowplow_collector_hostname` setting will instead always return `localhost:9090` (or whatever port is set for `snowplow_micro.port` GDK setting) from `Gitlab::Tracking.collector_hostname`. ```shell - export SNOWPLOW_MICRO_ENABLE=1 + gdk config set snowplow_micro.enabled true ``` - Optionally, you can set the URI for you Snowplow Micro instance as well (the default value is `http://localhost:9090`): + Optionally, you can set the port for you Snowplow Micro instance as well (the default value is `9090`): ```shell - export SNOWPLOW_MICRO_URI=https://127.0.0.1:8080 + gdk config set snowplow_micro.port 8080 + ``` + +1. Regenerate the project YAML config: + + ```shell + gdk reconfigure ``` 1. Restart GDK: diff --git a/lib/api/tags.rb b/lib/api/tags.rb index 0fa8c21f8d7..97a2aebf53b 100644 --- a/lib/api/tags.rb +++ b/lib/api/tags.rb @@ -6,7 +6,11 @@ module API TAG_ENDPOINT_REQUIREMENTS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS.merge(tag_name: API::NO_SLASH_URL_PART_REGEX) - before { authorize! :download_code, user_project } + before do + authorize! :download_code, user_project + + not_found! unless user_project.repo_exists? + end params do requires :id, type: String, desc: 'The ID of a project' diff --git a/lib/gitlab/http.rb b/lib/gitlab/http.rb index 7bb16e071b0..567c4dc899f 100644 --- a/lib/gitlab/http.rb +++ b/lib/gitlab/http.rb @@ -44,29 +44,19 @@ module Gitlab options end - options[:skip_read_total_timeout] = true if options[:skip_read_total_timeout].nil? && options[:stream_body] - - if options[:skip_read_total_timeout] + if options[:stream_body] return httparty_perform_request(http_method, path, options_with_timeouts, &block) end start_time = nil read_total_timeout = options.fetch(:timeout, DEFAULT_READ_TOTAL_TIMEOUT) - tracked_timeout_error = false httparty_perform_request(http_method, path, options_with_timeouts) do |fragment| start_time ||= Gitlab::Metrics::System.monotonic_time elapsed = Gitlab::Metrics::System.monotonic_time - start_time if elapsed > read_total_timeout - error = ReadTotalTimeout.new("Request timed out after #{elapsed} seconds") - - raise error if options[:use_read_total_timeout] - - unless tracked_timeout_error - Gitlab::ErrorTracking.track_exception(error) - tracked_timeout_error = true - end + raise ReadTotalTimeout, "Request timed out after #{elapsed} seconds" end block.call fragment if block diff --git a/lib/gitlab/tracking.rb b/lib/gitlab/tracking.rb index ae3f02c8c1c..04745bafe7c 100644 --- a/lib/gitlab/tracking.rb +++ b/lib/gitlab/tracking.rb @@ -39,7 +39,9 @@ module Gitlab end def snowplow_micro_enabled? - Rails.env.development? && Gitlab::Utils.to_boolean(ENV['SNOWPLOW_MICRO_ENABLE']) + Rails.env.development? && Gitlab.config.snowplow_micro.enabled + rescue Settingslogic::MissingSetting + Gitlab::Utils.to_boolean(ENV['SNOWPLOW_MICRO_ENABLE']) end private diff --git a/lib/gitlab/tracking/destinations/snowplow_micro.rb b/lib/gitlab/tracking/destinations/snowplow_micro.rb index 3553efba1e1..c7a95e88d0b 100644 --- a/lib/gitlab/tracking/destinations/snowplow_micro.rb +++ b/lib/gitlab/tracking/destinations/snowplow_micro.rb @@ -30,8 +30,9 @@ module Gitlab def uri strong_memoize(:snowplow_uri) do - uri = URI(ENV['SNOWPLOW_MICRO_URI'] || DEFAULT_URI) - uri = URI("http://#{ENV['SNOWPLOW_MICRO_URI']}") unless %w[http https].include?(uri.scheme) + base = base_uri + uri = URI(base) + uri = URI("http://#{base}") unless %w[http https].include?(uri.scheme) uri end end @@ -47,6 +48,14 @@ module Gitlab def protocol uri.scheme end + + def base_uri + url = Gitlab.config.snowplow_micro.address + scheme = Gitlab.config.gitlab.https ? 'https' : 'http' + "#{scheme}://#{url}" + rescue Settingslogic::MissingSetting + ENV['SNOWPLOW_MICRO_URI'] || DEFAULT_URI + end end end end diff --git a/package.json b/package.json index d433ac6361f..071ab9b8b73 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@gitlab/at.js": "1.5.7", "@gitlab/favicon-overlay": "2.0.0", "@gitlab/svgs": "2.25.0", - "@gitlab/ui": "42.12.0", + "@gitlab/ui": "42.13.0", "@gitlab/visual-review-tools": "1.7.3", "@rails/actioncable": "6.1.4-7", "@rails/ujs": "6.1.4-7", diff --git a/spec/features/groups/group_runners_spec.rb b/spec/features/groups/group_runners_spec.rb index a60b8a60da0..b6aaab207ce 100644 --- a/spec/features/groups/group_runners_spec.rb +++ b/spec/features/groups/group_runners_spec.rb @@ -182,5 +182,45 @@ RSpec.describe "Group Runners" do end end end + + context 'when group_runner_view_ui is enabled' do + before do + stub_feature_flags(group_runner_view_ui: true) + end + + it 'user views runner details' do + visit group_runner_path(group, runner) + + expect(page).to have_content "#{s_('Runners|Description')} runner-foo" + end + + it 'user edits the runner to be protected' do + visit edit_group_runner_path(group, runner) + + expect(page.find_field('runner[access_level]')).not_to be_checked + + check 'runner_access_level' + click_button _('Save changes') + + expect(page).to have_content "#{s_('Runners|Configuration')} #{s_('Runners|Protected')}" + end + + context 'when a runner has a tag' do + before do + runner.update!(tag_list: ['tag']) + end + + it 'user edits runner not to run untagged jobs' do + visit edit_group_runner_path(group, runner) + + page.find_field('runner[tag_list]').set('tag, tag2') + + uncheck 'runner_run_untagged' + click_button _('Save changes') + + expect(page).to have_content "#{s_('Runners|Tags')} tag tag2" + end + end + end end end diff --git a/spec/frontend/runner/group_runner_show/group_runner_show_app_spec.js b/spec/frontend/runner/group_runner_show/group_runner_show_app_spec.js new file mode 100644 index 00000000000..c13176ace15 --- /dev/null +++ b/spec/frontend/runner/group_runner_show/group_runner_show_app_spec.js @@ -0,0 +1,197 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert, VARIANT_SUCCESS } from '~/flash'; +import { redirectTo } from '~/lib/utils/url_utility'; + +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import RunnerHeader from '~/runner/components/runner_header.vue'; +import RunnerDetails from '~/runner/components/runner_details.vue'; +import RunnerPauseButton from '~/runner/components/runner_pause_button.vue'; +import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue'; +import RunnerEditButton from '~/runner/components/runner_edit_button.vue'; +import runnerQuery from '~/runner/graphql/show/runner.query.graphql'; +import GroupRunnerShowApp from '~/runner/group_runner_show/group_runner_show_app.vue'; +import { captureException } from '~/runner/sentry_utils'; +import { saveAlertToLocalStorage } from '~/runner/local_storage_alert/save_alert_to_local_storage'; + +import { runnerData } from '../mock_data'; + +jest.mock('~/runner/local_storage_alert/save_alert_to_local_storage'); +jest.mock('~/flash'); +jest.mock('~/runner/sentry_utils'); +jest.mock('~/lib/utils/url_utility'); + +const mockRunner = runnerData.data.runner; +const mockRunnerGraphqlId = mockRunner.id; +const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`; +const mockRunnersPath = '/groups/group1/-/runners'; +const mockEditGroupRunnerPath = `/groups/group1/-/runners/${mockRunnerId}/edit`; + +Vue.use(VueApollo); + +describe('GroupRunnerShowApp', () => { + let wrapper; + let mockRunnerQuery; + + const findRunnerHeader = () => wrapper.findComponent(RunnerHeader); + const findRunnerDetails = () => wrapper.findComponent(RunnerDetails); + const findRunnerDeleteButton = () => wrapper.findComponent(RunnerDeleteButton); + const findRunnerEditButton = () => wrapper.findComponent(RunnerEditButton); + const findRunnerPauseButton = () => wrapper.findComponent(RunnerPauseButton); + + const mockRunnerQueryResult = (runner = {}) => { + mockRunnerQuery = jest.fn().mockResolvedValue({ + data: { + runner: { ...mockRunner, ...runner }, + }, + }); + }; + + const createComponent = ({ props = {}, mountFn = shallowMountExtended, ...options } = {}) => { + wrapper = mountFn(GroupRunnerShowApp, { + apolloProvider: createMockApollo([[runnerQuery, mockRunnerQuery]]), + propsData: { + runnerId: mockRunnerId, + runnersPath: mockRunnersPath, + editGroupRunnerPath: mockEditGroupRunnerPath, + ...props, + }, + ...options, + }); + + return waitForPromises(); + }; + + afterEach(() => { + mockRunnerQuery.mockReset(); + wrapper.destroy(); + }); + + describe('When showing runner details', () => { + beforeEach(async () => { + mockRunnerQueryResult(); + + await createComponent({ mountFn: mountExtended }); + }); + + it('expect GraphQL ID to be requested', async () => { + expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunnerGraphqlId }); + }); + + it('displays the header', async () => { + expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`); + }); + + it('displays edit, pause, delete buttons', async () => { + expect(findRunnerEditButton().exists()).toBe(true); + expect(findRunnerPauseButton().exists()).toBe(true); + expect(findRunnerDeleteButton().exists()).toBe(true); + }); + + it('shows basic runner details', () => { + const expected = `Description Instance runner + Last contact Never contacted + Version 1.0.0 + IP Address 127.0.0.1 + Executor None + Architecture None + Platform darwin + Configuration Runs untagged jobs + Maximum job timeout None + Tags None`.replace(/\s+/g, ' '); + + expect(wrapper.text().replace(/\s+/g, ' ')).toContain(expected); + }); + + it('renders runner details component', () => { + expect(findRunnerDetails().props('runner')).toEqual(mockRunner); + }); + + describe('when runner cannot be updated', () => { + beforeEach(async () => { + mockRunnerQueryResult({ + userPermissions: { + ...mockRunner.userPermissions, + updateRunner: false, + }, + }); + + await createComponent({ + mountFn: mountExtended, + }); + }); + + it('does not display edit and pause buttons', () => { + expect(findRunnerEditButton().exists()).toBe(false); + expect(findRunnerPauseButton().exists()).toBe(false); + }); + + it('displays delete button', () => { + expect(findRunnerDeleteButton().exists()).toBe(true); + }); + }); + + describe('when runner cannot be deleted', () => { + beforeEach(async () => { + mockRunnerQueryResult({ + userPermissions: { + ...mockRunner.userPermissions, + deleteRunner: false, + }, + }); + + await createComponent({ + mountFn: mountExtended, + }); + }); + + it('does not display delete button', () => { + expect(findRunnerDeleteButton().exists()).toBe(false); + }); + + it('displays edit and pause buttons', () => { + expect(findRunnerEditButton().exists()).toBe(true); + expect(findRunnerPauseButton().exists()).toBe(true); + }); + }); + + describe('when runner is deleted', () => { + beforeEach(async () => { + await createComponent({ + mountFn: mountExtended, + }); + }); + + it('redirects to the runner list page', () => { + findRunnerDeleteButton().vm.$emit('deleted', { message: 'Runner deleted' }); + + expect(saveAlertToLocalStorage).toHaveBeenCalledWith({ + message: 'Runner deleted', + variant: VARIANT_SUCCESS, + }); + expect(redirectTo).toHaveBeenCalledWith(mockRunnersPath); + }); + }); + }); + + describe('When there is an error', () => { + beforeEach(async () => { + mockRunnerQuery = jest.fn().mockRejectedValueOnce(new Error('Error!')); + await createComponent(); + }); + + it('error is reported to sentry', () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error('Error!'), + component: 'GroupRunnerShowApp', + }); + }); + + it('error is shown to the user', () => { + expect(createAlert).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js index c3824ad9701..222cabc6a63 100644 --- a/spec/frontend/security_configuration/components/app_spec.js +++ b/spec/frontend/security_configuration/components/app_spec.js @@ -1,11 +1,10 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { GlTab, GlTabs, GlLink } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser'; import stubChildren from 'helpers/stub_children'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import SecurityConfigurationApp, { i18n } from '~/security_configuration/components/app.vue'; import AutoDevopsAlert from '~/security_configuration/components/auto_dev_ops_alert.vue'; import AutoDevopsEnabledAlert from '~/security_configuration/components/auto_dev_ops_enabled_alert.vue'; @@ -67,34 +66,32 @@ describe('App component', () => { const createComponent = ({ shouldShowCallout = true, ...propsData } = {}) => { userCalloutDismissSpy = jest.fn(); - wrapper = extendedWrapper( - mount(SecurityConfigurationApp, { - propsData: { - augmentedSecurityFeatures: securityFeaturesMock, - augmentedComplianceFeatures: complianceFeaturesMock, - securityTrainingEnabled: true, - ...propsData, - }, - provide: { - upgradePath, - autoDevopsHelpPagePath, - autoDevopsPath, - projectFullPath, - vulnerabilityTrainingDocsPath, - }, - stubs: { - ...stubChildren(SecurityConfigurationApp), - GlLink: false, - GlSprintf: false, - LocalStorageSync: false, - SectionLayout: false, - UserCalloutDismisser: makeMockUserCalloutDismisser({ - dismiss: userCalloutDismissSpy, - shouldShowCallout, - }), - }, - }), - ); + wrapper = mountExtended(SecurityConfigurationApp, { + propsData: { + augmentedSecurityFeatures: securityFeaturesMock, + augmentedComplianceFeatures: complianceFeaturesMock, + securityTrainingEnabled: true, + ...propsData, + }, + provide: { + upgradePath, + autoDevopsHelpPagePath, + autoDevopsPath, + projectFullPath, + vulnerabilityTrainingDocsPath, + }, + stubs: { + ...stubChildren(SecurityConfigurationApp), + GlLink: false, + GlSprintf: false, + LocalStorageSync: false, + SectionLayout: false, + UserCalloutDismisser: makeMockUserCalloutDismisser({ + dismiss: userCalloutDismissSpy, + shouldShowCallout, + }), + }, + }); }; const findMainHeading = () => wrapper.find('h1'); diff --git a/spec/lib/gitlab/content_security_policy/config_loader_spec.rb b/spec/lib/gitlab/content_security_policy/config_loader_spec.rb index 7ab60af31e5..d15c07ba137 100644 --- a/spec/lib/gitlab/content_security_policy/config_loader_spec.rb +++ b/spec/lib/gitlab/content_security_policy/config_loader_spec.rb @@ -146,7 +146,7 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do let(:snowplow_micro_url) { "http://#{snowplow_micro_hostname}/" } before do - stub_env('SNOWPLOW_MICRO_ENABLE', 1) + stub_config(snowplow_micro: { enabled: true }) allow(Gitlab::Tracking).to receive(:collector_hostname).and_return(snowplow_micro_hostname) end @@ -169,9 +169,9 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do expect(directives['connect_src']).to match(Regexp.new(snowplow_micro_url)) end - context 'when not enabled using ENV[SNOWPLOW_MICRO_ENABLE]' do + context 'when not enabled using config' do before do - stub_env('SNOWPLOW_MICRO_ENABLE', nil) + stub_config(snowplow_micro: { enabled: false }) end it 'does not add Snowplow Micro URL to connect-src' do diff --git a/spec/lib/gitlab/http_connection_adapter_spec.rb b/spec/lib/gitlab/http_connection_adapter_spec.rb index cde8376febd..a241a4b6490 100644 --- a/spec/lib/gitlab/http_connection_adapter_spec.rb +++ b/spec/lib/gitlab/http_connection_adapter_spec.rb @@ -15,18 +15,6 @@ RSpec.describe Gitlab::HTTPConnectionAdapter do stub_all_dns('https://example.org', ip_address: '93.184.216.34') end - context 'with use_read_total_timeout option' do - let(:options) { { use_read_total_timeout: true } } - - it 'sets up the connection using the Gitlab::NetHttpAdapter' do - expect(connection).to be_a(Gitlab::NetHttpAdapter) - expect(connection.address).to eq('93.184.216.34') - expect(connection.hostname_override).to eq('example.org') - expect(connection.addr_port).to eq('example.org') - expect(connection.port).to eq(443) - end - end - context 'when local requests are allowed' do let(:options) { { allow_local_requests: true } } diff --git a/spec/lib/gitlab/http_spec.rb b/spec/lib/gitlab/http_spec.rb index c2fb987d195..929fd37ee40 100644 --- a/spec/lib/gitlab/http_spec.rb +++ b/spec/lib/gitlab/http_spec.rb @@ -83,67 +83,25 @@ RSpec.describe Gitlab::HTTP do subject(:request_slow_responder) { described_class.post('http://example.org', **options) } - shared_examples 'tracks the timeout but does not raise an error' do - specify :aggregate_failures do - expect(Gitlab::ErrorTracking).to receive(:track_exception).with( - an_instance_of(Gitlab::HTTP::ReadTotalTimeout) - ).once - - expect { request_slow_responder }.not_to raise_error - end - - it 'still calls the block' do - expect { |b| described_class.post('http://example.org', **options, &b) }.to yield_successive_args('a', 'b') - end - end - - shared_examples 'does not track or raise timeout error' do - specify :aggregate_failures do - expect(Gitlab::ErrorTracking).not_to receive(:track_exception) - - expect { request_slow_responder }.not_to raise_error - end - end - - it_behaves_like 'tracks the timeout but does not raise an error' - - context 'and use_read_total_timeout option is truthy' do - let(:options) { { use_read_total_timeout: true } } - - it 'raises an error' do - expect { request_slow_responder }.to raise_error(Gitlab::HTTP::ReadTotalTimeout, /Request timed out after ?([0-9]*[.])?[0-9]+ seconds/) - end + it 'raises an error' do + expect { request_slow_responder }.to raise_error(Gitlab::HTTP::ReadTotalTimeout, /Request timed out after ?([0-9]*[.])?[0-9]+ seconds/) end context 'and timeout option is greater than DEFAULT_READ_TOTAL_TIMEOUT' do let(:options) { { timeout: 10.seconds } } - it_behaves_like 'does not track or raise timeout error' + it 'does not raise an error' do + expect { request_slow_responder }.not_to raise_error + end end context 'and stream_body option is truthy' do let(:options) { { stream_body: true } } - it_behaves_like 'does not track or raise timeout error' - - context 'but skip_read_total_timeout option is falsey' do - let(:options) { { stream_body: true, skip_read_total_timeout: false } } - - it_behaves_like 'tracks the timeout but does not raise an error' + it 'does not raise an error' do + expect { request_slow_responder }.not_to raise_error end end - - context 'and skip_read_total_timeout option is truthy' do - let(:options) { { skip_read_total_timeout: true } } - - it_behaves_like 'does not track or raise timeout error' - end - - context 'and skip_read_total_timeout option is falsely' do - let(:options) { { skip_read_total_timeout: false } } - - it_behaves_like 'tracks the timeout but does not raise an error' - end end it 'calls a block' do diff --git a/spec/lib/gitlab/tracking/destinations/snowplow_micro_spec.rb b/spec/lib/gitlab/tracking/destinations/snowplow_micro_spec.rb index 2b94eaa2db9..2554a15d97e 100644 --- a/spec/lib/gitlab/tracking/destinations/snowplow_micro_spec.rb +++ b/spec/lib/gitlab/tracking/destinations/snowplow_micro_spec.rb @@ -5,46 +5,83 @@ require 'spec_helper' RSpec.describe Gitlab::Tracking::Destinations::SnowplowMicro do include StubENV + let(:snowplow_micro_settings) do + { + enabled: true, + address: address + } + end + + let(:address) { "gdk.test:9091" } + before do - stub_application_setting(snowplow_enabled: true) - stub_env('SNOWPLOW_MICRO_ENABLE', '1') allow(Rails.env).to receive(:development?).and_return(true) end describe '#hostname' do - context 'when SNOWPLOW_MICRO_URI is set' do + context 'when snowplow_micro config is set' do + let(:address) { '127.0.0.1:9091' } + before do - stub_env('SNOWPLOW_MICRO_URI', 'http://gdk.test:9091') + stub_config(snowplow_micro: snowplow_micro_settings) end - it 'returns hostname URI part' do - expect(subject.hostname).to eq('gdk.test:9091') + it 'returns proper URI' do + expect(subject.hostname).to eq('127.0.0.1:9091') + expect(subject.uri.scheme).to eq('http') + end + + context 'when gitlab config has https scheme' do + before do + stub_config_setting(https: true) + end + + it 'returns proper URI' do + expect(subject.hostname).to eq('127.0.0.1:9091') + expect(subject.uri.scheme).to eq('https') + end end end - context 'when SNOWPLOW_MICRO_URI is without protocol' do + context 'when snowplow_micro config is not set' do before do - stub_env('SNOWPLOW_MICRO_URI', 'gdk.test:9091') + allow(Gitlab.config).to receive(:snowplow_micro).and_raise(Settingslogic::MissingSetting) end - it 'returns hostname URI part' do - expect(subject.hostname).to eq('gdk.test:9091') - end - end + context 'when SNOWPLOW_MICRO_URI has scheme and port' do + before do + stub_env('SNOWPLOW_MICRO_URI', 'http://gdk.test:9091') + end - context 'when SNOWPLOW_MICRO_URI is hostname only' do - before do - stub_env('SNOWPLOW_MICRO_URI', 'uriwithoutport') + it 'returns hostname URI part' do + expect(subject.hostname).to eq('gdk.test:9091') + end end - it 'returns hostname URI with default HTTP port' do - expect(subject.hostname).to eq('uriwithoutport:80') - end - end + context 'when SNOWPLOW_MICRO_URI is without protocol' do + before do + stub_env('SNOWPLOW_MICRO_URI', 'gdk.test:9091') + end - context 'when SNOWPLOW_MICRO_URI is not set' do - it 'returns localhost hostname' do - expect(subject.hostname).to eq('localhost:9090') + it 'returns hostname URI part' do + expect(subject.hostname).to eq('gdk.test:9091') + end + end + + context 'when SNOWPLOW_MICRO_URI is hostname only' do + before do + stub_env('SNOWPLOW_MICRO_URI', 'uriwithoutport') + end + + it 'returns hostname URI with default HTTP port' do + expect(subject.hostname).to eq('uriwithoutport:80') + end + end + + context 'when SNOWPLOW_MICRO_URI is not set' do + it 'returns localhost hostname' do + expect(subject.hostname).to eq('localhost:9090') + end end end end @@ -53,7 +90,7 @@ RSpec.describe Gitlab::Tracking::Destinations::SnowplowMicro do let_it_be(:group) { create :group } before do - stub_env('SNOWPLOW_MICRO_URI', 'http://gdk.test:9091') + stub_config(snowplow_micro: snowplow_micro_settings) end it 'includes protocol with the correct value' do diff --git a/spec/lib/gitlab/tracking_spec.rb b/spec/lib/gitlab/tracking_spec.rb index a99e3b0a7f2..dd62c832f6f 100644 --- a/spec/lib/gitlab/tracking_spec.rb +++ b/spec/lib/gitlab/tracking_spec.rb @@ -34,6 +34,26 @@ RSpec.describe Gitlab::Tracking do end end + shared_examples 'delegates to SnowplowMicro destination with proper options' do + it_behaves_like 'delegates to destination', Gitlab::Tracking::Destinations::SnowplowMicro + + it 'returns useful client options' do + expected_fields = { + namespace: 'gl', + hostname: 'localhost:9090', + cookieDomain: '.gitlab.com', + appId: '_abc123_', + protocol: 'http', + port: 9090, + forceSecureTracker: false, + formTracking: true, + linkClickTracking: true + } + + expect(subject.options(nil)).to match(expected_fields) + end + end + context 'when destination is Snowplow' do it_behaves_like 'delegates to destination', Gitlab::Tracking::Destinations::Snowplow @@ -53,26 +73,31 @@ RSpec.describe Gitlab::Tracking do context 'when destination is SnowplowMicro' do before do - stub_env('SNOWPLOW_MICRO_ENABLE', '1') allow(Rails.env).to receive(:development?).and_return(true) end - it_behaves_like 'delegates to destination', Gitlab::Tracking::Destinations::SnowplowMicro + context "enabled with yml config" do + let(:snowplow_micro_settings) do + { + enabled: true, + address: "localhost:9090" + } + end - it 'returns useful client options' do - expected_fields = { - namespace: 'gl', - hostname: 'localhost:9090', - cookieDomain: '.gitlab.com', - appId: '_abc123_', - protocol: 'http', - port: 9090, - forceSecureTracker: false, - formTracking: true, - linkClickTracking: true - } + before do + stub_config(snowplow_micro: snowplow_micro_settings) + end - expect(subject.options(nil)).to match(expected_fields) + it_behaves_like 'delegates to SnowplowMicro destination with proper options' + end + + context "enabled with env variable" do + before do + allow(Gitlab.config).to receive(:snowplow_micro).and_raise(Settingslogic::MissingSetting) + stub_env('SNOWPLOW_MICRO_ENABLE', '1') + end + + it_behaves_like 'delegates to SnowplowMicro destination with proper options' end end diff --git a/spec/models/awareness_session_spec.rb b/spec/models/awareness_session_spec.rb new file mode 100644 index 00000000000..f1369514610 --- /dev/null +++ b/spec/models/awareness_session_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe AwarenessSession do + subject { AwarenessSession.for(session_id) } + + let(:user) { create(:user) } + let(:session_id) { 1 } + + after do + redis_shared_state_cleanup! + end + + describe "when a user joins a session" do + let(:presence_ttl) { 15.minutes } + + it "changes number of session members" do + expect { subject.join(user) }.to change(subject, :size).by(1) + end + + it "returns user as member of session with last_activity timestamp" do + freeze_time do + subject.join(user) + + session_users = subject.users_with_last_activity + session_user, last_activity = session_users.first + + expect(session_user.id).to be(user.id) + expect(last_activity).to be_eql(Time.now.utc) + end + end + + it "reports user as present" do + freeze_time do + subject.join(user) + + expect(subject.present?(user, threshold: presence_ttl)).to be true + end + end + + it "reports user as away after a certain time on inactivity" do + subject.join(user) + + travel_to((presence_ttl + 1.minute).from_now) do + expect(subject.away?(user, threshold: presence_ttl)).to be true + end + end + + it "reports user as present still when there was some activity" do + subject.join(user) + + travel_to((presence_ttl - 1.minute).from_now) do + subject.touch!(user) + end + + travel_to((presence_ttl + 1.minute).from_now) do + expect(subject.present?(user, threshold: presence_ttl)).to be true + end + end + + it "creates user and session awareness keys in store" do + subject.join(user) + + Gitlab::Redis::SharedState.with do |redis| + keys = redis.scan_each(match: "gitlab:awareness:*").to_a + + expect(keys.size).to be(2) + end + end + + it "sets a timeout for user and session key" do + subject.join(user) + subject_id = Digest::SHA256.hexdigest(session_id.to_s)[0, 15] + + Gitlab::Redis::SharedState.with do |redis| + ttl_session = redis.ttl("gitlab:awareness:session:#{subject_id}:users") + ttl_user = redis.ttl("gitlab:awareness:user:#{user.id}:sessions") + + expect(ttl_session).to be > 0 + expect(ttl_user).to be > 0 + end + end + end + + describe "when a user leaves a session" do + it "changes number of session members" do + subject.join(user) + + expect { subject.leave(user) }.to change(subject, :size).by(-1) + end + + it "destroys the session when it was the last user" do + subject.join(user) + + expect { subject.leave(user) }.to change(subject, :id).to(nil) + end + end + + describe "when last user leaves a session" do + it "session and user keys are removed" do + subject.join(user) + + Gitlab::Redis::SharedState.with do |redis| + expect { subject.leave(user) } + .to change { redis.scan_each(match: "gitlab:awareness:*").to_a.size } + .to(0) + end + end + end +end diff --git a/spec/models/concerns/awareness_spec.rb b/spec/models/concerns/awareness_spec.rb new file mode 100644 index 00000000000..9119fe2c458 --- /dev/null +++ b/spec/models/concerns/awareness_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Awareness do + subject { create(:user) } + + let(:session) { AwarenessSession.for(1) } + + after do + redis_shared_state_cleanup! + end + + describe "when joining a session" do + it "increases the number of sessions" do + expect { subject.join(session) } + .to change { subject.session_ids.size } + .by(1) + end + end + + describe "when leaving session" do + it "decreases the number of sessions" do + subject.join(session) + + expect { subject.leave(session) } + .to change { subject.session_ids.size } + .by(-1) + end + end + + describe "when joining multiple sessions" do + let(:session2) { AwarenessSession.for(2) } + + it "increases number of active sessions for user" do + expect do + subject.join(session) + subject.join(session2) + end.to change { subject.session_ids.size } + .by(2) + end + end +end diff --git a/spec/requests/api/tags_spec.rb b/spec/requests/api/tags_spec.rb index 3558babf2f1..e81e9e0bf2f 100644 --- a/spec/requests/api/tags_spec.rb +++ b/spec/requests/api/tags_spec.rb @@ -90,6 +90,13 @@ RSpec.describe API::Tags do let(:request) { get api(route, current_user) } end end + + context 'when repository does not exist' do + it_behaves_like '404 response' do + let(:project) { create(:project, creator: user) } + let(:request) { get api(route, current_user) } + end + end end context 'when unauthenticated', 'and project is public' do diff --git a/workhorse/go.mod b/workhorse/go.mod index 4b0478ffa68..e47ac3b408b 100644 --- a/workhorse/go.mod +++ b/workhorse/go.mod @@ -20,7 +20,7 @@ require ( github.com/johannesboyne/gofakes3 v0.0.0-20220627085814-c3ac35da23b2 github.com/jpillora/backoff v1.0.0 github.com/mitchellh/copystructure v1.0.0 - github.com/prometheus/client_golang v1.12.1 + github.com/prometheus/client_golang v1.12.2 github.com/rafaeljusto/redigomock/v3 v3.1.1 github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a github.com/sirupsen/logrus v1.8.1 diff --git a/workhorse/go.sum b/workhorse/go.sum index c3367098416..595a08a239f 100644 --- a/workhorse/go.sum +++ b/workhorse/go.sum @@ -939,8 +939,9 @@ github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeD github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= github.com/prometheus/client_golang v1.10.0/go.mod h1:WJM3cc3yu7XKBKa/I8WeZm+V3eltZnBwfENSU7mdogU= github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_golang v1.12.1 h1:ZiaPsmm9uiBeaSMRznKsCDNtPCS0T3JVDGF+06gjBzk= github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= +github.com/prometheus/client_golang v1.12.2 h1:51L9cDoUHVrXx4zWYlcLQIZ+d+VXHgqnYKkIuq4g/34= +github.com/prometheus/client_golang v1.12.2/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= diff --git a/yarn.lock b/yarn.lock index b2736b14457..0f8a7d6c984 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1053,10 +1053,10 @@ resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-2.25.0.tgz#0fb831959c9f312ebb665d23ba8944f26faea164" integrity sha512-R2oS/VghjP1T4WSTEkbadrzencmBesortvHT8VZUgUB1uQTLg52b843rTw/atVWpW2ecFRrEbjM8/lDwUwx0Aw== -"@gitlab/ui@42.12.0": - version "42.12.0" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-42.12.0.tgz#0a8b24507bc8459dd2408c7c387fe0aa10da65c9" - integrity sha512-OowlK2U9Mcx2LdpBYAqDQ0WwYdBe7vJMSVZwWri+iaQWtJziGowyFJBMYxDebK5IoYXQAnY4rTxwNYZfdqgk1w== +"@gitlab/ui@42.13.0": + version "42.13.0" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-42.13.0.tgz#bde99885d97d06fc16fce5054b68d85799fe85e5" + integrity sha512-uYHYWQ5RlmmMFjLbLxrJnhTqEo/Hh5dLKNK7+WAyyCFke9ycn70WQ4quxY3MJckdMhNS5dYg/6DhrjqUQpFBPA== dependencies: "@popperjs/core" "^2.11.2" bootstrap-vue "2.20.1"