diff --git a/.rubocop_todo/layout/line_end_string_concatenation_indentation.yml b/.rubocop_todo/layout/line_end_string_concatenation_indentation.yml index d04c54c44cd..f59795126ad 100644 --- a/.rubocop_todo/layout/line_end_string_concatenation_indentation.yml +++ b/.rubocop_todo/layout/line_end_string_concatenation_indentation.yml @@ -490,8 +490,6 @@ Layout/LineEndStringConcatenationIndentation: - 'scripts/failed_tests.rb' - 'scripts/flaky_examples/prune-old-flaky-examples' - 'scripts/generate_rspec_pipeline.rb' - - 'scripts/internal_events/cli.rb' - - 'scripts/internal_events/cli/event_definer.rb' - 'scripts/lint-docs-redirects.rb' - 'scripts/qa/testcases-check' - 'scripts/trigger-build.rb' diff --git a/.rubocop_todo/sidekiq_load_balancing/worker_data_consistency.yml b/.rubocop_todo/sidekiq_load_balancing/worker_data_consistency.yml index 34022051be0..c0c621004c3 100644 --- a/.rubocop_todo/sidekiq_load_balancing/worker_data_consistency.yml +++ b/.rubocop_todo/sidekiq_load_balancing/worker_data_consistency.yml @@ -5,7 +5,6 @@ SidekiqLoadBalancing/WorkerDataConsistency: - 'app/workers/authorized_project_update/project_recalculate_worker.rb' - 'app/workers/authorized_project_update/user_refresh_from_replica_worker.rb' - 'app/workers/authorized_projects_worker.rb' - - 'app/workers/auto_devops/disable_worker.rb' - 'app/workers/bulk_import_worker.rb' - 'app/workers/bulk_imports/entity_worker.rb' - 'app/workers/bulk_imports/export_request_worker.rb' diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 085524d3ac0..13c98100026 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -324,8 +324,12 @@ module Ci after_transition any => ::Ci::Pipeline.completed_statuses do |pipeline| pipeline.run_after_commit do - pipeline.all_merge_requests.with_auto_merge_enabled.each do |merge_request| - AutoMergeProcessWorker.perform_async(merge_request.id) + if Feature.enabled?(:auto_merge_process_worker_pipeline, pipeline.project) + AutoMergeProcessWorker.perform_async({ 'pipeline_id' => self.id }) + else + pipeline.all_merge_requests.with_auto_merge_enabled.each do |merge_request| + AutoMergeProcessWorker.perform_async(merge_request.id) + end end if pipeline.auto_devops_source? diff --git a/app/services/auto_merge/base_service.rb b/app/services/auto_merge/base_service.rb index 47d805d5284..ed85d910b06 100644 --- a/app/services/auto_merge/base_service.rb +++ b/app/services/auto_merge/base_service.rb @@ -12,7 +12,7 @@ module AutoMerge end notify(merge_request) - AutoMergeProcessWorker.perform_async(merge_request.id) + AutoMergeProcessWorker.perform_async({ 'merge_request_id' => merge_request.id }) strategy.to_sym rescue StandardError => e diff --git a/app/services/deployments/update_environment_service.rb b/app/services/deployments/update_environment_service.rb index 90a31ae9370..489681683cf 100644 --- a/app/services/deployments/update_environment_service.rb +++ b/app/services/deployments/update_environment_service.rb @@ -31,6 +31,7 @@ module Deployments renew_external_url renew_auto_stop_in renew_deployment_tier + renew_cluster_agent environment.fire_state_event(action) if environment.save @@ -67,6 +68,12 @@ module Deployments ExpandVariables.expand(auto_stop_in, -> { variables.sort_and_expand_all }) end + def expanded_cluster_agent_path + return unless cluster_agent_path + + ExpandVariables.expand(cluster_agent_path, -> { variables.sort_and_expand_all }) + end + def environment_url environment_options[:url] end @@ -79,6 +86,10 @@ module Deployments deployable&.environment_auto_stop_in end + def cluster_agent_path + environment_options.dig(:kubernetes, :agent) + end + def renew_external_url if (url = expanded_environment_url) environment.external_url = url @@ -100,6 +111,25 @@ module Deployments environment.tier = tier end end + + def renew_cluster_agent + return unless cluster_agent_path && deployable.user + + requested_project_path, requested_agent_name = expanded_cluster_agent_path.split(':') + + matching_authorization = user_access_authorizations_for_project.find do |authorization| + requested_project_path == authorization.config_project.full_path && + requested_agent_name == authorization.agent.name + end + + return unless matching_authorization + + environment.cluster_agent = matching_authorization.agent + end + + def user_access_authorizations_for_project + Clusters::Agents::Authorizations::UserAccess::Finder.new(deployable.user, project: deployable.project).execute + end end end diff --git a/app/services/discussions/resolve_service.rb b/app/services/discussions/resolve_service.rb index 8f5260986c2..03a0ad902d1 100644 --- a/app/services/discussions/resolve_service.rb +++ b/app/services/discussions/resolve_service.rb @@ -90,7 +90,7 @@ module Discussions ) ) else - AutoMergeProcessWorker.perform_async(merge_request.id) + AutoMergeProcessWorker.perform_async({ 'merge_request_id' => merge_request.id }) end end diff --git a/app/workers/auto_devops/disable_worker.rb b/app/workers/auto_devops/disable_worker.rb index 03613db3f29..982aa3ac14f 100644 --- a/app/workers/auto_devops/disable_worker.rb +++ b/app/workers/auto_devops/disable_worker.rb @@ -4,7 +4,7 @@ module AutoDevops class DisableWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker - data_consistency :always + data_consistency :sticky sidekiq_options retry: 3 include AutoDevopsQueue diff --git a/config/feature_flags/gitlab_com_derisk/auto_merge_process_worker_pipeline.yml b/config/feature_flags/gitlab_com_derisk/auto_merge_process_worker_pipeline.yml new file mode 100644 index 00000000000..9573d2f11d6 --- /dev/null +++ b/config/feature_flags/gitlab_com_derisk/auto_merge_process_worker_pipeline.yml @@ -0,0 +1,9 @@ +--- +name: auto_merge_process_worker_pipeline +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/483008 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/167095 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/499877 +milestone: '17.6' +group: group::ci platform +type: gitlab_com_derisk +default_enabled: false diff --git a/db/docs/batched_background_migrations/backfill_boards_epic_board_labels_group_id.yml b/db/docs/batched_background_migrations/backfill_boards_epic_board_labels_group_id.yml index 1a584a12770..9ef39215ca2 100644 --- a/db/docs/batched_background_migrations/backfill_boards_epic_board_labels_group_id.yml +++ b/db/docs/batched_background_migrations/backfill_boards_epic_board_labels_group_id.yml @@ -5,4 +5,4 @@ feature_category: portfolio_management introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/153674 milestone: '17.1' queued_migration_version: 20240521090846 -finalized_by: # version of the migration that finalized this BBM +finalized_by: '20241023232309' diff --git a/db/post_migrate/20241023015533_remove_tmp_index_on_vulnerability_reads_state.rb b/db/post_migrate/20241023015533_remove_tmp_index_on_vulnerability_reads_state.rb new file mode 100644 index 00000000000..b75e12a1970 --- /dev/null +++ b/db/post_migrate/20241023015533_remove_tmp_index_on_vulnerability_reads_state.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class RemoveTmpIndexOnVulnerabilityReadsState < Gitlab::Database::Migration[2.2] + milestone '17.6' + disable_ddl_transaction! + + INDEX_NAME = 'tmp_index_vulnerability_reads_where_state_is_detected' + TABLE_NAME = :vulnerability_reads + + def up + remove_concurrent_index_by_name TABLE_NAME, INDEX_NAME + end + + def down + add_concurrent_index TABLE_NAME, :id, name: INDEX_NAME, where: "state = 1" + end +end diff --git a/db/post_migrate/20241023232309_finalize_backfill_boards_epic_board_labels_group_id.rb b/db/post_migrate/20241023232309_finalize_backfill_boards_epic_board_labels_group_id.rb new file mode 100644 index 00000000000..2db26d6b035 --- /dev/null +++ b/db/post_migrate/20241023232309_finalize_backfill_boards_epic_board_labels_group_id.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class FinalizeBackfillBoardsEpicBoardLabelsGroupId < Gitlab::Database::Migration[2.2] + milestone '17.6' + + disable_ddl_transaction! + + restrict_gitlab_migration gitlab_schema: :gitlab_main_cell + + def up + ensure_batched_background_migration_is_finished( + job_class_name: 'BackfillBoardsEpicBoardLabelsGroupId', + table_name: :boards_epic_board_labels, + column_name: :id, + job_arguments: [:group_id, :boards_epic_boards, :group_id, :epic_board_id], + finalize: true + ) + end + + def down; end +end diff --git a/db/schema_migrations/20241023015533 b/db/schema_migrations/20241023015533 new file mode 100644 index 00000000000..4828ffa828a --- /dev/null +++ b/db/schema_migrations/20241023015533 @@ -0,0 +1 @@ +a0bb64a39809e232b50a522effd27104a6514876f193170847c1e6124231256d \ No newline at end of file diff --git a/db/schema_migrations/20241023232309 b/db/schema_migrations/20241023232309 new file mode 100644 index 00000000000..21b919e750b --- /dev/null +++ b/db/schema_migrations/20241023232309 @@ -0,0 +1 @@ +5f2b18d3d4242b240dae17ecf0e9ed96d3e674239bf0c81b9ee50c4571faf5d7 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index e072c2ee46a..49d0f8dd707 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -32029,8 +32029,6 @@ CREATE INDEX tmp_index_project_statistics_cont_registry_size ON project_statisti CREATE INDEX tmp_index_vulnerability_overlong_title_html ON vulnerabilities USING btree (id) WHERE (length(title_html) > 800); -CREATE INDEX tmp_index_vulnerability_reads_where_state_is_detected ON vulnerability_reads USING btree (id) WHERE (state = 1); - CREATE UNIQUE INDEX u_compliance_requirements_for_framework ON compliance_requirements USING btree (framework_id, name); CREATE UNIQUE INDEX u_project_compliance_standards_adherence_for_reporting ON project_compliance_standards_adherence USING btree (project_id, check_name, standard); diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index f27c137b611..2e870b1148f 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -20893,6 +20893,7 @@ The currently authenticated GitLab user. | `createdAt` | [`Time`](#time) | Timestamp of when the user was created. | | `discord` | [`String`](#string) | Discord ID of the user. | | `duoChatAvailable` **{warning-solid}** | [`Boolean!`](#boolean) | **Introduced** in GitLab 16.8. **Status**: Experiment. User access to AI chat feature. | +| `duoChatAvailableFeatures` **{warning-solid}** | [`[String!]!`](#string) | **Introduced** in GitLab 17.6. **Status**: Experiment. List of available features for AI chat. | | `duoCodeSuggestionsAvailable` **{warning-solid}** | [`Boolean!`](#boolean) | **Introduced** in GitLab 16.8. **Status**: Experiment. User access to code suggestions feature. | | `email` **{warning-solid}** | [`String`](#string) | **Deprecated** in GitLab 13.7. This was renamed. Use: [`User.publicEmail`](#userpublicemail). | | `emails` | [`EmailConnection`](#emailconnection) | User's email addresses. (see [Connections](#connections)) | diff --git a/doc/ci/environments/kubernetes_dashboard.md b/doc/ci/environments/kubernetes_dashboard.md index 885a96831ad..f8f1d2cfc85 100644 --- a/doc/ci/environments/kubernetes_dashboard.md +++ b/doc/ci/environments/kubernetes_dashboard.md @@ -63,6 +63,29 @@ Prerequisites: ::EndTabs +### Configure a dashboard for a dynamic environment + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/467912) in GitLab 17.6. + +To configure a dashboard for a dynamic environment: + +- Specify the agent in your `.gitlab-ci.yml` file. You must specify the full path to the agent configuration project, +followed by a colon and the name of the agent. + +For example: + +```yaml +deploy_review_app: + stage: deploy + script: make deploy + environment: + name: review/$CI_COMMIT_REF_SLUG + kubernetes: + agent: path/to/agent/project:agent-name +``` + +For more information, see the [CI/CD YAML syntax reference](../yaml/index.md#environmentkubernetes). + ## View a dashboard > - Kubernetes watch API integration [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/422945) in GitLab 16.6 [with a flag](../../administration/feature_flags.md) named `k8s_watch_api`. Disabled by default. diff --git a/doc/ci/steps/index.md b/doc/ci/steps/index.md index 9eecadb5a8a..b17920652b2 100644 --- a/doc/ci/steps/index.md +++ b/doc/ci/steps/index.md @@ -11,276 +11,538 @@ DETAILS: **Offering:** GitLab.com, Self-managed, GitLab Dedicated **Status:** Experimental -Steps are reusable and composable pieces of a job. -Each step defines structured inputs and outputs that can be consumed by other steps. -Steps can come from local files, GitLab.com repositories, or any other Git source. +Steps are reusable units of a job that when composed together replace the `script` used in a GitLab CI/CD job. +While you are not required to use steps, the reusability, composability, testability, and independence +of steps make it easier to understand and maintain CI/CD pipeline. -Steps is an alternative to shell scripts for running jobs. -They provide more structure, can be composed, and can be tested and reused. -A `exec:command` is run by using an Exec system call, not by running a shell. +To get started, you can try the [Set up steps tutorial](../../tutorials/setup_steps/index.md). +To start creating your own steps, see [Creating your own step](#create-your-own-step). -To get started, see the [Set up steps tutorial](../../tutorials/setup_steps/index.md). +Follow the team's progress building steps at the epic, [CI Steps: Composability in GitLab CI/CD](https://gitlab.com/groups/gitlab-org/-/epics/11535). +To report a bug, create an issue in the [step-runner](https://gitlab.com/gitlab-org/step-runner/-/issues) project. -Support for a CI Catalog that publishes steps is proposed in [issue 425891](https://gitlab.com/gitlab-org/gitlab/-/issues/425891). +CI/CD steps are different than [CI/CD components](../components/index.md). Components +are reusable single pipeline configuration units. They are included in a pipeline when it is created, +adding jobs and configuration to the pipeline. Files such as common scripts or programs +from the component project cannot be referenced from a CI/CD job. -## Define steps +CI/CD Steps are reusable units of a job. When the job runs, the referenced step is downloaded to +the execution environment or image, bringing along any extra files included with the step. +Execution of the step replaces the `script` in the job. -Steps are defined in a `step.yml` file. -Each file has two documents, the spec and the definition. +## Step workflow -The spec provides inputs, outputs, types, descriptions, and defaults. +A step either runs a sequence of steps or executes a command. Each step specifies inputs received and outputs returned, has +access to CI/CD job variables, environment variables, and resources provided by the execution environment such as the file +system and networking. Steps are hosted locally on the file system, in GitLab.com repositories, or in any other Git source. + +Additionally, steps: + +- Run in a Docker container created by the Steps team, you can review the [`Dockerfile`](https://gitlab.com/gitlab-org/step-runner/-/blob/main/Dockerfile). + Follow [epic 15073](https://gitlab.com/groups/gitlab-org/-/epics/15073+) to track + when steps will run inside the environment defined by the CI/CD job. +- Are specific to Linux. Follow [epic 15074](https://gitlab.com/groups/gitlab-org/-/epics/15074) + to track when steps supports multiple operating systems. + +For example, this job uses the [`run`](../yaml/index.md#run) CI/CD keyword to run a step: + +```yaml +job: + variables: + CI_SAY_HI_TO: "Sally" + run: + - name: say_hi + step: gitlab.com/gitlab-org/ci-cd/runner-tools/echo-step@v1.0.0 + inputs: + message: "hello, ${{job.CI_SAY_HI_TO}}" +``` + +When this job runs, the message `hello, Sally` is printed to job log. +The definition of the echo step is: ```yaml -# Example spec spec: inputs: - name: + message: type: string - default: joe steppy --- -# (definition goes here) +exec: + command: + - bash + - -c + - echo '${{inputs.message}}' ``` -The definition provides the implementation of the step. -There are two kinds of step definitions: +## Use CI/CD Steps -- The `exec` type, which executes a command. +Configure a GitLab CI/CD job to use CI Steps with the `run` keyword. You cannot use `before_script`, +`after_script`, or `script` in a job when you are running CI/CD Steps. - ```yaml - # (spec goes here) - --- - # Example exec definition - exec: - command: [ docker, run, -it, ubuntu, uname, -a ] - ``` +The `run` keyword accepts a list of steps to run. Steps are run one at a time in the order they are defined in the list. +Each list item has a `name` and either `step`, `script`, or `action`. -- The `steps` type, which runs a sequence of other steps. +Name must consist only of alpha-numeric characters and underscores, and must not start with a number. - ```yaml - # (spec goes here) - --- - # Example steps definition - steps: - - name: greet_user - step: gitlab.com/gitlab-org/ci-cd/runner-tools/echo-step@v1 - inputs: - echo: hello ${{ inputs.name }} - - name: print_system_information - step: ./my-local-steps/uname - ``` +### Run a step -So that you can refactor step implementations, you can change steps from `exec` to `steps` type, and from `exec` to `steps` type without an affect on workflows (or calling steps). +Run a step by providing the [step location](#step-location) using the `step` keyword. -### Inputs +Inputs and environment variables can be passed to the step, and these can contain expressions that interpolate values. +Steps run in the directory defined by the `CI_BUILDS_DIR` [predefined variable](../variables/predefined_variables.md). -Inputs can be the following types: - -- `string` -- `number` -- `boolean` -- `array` -- `struct` - -The default input type is `string`. - -If an input doesn't define a default then it is required. -Defaults cannot use expressions (`${{ }}`) that are only permitted in the step definition. - -### Outputs - -Outputs can be the following types: - -- `string` -- `number` -- `boolean` -- `array` -- `struct` -- `raw_string` -- `step_result` - -Outputs are written to `${{ output_file }}` in the form `key=value` where `key` is the name of the output. -The `value` should be written as JSON unless the type is `raw_string`. -The value type written by the step must match the declared type. The default output type is `raw_string`. - -The special output type `step_result` is used when delegating step execution to another step. -For example, the `script` and `action-runner` steps. - -Outputs for `steps` type definitions use expressions to aggregate from sub-step outputs. -Because expressions are not permitted in the spec, the `outputs` keyword appear in the definition. -To preserve encapsulation and allow refactoring, callers cannot directly access outputs from sub-steps. +For example, the echo step loaded from the Git repository `gitlab.com/components/echo` +receives the environment variable `USER: Fred` and the input `message: hello Sally`: + +```yaml +job: + variables: + CI_SAY_HI_TO: "Sally" + run: + - name: say_hi + step: gitlab.com/components/echo@v1.0.0 + env: + USER: "Fred" + inputs: + message: "hello ${{job.CI_SAY_HI_TO}}" +``` + +### Run a script + +Run a script in a shell with the `script` keyword. Environment variables passed to scripts +using `env` are set in the shell. Script steps run in the directory defined by the `CI_BUILDS_DIR` +[predefined variable](../variables/predefined_variables.md). + +For example, the following script prints the GitLab user to the job log: + +```yaml +my-job: + run: + - name: say_hi + script: echo hello ${{job.GITLAB_USER_LOGIN}} +``` + +Script steps always use the `bash` shell. Follow [issue 109](https://gitlab.com/gitlab-org/step-runner/-/issues/109) +to track when shell fallback is supported. + +### Run a GitHub action + +Run GitHub actions with the `action` keyword. Inputs and environment variables are passed directly to the +action, and action outputs are returned as step outputs. Action steps run in the directory +defined by the `CI_PROJECT_DIR` [predefined variable](../variables/predefined_variables.md). + +Running actions requires the `dind` service. For more information, see +[Use Docker to build Docker images](../docker/using_docker_build.md). + +For example, the following step uses `action` to make `yq` available: + +```yaml +my-job: + run: + - name: say_hi_again + action: mikefarah/yq@master + inputs: + cmd: echo ["hi ${{job.GITLAB_USER_LOGIN}} again!"] | yq .[0] +``` + +#### Known issues + +Actions running in GitLab do not support uploading artifacts directly. +Artifacts must be written to the file system and cache instead, and selected with the +existing [`artifacts` keyword](../yaml/index.md#artifacts) and [`cache` keyword](../yaml/index.md#cache). + +### Step location + +Steps are loaded from a relative path on the file system, GitLab.com repositories, +or any other Git source. + +#### Load a step from the file system + +Load a step from the file system using a relative path that starts with a full-stop `.`. +The folder referenced by the path must contain a `step.yml` step definition file. +Path separators must always use forward-slashes `/`, regardless of operating system. + +For example: + +```yaml +- name: my-step + step: ./path/to/my-step +``` + +#### Load a step from a Git repository + +Load a step from a Git repository by supplying the URL and revision (commit, branch, or tag) of the repository. +You can also specify the relative directory of the step inside the repository. +If the URL is specified without a directory, then `step.yml` is loaded from the root folder of the repository. + +For example: + +- Specify the step with a branch: + + ```yaml + job: + run: + - name: my_echo_step + step: gitlab.com/gitlab-org/ci-cd/runner-tools/echo-step@main + ``` + +- Specify the step with a tag: + + ```yaml + job: + run: + - name: my_echo_step + step: gitlab.com/gitlab-org/ci-cd/runner-tools/echo-step@v1.0.0 + ``` + +- Specify the step with a Git rev and directory in a repository: + + ```yaml + job: + run: + - name: specifying_a_revision_and_directory_within_the_repository + step: + git: + url: gitlab.com/gitlab-org/ci-cd/runner-tools/echo-step + dir: reverse + rev: main + ``` + +Steps can't reference Git repositories using annotated tags. Follow [issue 123](https://gitlab.com/gitlab-org/step-runner/-/issues/123) +to track when annotated tags are supported. + +### Expressions + +Expressions are a mini-language enclosed in double curly-braces `${{ }}`. Expressions are evaluated +just prior to step execution in the job environment and can be used in: + +- Input values +- Environment variable values +- Step location URL +- The executable command +- The executable work directory +- Outputs in a sequence of steps +- The `script` step +- The `action` step + +Expressions can reference the following variables: + +| Variable | Example | Description | +|:----------------------------|:-----------------------------------|:------------| +| `env` | `${{env.HOME}}` | Access to environment variables set on the execution environment or in previous steps. | +| `export_file` | `echo name=FRED >${{export_file}}` | The path to the export file. Write to this file to export environment variables for use by subsequent running steps. | +| `inputs` | `${{inputs.message}}` | Access inputs to the step. | +| `job` | `${{job.GITLAB_USER_NAME}}` | Access GitLab CI/CD job variables, limited to those starting with `CI_`, `DOCKER_` or `GITLAB_`. | +| `output_file` | `echo name=Fred >${{output_file}}` | The path to the output file. Write to this file to set output variables from the step. | +| `step_dir` | `work_dir: ${{step_dir}}` | The folder to where the step has been downloaded. Use to refer to files in the step, or to set the work directory of an executable step. | +| `steps.[step-name].outputs` | `${{steps.my-step.outputs.name}}` | Access to outputs from previously executed steps. Choose the specific step using the step name. | +| `work_dir` | `${{work_dir}}` | The work directory of an executing step. | + +Expressions are different from template interpolation which uses double square-brackets (`$[[ ]]`) +and are evaluated during job generation. + +Expressions only have access to CI/CD job variables with names starting with `CI_`, `DOCKER_`, +or `GITLAB_`. Follow [epic 15073](https://gitlab.com/groups/gitlab-org/-/epics/15073+) +to track when steps can access all CI/CD job variables. + +### Using prior step outputs + +Step inputs can reference outputs from prior steps by referencing the step name and output variable name. + +For example, if the `gitlab.com/components/random-string` step defined an output variable called `random_value`: + +```yaml +job: + run: + - name: generate_rand + step: gitlab.com/components/random + - name: echo_random + step: gitlab.com/components/echo + inputs: + message: "The random value is: ${{steps.generate_rand.random_value}}" +``` + +### Environment variables + +Steps can [set](#set-environment-variables) environment variables, [export](#export-an-environment-variable) +environment variables, and environment variables can be passed in when using `step`, `script`, or `action`. + +Environment variable precedence, from highest to lowest precedence, are variables set: + +1. By using `env` keyword in the `step.yml`. +1. By using the `env` keyword passed to a step in a sequence of steps. +1. By using the `env` keyword for all steps in a sequence. +1. Where a previously run step has written to `${{export_file}}`. +1. By the Runner. +1. By the container. + +## Create your own step + +Create your own step by performing the following tasks: + +1. Create a GitLab project, a Git repository, or a directory on a file system that is accessible + when the CI/CD job runs. +1. Create a `step.yml` file and place it in the root folder of the project, repository, or directory. +1. Define the [specification](#the-step-specification) for the step in the `step.yml`. +1. Define the [definition](#the-step-definition) for the step in the `step.yml`. +1. Add any files that your step uses to the project, repository, or directory. + +After the step is created, you can [use the step in a job](#run-a-step). + +### The step specification + +The step specification is the first of two documents contained in the step `step.yml`. +The specification defines inputs and outputs that the step receives and returns. + +#### Specify inputs + +Input names can only use alpha-numeric characters and underscores, and must not start with a number. +Inputs must have a type, and they can optionally specify a default value. An input with no default value +is a required input, it must be specified when using the step. + +Inputs must be one of the following types. The default input type is `string`. + +| Type | Example | Description | +|:----------|:------------------------|:------------| +| `array` | `["a","b"]` | A list of un-typed items. | +| `boolean` | `true` | True or false. | +| `number` | `56.77` | 64 bit float. | +| `string` | `"brown cow"` | Text. | +| `struct` | `{"k1":"v1","k2":"v2"}` | Structured content. | + +For example, to specify that the step accepts an optional input called `greeting` of type `string`: + +```yaml +spec: + inputs: + greeting: + type: string + default: "hello, world" +--- +``` + +To provide the input when using the step: + +```yaml +run: + - name: my_step + step: ./my-step + inputs: + greeting: "hello, another world" +``` + +#### Specify outputs + +Similar to inputs, output names can only use alpha-numeric characters and underscores, +and must not start with a number. Outputs must have a type, and they can optionally specify a default value. +The default value is returned when the step doesn't return the output. + +Outputs must be one of the following types. The default input type is `raw_string`. + +| Type | Example | Description | +|:-------------|:------------------------|:------------| +| `array` | `["a","b"]` | A list of un-typed items. | +| `boolean` | `true` | True or false. | +| `number` | `56.77` | 64 bit float. | +| `raw_string` | `brown cow` | Text written without enclosing double-quotes. | +| `string` | `"brown cow"` | Text. | +| `struct` | `{"k1":"v1","k2":"v2"}` | Structured content. | + +For example, to specify that the step returns an output called `value` of type `number`: ```yaml -# Example output from multiple steps spec: outputs: - full_name: - type: string + value: + type: number --- -steps: - - name: first_name - step: ./fn - - name: last_name - step: ./ln -outputs: - full_name: "hello ${{ steps.first_name.outputs.name }} ${{ steps.last_name.outputs.name }}" ``` -## Using steps - -The keyword `step` points to a remote or local step. - -Remote step references are the URL of a Git repo, the character `@`, and the tag or branch (version). -Step runner looks for a file `step.yml` at the root of the repository. - -Local steps begin with `.` and point to a directory where step-runner looks for `step.yml`. -Local references always use the path separator `/` regardless of operating system. -The OS appropriate separate is used when loading the file. +To use the output when using the step: ```yaml -# Example job using steps -my-job: - run: - - name: greet_user - step: gitlab.com/gitlab-org/ci-cd/runner-tools/echo-step@v1 - inputs: - echo: hello $[[ GITLAB_USER_LOGIN ]] - - name: print_system_information - step: ./my-local-steps/uname -``` - -To use steps in a job, provide steps in a variable and invoke the step runner the job `script` keyword. Support to use steps in a job as a `run` keyword in a GitLab CI pipeline configuration is proposed in [epic 11525](https://gitlab.com/groups/gitlab-org/-/epics/11525). - -```yaml -# Example work-around until run keyword is implemented -my-job: - image: registry.gitlab.com/gitlab-org/step-runner:v0 - variables: - STEPS: | - - name: greet_user - step: gitlab.com/gitlab-org/ci-cd/runner-tools/echo-step@v1 - inputs: - echo: hello $GITLAB_USER_LOGIN - - name: print_system_information - step: ./my-local-steps/uname - script: - # Run the step-runner's ci command which ready from the STEPS environment variable - - /step-runner ci -``` - -### Set environment variables - -You do not need to declare environment variables for steps. -Any exports written to `${{ export_file }}` in the form `key=value` are added to the global execution environment. -Exported values are plain strings (no JSON). - -You can use the `env` keyword for steps to temporarily set environment variables during their execution: - -```yaml -# Example job using env -my-job: - run: - - name: greet_user - step: gitlab.com/gitlab-org/ci-cd/runner-tools/echo-step@v1 - env: - USER: $[[ GITLAB_USER_LOGIN ]] - inputs: - echo: hello ${{ env.USER }} -``` - -Step definitions can also temporarily set environment variables. - -```yaml -# (spec goes here) ---- -# Example step definition using env -env: - USER: ${{ inputs.user }} -steps: - - name: greet_user - step: gitlab.com/gitlab-org/ci-cd/runner-tools/echo-step@v1 +run: + - name: random_generator + step: ./random-generator + - name: echo_number + step: ./echo inputs: - echo: hello ${{ env.USER }} + message: "Random number generated was ${{step.random-generator.outputs.value}}" ``` -The order of precedence for environment variables is: +#### Specify delegated outputs -1. Step definition -1. Step reference (calling a step) -1. Global environment +Instead of specifying output names and types, outputs can be entirely delegated to a sub-step. +The outputs returned by the sub-step are returned by your step. The `delegate` keyword +in the step definition determines which sub-step outputs are returned by the step. -The `env` variables set in a step definition override variables that are set when the step is called, and so on. - -### Running steps locally - -To run steps locally, [download `step-runner`](https://gitlab.com/gitlab-org/step-runner) and run the `ci` command. -This is the same binary that is used to run steps in production. - -```shell -STEPS=$(yq '."my-job"'.run .gitlab-ci.yml) step-runner ci -``` - -You can debug with [`delve`](https://github.com/go-delve/delve). -Set a break point at [`Run` in `pkg/runner.go`](https://gitlab.com/gitlab-org/step-runner/-/blob/ac25318db27ed049dc3ce0fd7d9ce507d215b690/pkg/runner/runner.go#L57). - -```shell -STEPS=$(yq '."my-job"'.run .gitlab-ci.yml) dlv debug . ci -``` - -## Scripts - -While steps are usually used instead of shell scripts, sometimes a shell script is still needed. -The `script` keyword will automatically select the correct shell and runs a script. +For example, the following step returns outputs returned by the `random-generator`. ```yaml -# Example job using script -my-job: - run: - - name: greet_user - script: echo hello $[[ GITLAB_USER_LOGIN ]] +spec: + outputs: delegate +--- +steps: + - name: random_generator + step: ./random-generator +delegate: random-generator +``` + +#### Specify no inputs or outputs + +A step might not require any inputs or return any outputs. This could be when a step +only writes to disk, sets an environment variable, or prints to STDOUT. In this case, +`spec:` is empty: + +```yaml +spec: +--- +``` + +### The step definition + +Steps can: + +- Set environment variables +- Execute a command +- Run a sequence of other steps. + +#### Set environment variables + +Set environment variables by using the `env` keyword. Environment variable names can only use +alpha-numeric characters and underscores, and must not start with a number. + +Environment variables are made available either to the executable command or to all of the steps +if running a sequence of steps. For example: + +```yaml +spec: +--- +env: + FIRST_NAME: Sally + LAST_NAME: Seashells +steps: + # omitted for brevity +``` + +Steps only have access to a subset of environment variables from the runner environment. +Follow [epic 15073](https://gitlab.com/groups/gitlab-org/-/epics/15073+) to track +when steps can access all environment variables. + +#### Execute a command + +A step declares it executes a command by using the `exec` keyword. The command must be specified, +but the working directory (`work_dir`) is optional. Environment variables set by the step +are available to the running process. + +For example, the following step prints the step directory to the job log: + +```yaml +spec: +--- +exec: + work_dir: ${{step_dir}} + command: + - bash + - -c + - "echo ${PWD}" ``` NOTE: -Only the `bash` shell is supported. Support for conditional expressions is proposed in [epic 12168](https://gitlab.com/groups/gitlab-org/-/epics/12168). +Any dependency required by the executing step should also be installed by the step. +For example, if a step calls `go`, it should first install it. -## Actions +##### Return an output -You can run GitHub actions with the `action` keyword. -Inputs and outputs work the same way as steps. -Steps and actions can be used interchangably. +Executable steps return an output by adding a line to the `${{output_file}}` in the format `name=value`, +where the value is a JSON representation of the output type. The type of value written by the step +must match the type of the output in the step specification. + +For example, to return the output named `car` with `string` value `range rover`: ```yaml -# Example job using action -my-job: - run: - - name: greet_user - step: gitlab.com/gitlab-org/ci-cd/runner-tools/echo-step@v1 - inputs: - echo: hello $[[ GITLAB_USER_LOGIN ]] - - name: greet_user_again - action: mikefarah/yq@master - inputs: - cmd: echo ["${{ steps.greet_user.outputs.echo }} again!"] | yq .[0] +spec: + outputs: + car: + type: string +--- +exec: + command: + - bash + - -c + - echo car=\"range rover\" >>${{output_file}} ``` -### Known issues +##### Export an environment variable -Actions running in GitLab do not support uploading artifacts directly. -Artifacts must be written to the file system and cache instead, and selected with the existing [`artifacts` keyword](../yaml/index.md#artifacts). +Executable steps export an environment variable by adding a line to the `${{export_file}}` +in the format `name=value`. Double quotation marks are not required around the value. -Running actions requires the `dind` service. -For more information, see [Use Docker to build Docker images](../docker/using_docker_build.md). +For example, to set the variable `GOPATH` to value `/go`: -Actions in GitLab are experimental and may contain bugs. -To report a bug, create an issue in the [action-runner repo](https://gitlab.com/components/action-runner/-/issues). +```yaml +spec: +--- +exec: + command: + - bash + - -c + - echo GOPATH=/go >${{export_file}} +``` -## Expressions +#### Run a sequence of steps -Expressions is a mini-language enclosed in double curly-braces (`${{ }}`) -They can reference `inputs`, `env` (the environment shared by steps) and the outputs of previous steps (`steps..outputs`). +A step declares it runs a sequence of steps using the `steps` keyword. Steps run one at a time +in the order they are defined in the list. This syntax is the same as the `run` keyword. -Expressions can also reference `work_dir` which is the build directory. -And `step_dir` where the step definition and associated files are cached. -As well as `output_file` and `export_file` which is where outputs and exports are to be written. +Steps must have a name consisting only of alpha-numeric characters and underscores, and must not start with a number. -Expressions are different from template interpolation which uses double square-brackets (`$[[ ]]`) and is evaluated during job generation. -Expressions are evaluated just before step execution in the job environment. +For example, thisg step installs Go, then runs a second step that expects Go to already +have been installed: + +```yaml +spec: +--- +steps: + - name: install_go + step: ./go-steps/install-go + inputs: + version: "1.22" + - name: format_go_code + step: ./go-steps/go-fmt + inputs: + code: path/to/go-code +``` + +##### Return an output + +Outputs are returned from a sequence of steps by using the `outputs` keyword. +The type of value in the output must match the type of the output in the step specification. + +For example, the following step returns the installed Java version as an output. +This assumes the `install_java` step returns an output named `java_version`. + +```yaml +spec: + outputs: + java_version: + type: string +--- +steps: + - name: install_java + step: ./common/install-java +outputs: + java_version: "the java version is ${{steps.install_java.outputs.java_version}}" +``` + +Alternatively, all outputs of a sub-step can be returned using the `delegate` keyword. +For example: + +```yaml +spec: + outputs: delegate +--- +steps: + - name: install_java + step: ./common/install-java +delegate: install_java +``` diff --git a/doc/ci/yaml/index.md b/doc/ci/yaml/index.md index 3783945908c..f9200631872 100644 --- a/doc/ci/yaml/index.md +++ b/doc/ci/yaml/index.md @@ -2345,8 +2345,10 @@ Every time the review app is deployed, that lifetime is also reset to `1 day`. #### `environment:kubernetes` -Use the `kubernetes` keyword to configure deployments to a -[Kubernetes cluster](../../user/infrastructure/clusters/index.md) that is associated with your project. +> - `agent` keyword [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/467912) in GitLab 17.6. + +Use the `kubernetes` keyword to configure the [dashboard for Kubernetes](../environments/kubernetes_dashboard.md) +for an environment. **Keyword type**: Job keyword. You can use it only as part of a job. @@ -2359,21 +2361,19 @@ deploy: environment: name: production kubernetes: - namespace: production + agent: path/to/agent/project:agent-name ``` This configuration sets up the `deploy` job to deploy to the `production` -environment, using the `production` -[Kubernetes namespace](https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/). +environment, and associates the [agent](../../user/clusters/agent/index.md) +named `agent-name` with the environment. **Additional details**: -- Kubernetes configuration is not supported for Kubernetes clusters - [managed by GitLab](../../user/project/clusters/gitlab_managed_clusters.md). - -**Related topics**: - -- [Available settings for `kubernetes`](../environments/configure_kubernetes_deployments.md). +- To use the dashboard, you must + [install the GitLab agent for Kubernetes](../../user/clusters/agent/install/index.md) and + [configure `user_access`](../../user/clusters/agent/user_access.md) + for the environment's project or its parent group. #### `environment:deployment_tier` diff --git a/lib/gitlab/ci/config/entry/kubernetes.rb b/lib/gitlab/ci/config/entry/kubernetes.rb index 2f1595d4437..10cf4a16dc8 100644 --- a/lib/gitlab/ci/config/entry/kubernetes.rb +++ b/lib/gitlab/ci/config/entry/kubernetes.rb @@ -8,15 +8,16 @@ module Gitlab include ::Gitlab::Config::Entry::Validatable include ::Gitlab::Config::Entry::Attributable - ALLOWED_KEYS = %i[namespace].freeze + ALLOWED_KEYS = %i[namespace agent].freeze attributes ALLOWED_KEYS validations do - validates :config, type: Hash + validates :config, type: Hash, presence: true validates :config, allowed_keys: ALLOWED_KEYS - validates :namespace, type: String, presence: true + validates :namespace, type: String, allow_nil: true + validates :agent, type: String, allow_nil: true end end end diff --git a/scripts/internal_events/cli.rb b/scripts/internal_events/cli.rb index e101216b8c0..ff2392e115c 100755 --- a/scripts/internal_events/cli.rb +++ b/scripts/internal_events/cli.rb @@ -10,10 +10,10 @@ require 'json_schemer' require 'delegate' require_relative './cli/helpers' -require_relative './cli/usage_viewer' -require_relative './cli/metric_definer' -require_relative './cli/event_definer' -require_relative './cli/flow_advisor' +require_relative './cli/flows/usage_viewer' +require_relative './cli/flows/metric_definer' +require_relative './cli/flows/event_definer' +require_relative './cli/flows/flow_advisor' require_relative './cli/global_state' require_relative './cli/metric' require_relative './cli/event' @@ -36,22 +36,22 @@ class Cli menu.enum "." menu.choice "New Event -- track when a specific scenario occurs on gitlab instances\n " \ - "ex) a user applies a label to an issue", :new_event + "ex) a user applies a label to an issue", :new_event menu.choice "New Metric -- track the count of existing events over time\n " \ - "ex) count unique users who assign labels to issues per month", :new_metric + "ex) count unique users who assign labels to issues per month", :new_metric menu.choice 'View Usage -- look at code and testing examples for existing events & metrics', :view_usage menu.choice '...am I in the right place?', :help_decide end case task when :new_event - InternalEventsCli::EventDefiner.new(cli).run + InternalEventsCli::Flows::EventDefiner.new(cli).run when :new_metric - InternalEventsCli::MetricDefiner.new(cli).run + InternalEventsCli::Flows::MetricDefiner.new(cli).run when :view_usage - InternalEventsCli::UsageViewer.new(cli).run + InternalEventsCli::Flows::UsageViewer.new(cli).run when :help_decide - InternalEventsCli::FlowAdvisor.new(cli).run + InternalEventsCli::Flows::FlowAdvisor.new(cli).run end end end diff --git a/scripts/internal_events/cli/event_definer.rb b/scripts/internal_events/cli/event_definer.rb deleted file mode 100755 index 0e5da5b35eb..00000000000 --- a/scripts/internal_events/cli/event_definer.rb +++ /dev/null @@ -1,280 +0,0 @@ -# frozen_string_literal: true - -require_relative './helpers' - -module InternalEventsCli - class EventDefiner - include Helpers - - SCHEMA = ::JSONSchemer.schema(Pathname('config/events/schema.json')) - STEPS = [ - 'New Event', - 'Description', - 'Name', - 'Context', - 'URL', - 'Group', - 'Tiers', - 'Save files' - ].freeze - - IDENTIFIER_OPTIONS = { - %w[project namespace user] => 'Use case: For project-level user actions ' \ - '(ex - issue_assignee_changed) [MOST COMMON]', - %w[namespace user] => 'Use case: For namespace-level user actions (ex - epic_assigned_to_milestone)', - %w[user] => 'Use case: For user-only actions (ex - admin_impersonated_user)', - %w[project namespace] => 'Use case: For project-level events without user interaction ' \ - '(ex - service_desk_request_received)', - %w[namespace] => 'Use case: For namespace-level events without user interaction ' \ - '(ex - stale_runners_cleaned_up)', - %w[feature_enabled_by_namespace_ids user] => 'Use case: For user actions attributable to multiple namespaces ' \ - '(ex - Code-Suggestions / Duo Pro)', - %w[] => "Use case: For instance-level events without user interaction [LEAST COMMON]" - }.freeze - - IDENTIFIER_FORMATTING_BUFFER = "[#{IDENTIFIER_OPTIONS.keys.map { |k| k.join(', ') }.max_by(&:length)}]".length - - attr_reader :cli, :event - - def initialize(cli) - @cli = cli - @event = Event.new(milestone: MILESTONE) - end - - def run - prompt_for_description - prompt_for_action - prompt_for_context - prompt_for_url - prompt_for_product_group - prompt_for_tier - - outcome = create_event_file - display_result(outcome) - - prompt_for_next_steps - end - - private - - def prompt_for_description - new_page!(1, 7, STEPS) - cli.say Text::EVENT_DESCRIPTION_INTRO - - event.description = cli.ask("Describe what the event tracks: #{input_required_text}", **input_opts) do |q| - q.required true - q.modify :trim - q.messages[:required?] = Text::EVENT_DESCRIPTION_HELP - end - end - - def prompt_for_action - new_page!(2, 7, STEPS) - cli.say Text::EVENT_ACTION_INTRO - - event.action = cli.ask("Define the event name: #{input_required_text}", **input_opts) do |q| - q.required true - q.validate ->(input) { input =~ NAME_REGEX && cli.global.events.map(&:action).none?(input) } - q.modify :trim - q.messages[:valid?] = format_warning("Invalid event name. Only lowercase/numbers/underscores allowed. " \ - "Ensure %{value} is not an existing event.") - q.messages[:required?] = Text::EVENT_ACTION_HELP - end - end - - def prompt_for_context - new_page!(3, 7, STEPS) - cli.say format_prompt("EVENT CONTEXT #{counter(0, 2)}") - prompt_for_identifiers - - new_page!(3, 7, STEPS) # Same "step" but increment counter - cli.say format_prompt("EVENT CONTEXT #{counter(1, 2)}") - prompt_for_additional_properties - end - - def prompt_for_identifiers - cli.say Text::EVENT_IDENTIFIERS_INTRO % event.action - - identifiers = prompt_for_array_selection( - 'Which identifiers are available when the event occurs?', - IDENTIFIER_OPTIONS.keys, - per_page: IDENTIFIER_OPTIONS.length - ) { |choice| format_identifier_choice(choice) } - - event.identifiers = identifiers if identifiers.any? - end - - def format_identifier_choice(choice) - formatted_choice = choice.empty? ? 'None' : "[#{choice.sort.join(', ')}]" - buffer = IDENTIFIER_FORMATTING_BUFFER - formatted_choice.length - - "#{formatted_choice}#{' ' * buffer} -- #{IDENTIFIER_OPTIONS[choice]}" - end - - def prompt_for_additional_properties - cli.say Text::ADDITIONAL_PROPERTIES_INTRO - - available_props = [:label, :property, :value, :add_extra_prop] - - while available_props.any? - disabled = format_help('(already defined)') - - # rubocop:disable Rails/NegateInclude -- this isn't Rails - options = [ - { value: :none, name: 'None! Continue to next section!' }, - disableable_option( - value: :label, - name: 'String 1 (attribute will be named `label`)', - disabled: disabled - ) { !available_props.include?(:label) }, - disableable_option( - value: :property, - name: 'String 2 (attribute will be named `property`)', - disabled: disabled - ) { !available_props.include?(:property) }, - disableable_option( - value: :value, - name: 'Number (attribute will be named `value`)', - disabled: disabled - ) { !available_props.include?(:value) }, - disableable_option( - value: :add_extra_prop, - name: 'Add extra property (attribute will be named the input custom name)', - disabled: format_warning('(option disabled - use label/property/value first)') - ) do - !((!available_props.include?(:label) && - !available_props.include?(:property)) || - !available_props.include?(:value)) - end - ] - # rubocop:enable Rails/NegateInclude - - selected_property = cli.select( - "Which additional property do you want to add to the event?", - options, - help: format_help("(will reprompt for multiple)"), - **select_opts, - &disabled_format_callback - ) - - if selected_property == :none - available_props.clear - elsif selected_property == :add_extra_prop - property_name = prompt_for_add_extra_properties - property_description = prompt_for_text('Describe what the field will include:') - assign_extra_properties(property_name, property_description) - else - available_props.delete(selected_property) - property_description = prompt_for_text('Describe what the field will include:') - assign_extra_properties(selected_property, property_description) - end - end - end - - def assign_extra_properties(property, description = nil) - event.additional_properties ||= {} - event.additional_properties[property.to_s] = { - 'description' => description || 'TODO' - } - end - - def prompt_for_add_extra_properties - primary_props = %w[label property value] - - prompt_for_text('Define a name for the attribute:', **input_opts) do |q| - q.required true - q.validate ->(input) { input =~ NAME_REGEX && primary_props.none?(input) } - q.modify :trim - q.messages[:required?] = Text::ADDITIONAL_PROPERTIES_ADD_MORE_HELP - q.messages[:valid?] = format_warning("Invalid property name. Only lowercase/numbers/underscores allowed. " \ - "Ensure %{value} is not one of `property, label, value`.") - end - end - - def prompt_for_url - new_page!(4, 7, STEPS) - - event.introduced_by_url = prompt_for_text('Which MR URL will merge the event definition?') - end - - def prompt_for_product_group - new_page!(5, 7, STEPS) - - product_group = prompt_for_group_ownership('Which group will own the event?') - - event.product_group = product_group - end - - def prompt_for_tier - new_page!(6, 7, STEPS) - - event.tiers = prompt_for_array_selection( - 'Which tiers will the event be recorded on?', - [%w[free premium ultimate], %w[premium ultimate], %w[ultimate]] - ) - - event.distributions = event.tiers.include?('free') ? %w[ce ee] : %w[ee] - end - - def create_event_file - new_page!(7, 7, STEPS) - - prompt_to_save_file(event.file_path, event.formatted_output) - end - - def display_result(outcome) - new_page! - - cli.say <<~TEXT - #{divider} - #{format_info('Done with event definition!')} - - #{outcome || ' No files saved.'} - - #{divider} - - Do you need to create a metric? Probably! - - Metrics are required to pull any usage data from self-managed instances or GitLab-Dedicated through Service Ping. Collected metric data can viewed in Tableau. Individual event details from GitLab.com can also be accessed through Snowflake. - - Typical flow: Define event > Define metric > Instrument app code > Merge/Deploy MR > Verify data in Tableau/Snowflake - - TEXT - end - - def prompt_for_next_steps - next_step = cli.select("How would you like to proceed?", **select_opts) do |menu| - menu.enum "." - - menu.choice "New Event -- define another event", :new_event - - choice = if File.exist?(event.file_path) - ["Create Metric -- define a new metric using #{event.action}.yml", :add_metric] - else - ["Save & Create Metric -- save #{event.action}.yml and define a matching metric", :save_and_add] - end - - menu.default choice[0] - menu.choice(*choice) - - menu.choice "View Usage -- look at code examples for #{event.action}.yml", :view_usage - menu.choice 'Exit', :exit - end - - case next_step - when :new_event - InternalEventsCli::EventDefiner.new(cli).run - when :add_metric - MetricDefiner.new(cli, event.file_path).run - when :save_and_add - write_to_file(event.file_path, event.formatted_output, 'create') - - MetricDefiner.new(cli, event.file_path).run - when :view_usage - UsageViewer.new(cli, event.file_path, event).run - when :exit - cli.say Text::FEEDBACK_NOTICE - end - end - end -end diff --git a/scripts/internal_events/cli/flow_advisor.rb b/scripts/internal_events/cli/flow_advisor.rb deleted file mode 100644 index 5c8037aa684..00000000000 --- a/scripts/internal_events/cli/flow_advisor.rb +++ /dev/null @@ -1,89 +0,0 @@ -# frozen_string_literal: true - -require_relative './helpers' - -# Entrypoint for help flow, which directs the user to the -# correct flow or documentation based on their goal -module InternalEventsCli - class FlowAdvisor - include Helpers - - attr_reader :cli - - def initialize(cli) - @cli = cli - end - - def run - return use_case_error unless goal_is_tracking_usage? - return use_case_error unless usage_trackable_with_internal_events? - - event_already_tracked? ? proceed_to_metric_definition : proceed_to_event_definition - end - - private - - def goal_is_tracking_usage? - new_page! - - cli.say format_info("First, let's check your objective.\n") - - cli.yes?('Are you trying to track customer usage of a GitLab feature?', **yes_no_opts) - end - - def usage_trackable_with_internal_events? - new_page! - - cli.say format_info("Excellent! Let's check that this tool will fit your needs.\n") - cli.say Text::EVENT_TRACKING_EXAMPLES - - cli.yes?( - 'Can usage for the feature be measured with a count of specific user actions or events? ' \ - 'Or counting a set of events?', - **yes_no_opts - ) - end - - def event_already_tracked? - new_page! - - cli.say format_info("Super! Let's figure out if the event is already tracked & usable.\n") - cli.say Text::EVENT_EXISTENCE_CHECK_INSTRUCTIONS - - cli.yes?('Is the event already tracked?', **yes_no_opts) - end - - def use_case_error - new_page! - - cli.error("Oh no! This probably isn't the tool you need!\n") - cli.say Text::ALTERNATE_RESOURCES_NOTICE - cli.say Text::FEEDBACK_NOTICE - end - - def proceed_to_metric_definition - new_page! - - cli.say format_info("Amazing! The next step is adding a new metric! (~8-15 min)\n") - - return not_ready_error('New Metric') unless cli.yes?(format_prompt('Ready to start?')) - - MetricDefiner.new(cli).run - end - - def proceed_to_event_definition - new_page! - - cli.say format_info("Okay! The next step is adding a new event! (~5-10 min)\n") - - return not_ready_error('New Event') unless cli.yes?(format_prompt('Ready to start?')) - - EventDefiner.new(cli).run - end - - def not_ready_error(description) - cli.say "\nNo problem! When you're ready, run the CLI & select '#{description}'\n" - cli.say Text::FEEDBACK_NOTICE - end - end -end diff --git a/scripts/internal_events/cli/flows/event_definer.rb b/scripts/internal_events/cli/flows/event_definer.rb new file mode 100644 index 00000000000..1a5bae24f24 --- /dev/null +++ b/scripts/internal_events/cli/flows/event_definer.rb @@ -0,0 +1,288 @@ +# frozen_string_literal: true + +require_relative '../helpers' + +# Entrypoint for flow to create an event definition file +module InternalEventsCli + module Flows + class EventDefiner + include Helpers + + SCHEMA = ::JSONSchemer.schema(Pathname('config/events/schema.json')) + STEPS = [ + 'New Event', + 'Description', + 'Name', + 'Context', + 'URL', + 'Group', + 'Tiers', + 'Save files' + ].freeze + + IDENTIFIER_OPTIONS = { + %w[project namespace user] => + 'Use case: For project-level user actions (ex - issue_assignee_changed) [MOST COMMON]', + %w[namespace user] => + 'Use case: For namespace-level user actions (ex - epic_assigned_to_milestone)', + %w[user] => + 'Use case: For user-only actions (ex - admin_impersonated_user)', + %w[project namespace] => + 'Use case: For project-level events without user interaction (ex - service_desk_request_received)', + %w[namespace] => + 'Use case: For namespace-level events without user interaction (ex - stale_runners_cleaned_up)', + %w[feature_enabled_by_namespace_ids user] => + 'Use case: For user actions attributable to multiple namespaces (ex - Code-Suggestions / Duo Pro)', + %w[] => + 'Use case: For instance-level events without user interaction [LEAST COMMON]' + }.freeze + + IDENTIFIER_FORMATTING_BUFFER = "[#{IDENTIFIER_OPTIONS.keys.map { |k| k.join(', ') }.max_by(&:length)}]".length + + attr_reader :cli, :event + + def initialize(cli) + @cli = cli + @event = Event.new(milestone: MILESTONE) + end + + def run + prompt_for_description + prompt_for_action + prompt_for_context + prompt_for_url + prompt_for_product_group + prompt_for_tier + + outcome = create_event_file + display_result(outcome) + + prompt_for_next_steps + end + + private + + def prompt_for_description + new_page!(1, 7, STEPS) + cli.say Text::EVENT_DESCRIPTION_INTRO + + event.description = cli.ask("Describe what the event tracks: #{input_required_text}", **input_opts) do |q| + q.required true + q.modify :trim + q.messages[:required?] = Text::EVENT_DESCRIPTION_HELP + end + end + + def prompt_for_action + new_page!(2, 7, STEPS) + cli.say Text::EVENT_ACTION_INTRO + + event.action = cli.ask("Define the event name: #{input_required_text}", **input_opts) do |q| + q.required true + q.validate ->(input) { input =~ NAME_REGEX && cli.global.events.map(&:action).none?(input) } + q.modify :trim + q.messages[:valid?] = format_warning( + "Invalid event name. Only lowercase/numbers/underscores allowed. " \ + "Ensure %{value} is not an existing event.") + q.messages[:required?] = Text::EVENT_ACTION_HELP + end + end + + def prompt_for_context + new_page!(3, 7, STEPS) + cli.say format_prompt("EVENT CONTEXT #{counter(0, 2)}") + prompt_for_identifiers + + new_page!(3, 7, STEPS) # Same "step" but increment counter + cli.say format_prompt("EVENT CONTEXT #{counter(1, 2)}") + prompt_for_additional_properties + end + + def prompt_for_identifiers + cli.say Text::EVENT_IDENTIFIERS_INTRO % event.action + + identifiers = prompt_for_array_selection( + 'Which identifiers are available when the event occurs?', + IDENTIFIER_OPTIONS.keys, + per_page: IDENTIFIER_OPTIONS.length + ) { |choice| format_identifier_choice(choice) } + + event.identifiers = identifiers if identifiers.any? + end + + def format_identifier_choice(choice) + formatted_choice = choice.empty? ? 'None' : "[#{choice.sort.join(', ')}]" + buffer = IDENTIFIER_FORMATTING_BUFFER - formatted_choice.length + + "#{formatted_choice}#{' ' * buffer} -- #{IDENTIFIER_OPTIONS[choice]}" + end + + def prompt_for_additional_properties + cli.say Text::ADDITIONAL_PROPERTIES_INTRO + + available_props = [:label, :property, :value, :add_extra_prop] + + while available_props.any? + disabled = format_help('(already defined)') + + # rubocop:disable Rails/NegateInclude -- this isn't Rails + options = [ + { value: :none, name: 'None! Continue to next section!' }, + disableable_option( + value: :label, + name: 'String 1 (attribute will be named `label`)', + disabled: disabled + ) { !available_props.include?(:label) }, + disableable_option( + value: :property, + name: 'String 2 (attribute will be named `property`)', + disabled: disabled + ) { !available_props.include?(:property) }, + disableable_option( + value: :value, + name: 'Number (attribute will be named `value`)', + disabled: disabled + ) { !available_props.include?(:value) }, + disableable_option( + value: :add_extra_prop, + name: 'Add extra property (attribute will be named the input custom name)', + disabled: format_warning('(option disabled - use label/property/value first)') + ) do + !((!available_props.include?(:label) && + !available_props.include?(:property)) || + !available_props.include?(:value)) + end + ] + # rubocop:enable Rails/NegateInclude + + selected_property = cli.select( + "Which additional property do you want to add to the event?", + options, + help: format_help("(will reprompt for multiple)"), + **select_opts, + &disabled_format_callback + ) + + if selected_property == :none + available_props.clear + elsif selected_property == :add_extra_prop + property_name = prompt_for_add_extra_properties + property_description = prompt_for_text('Describe what the field will include:') + assign_extra_properties(property_name, property_description) + else + available_props.delete(selected_property) + property_description = prompt_for_text('Describe what the field will include:') + assign_extra_properties(selected_property, property_description) + end + end + end + + def assign_extra_properties(property, description = nil) + event.additional_properties ||= {} + event.additional_properties[property.to_s] = { + 'description' => description || 'TODO' + } + end + + def prompt_for_add_extra_properties + primary_props = %w[label property value] + + prompt_for_text('Define a name for the attribute:', **input_opts) do |q| + q.required true + q.validate ->(input) { input =~ NAME_REGEX && primary_props.none?(input) } + q.modify :trim + q.messages[:required?] = Text::ADDITIONAL_PROPERTIES_ADD_MORE_HELP + q.messages[:valid?] = format_warning( + "Invalid property name. Only lowercase/numbers/underscores allowed. " \ + "Ensure %{value} is not one of `property, label, value`.") + end + end + + def prompt_for_url + new_page!(4, 7, STEPS) + + event.introduced_by_url = prompt_for_text('Which MR URL will merge the event definition?') + end + + def prompt_for_product_group + new_page!(5, 7, STEPS) + + product_group = prompt_for_group_ownership('Which group will own the event?') + + event.product_group = product_group + end + + def prompt_for_tier + new_page!(6, 7, STEPS) + + event.tiers = prompt_for_array_selection( + 'Which tiers will the event be recorded on?', + [%w[free premium ultimate], %w[premium ultimate], %w[ultimate]] + ) + + event.distributions = event.tiers.include?('free') ? %w[ce ee] : %w[ee] + end + + def create_event_file + new_page!(7, 7, STEPS) + + prompt_to_save_file(event.file_path, event.formatted_output) + end + + def display_result(outcome) + new_page! + + cli.say <<~TEXT + #{divider} + #{format_info('Done with event definition!')} + + #{outcome || ' No files saved.'} + + #{divider} + + Do you need to create a metric? Probably! + + Metrics are required to pull any usage data from self-managed instances or GitLab-Dedicated through Service Ping. Collected metric data can viewed in Tableau. Individual event details from GitLab.com can also be accessed through Snowflake. + + Typical flow: Define event > Define metric > Instrument app code > Merge/Deploy MR > Verify data in Tableau/Snowflake + + TEXT + end + + def prompt_for_next_steps + next_step = cli.select("How would you like to proceed?", **select_opts) do |menu| + menu.enum "." + + menu.choice "New Event -- define another event", :new_event + + choice = if File.exist?(event.file_path) + ["Create Metric -- define a new metric using #{event.action}.yml", :add_metric] + else + ["Save & Create Metric -- save #{event.action}.yml and define a matching metric", :save_and_add] + end + + menu.default choice[0] + menu.choice(*choice) + + menu.choice "View Usage -- look at code examples for #{event.action}.yml", :view_usage + menu.choice 'Exit', :exit + end + + case next_step + when :new_event + EventDefiner.new(cli).run + when :add_metric + MetricDefiner.new(cli, event.file_path).run + when :save_and_add + write_to_file(event.file_path, event.formatted_output, 'create') + + MetricDefiner.new(cli, event.file_path).run + when :view_usage + UsageViewer.new(cli, event.file_path, event).run + when :exit + cli.say Text::FEEDBACK_NOTICE + end + end + end + end +end diff --git a/scripts/internal_events/cli/flows/flow_advisor.rb b/scripts/internal_events/cli/flows/flow_advisor.rb new file mode 100755 index 00000000000..79435a08af5 --- /dev/null +++ b/scripts/internal_events/cli/flows/flow_advisor.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require_relative '../helpers' + +# Entrypoint for help flow, which directs the user to the +# correct flow or documentation based on their goal +module InternalEventsCli + module Flows + class FlowAdvisor + include Helpers + + attr_reader :cli + + def initialize(cli) + @cli = cli + end + + def run + return use_case_error unless goal_is_tracking_usage? + return use_case_error unless usage_trackable_with_internal_events? + + event_already_tracked? ? proceed_to_metric_definition : proceed_to_event_definition + end + + private + + def goal_is_tracking_usage? + new_page! + + cli.say format_info("First, let's check your objective.\n") + + cli.yes?('Are you trying to track customer usage of a GitLab feature?', **yes_no_opts) + end + + def usage_trackable_with_internal_events? + new_page! + + cli.say format_info("Excellent! Let's check that this tool will fit your needs.\n") + cli.say Text::EVENT_TRACKING_EXAMPLES + + cli.yes?( + 'Can usage for the feature be measured with a count of specific user actions or events? ' \ + 'Or counting a set of events?', + **yes_no_opts + ) + end + + def event_already_tracked? + new_page! + + cli.say format_info("Super! Let's figure out if the event is already tracked & usable.\n") + cli.say Text::EVENT_EXISTENCE_CHECK_INSTRUCTIONS + + cli.yes?('Is the event already tracked?', **yes_no_opts) + end + + def use_case_error + new_page! + + cli.error("Oh no! This probably isn't the tool you need!\n") + cli.say Text::ALTERNATE_RESOURCES_NOTICE + cli.say Text::FEEDBACK_NOTICE + end + + def proceed_to_metric_definition + new_page! + + cli.say format_info("Amazing! The next step is adding a new metric! (~8-15 min)\n") + + return not_ready_error('New Metric') unless cli.yes?(format_prompt('Ready to start?')) + + MetricDefiner.new(cli).run + end + + def proceed_to_event_definition + new_page! + + cli.say format_info("Okay! The next step is adding a new event! (~5-10 min)\n") + + return not_ready_error('New Event') unless cli.yes?(format_prompt('Ready to start?')) + + EventDefiner.new(cli).run + end + + def not_ready_error(description) + cli.say "\nNo problem! When you're ready, run the CLI & select '#{description}'\n" + cli.say Text::FEEDBACK_NOTICE + end + end + end +end diff --git a/scripts/internal_events/cli/flows/metric_definer.rb b/scripts/internal_events/cli/flows/metric_definer.rb new file mode 100755 index 00000000000..17ea14772d0 --- /dev/null +++ b/scripts/internal_events/cli/flows/metric_definer.rb @@ -0,0 +1,574 @@ +# frozen_string_literal: true + +require_relative '../helpers' +require_relative '../text' + +# Entrypoint for flow to create an metric definition file +module InternalEventsCli + module Flows + class MetricDefiner + include Helpers + + SCHEMA = ::JSONSchemer.schema(Pathname('config/metrics/schema/base.json')) + STEPS = [ + 'New Metric', + 'Type', + 'Events', + 'Scope', + 'Descriptions', + 'Copy event', + 'Group', + 'URL', + 'Tiers', + 'Save files' + ].freeze + + NAME_REQUIREMENT_REASONS = { + filters: { + text: 'Metrics using filters are too complex for default naming.', + help: Text::METRIC_NAME_FILTER_HELP + }, + length: { + text: 'The default filename will be too long.', + help: Text::METRIC_NAME_LENGTH_HELP + }, + conflict: { + text: 'The default key path is already in use.', + help: Text::METRIC_NAME_CONFLICT_HELP + } + }.freeze + + attr_reader :cli + + def initialize(cli, starting_event = nil) + @cli = cli + @selected_event_paths = Array(starting_event) + @metrics = [] + @selected_filters = {} + end + + def run + type = prompt_for_metric_type + prompt_for_events(type) + + return unless @selected_event_paths.any? + + prompt_for_metrics + prompt_for_event_filters + + return unless @metrics.any? + + prompt_for_descriptions + defaults = prompt_for_copying_event_properties + prompt_for_product_group(defaults) + prompt_for_url(defaults) + prompt_for_tier(defaults) + outcomes = create_metric_files + prompt_for_next_steps(outcomes) + end + + private + + # ----- Memoization Helpers ----------------- + + def events + @events ||= events_by_filepath(@selected_event_paths) + end + + def selected_events + @selected_events ||= events.values_at(*@selected_event_paths) + end + + # ----- Prompts ----------------------------- + + def prompt_for_metric_type + return if @selected_event_paths.any? + + new_page!(1, 9, STEPS) + + cli.select("Which best describes what the metric should track?", **select_opts) do |menu| + menu.enum "." + + menu.choice 'Single event -- count occurrences of a specific event or user interaction', + :event_metric + menu.choice 'Multiple events -- count occurrences of several separate events or interactions', + :aggregate_metric + menu.choice 'Database -- record value of a particular field or count of database rows', + :database_metric + end + end + + def prompt_for_events(type) + return if @selected_event_paths.any? + + new_page!(2, 9, STEPS) + + case type + when :event_metric + cli.say "For robust event search, use the Metrics Dictionary: https://metrics.gitlab.com/snowplow\n\n" + + @selected_event_paths = [cli.select( + 'Which event does this metric track?', + get_event_options(events), + **select_opts, + **filter_opts(header_size: 7) + )] + when :aggregate_metric + cli.say "For robust event search, use the Metrics Dictionary: https://metrics.gitlab.com/snowplow\n\n" + + @selected_event_paths = cli.multi_select( + 'Which events does this metric track? (Space to select)', + get_event_options(events), + **multiselect_opts, + **filter_opts(header_size: 7) + ) + when :database_metric + cli.error Text::DATABASE_METRIC_NOTICE + cli.say Text::FEEDBACK_NOTICE + end + end + + def prompt_for_metrics + eligible_metrics = get_metric_options(selected_events) + + if eligible_metrics.all? { |metric| metric[:disabled] } + cli.error Text::ALL_METRICS_EXIST_NOTICE + cli.say Text::FEEDBACK_NOTICE + + return + end + + new_page!(3, 9, STEPS) + cli.say format_info('SELECTED EVENTS') + cli.say selected_events_filter_options.join + cli.say "\n" + + @metrics = cli.select( + 'Which metrics do you want to add?', + eligible_metrics, + **select_opts, + **filter_opts, + per_page: 20, + &disabled_format_callback + ) + + assign_shared_attrs(:actions, :milestone) do + { + actions: selected_events.map(&:action).sort, + milestone: MILESTONE + } + end + end + + def prompt_for_event_filters + return if @metrics.none?(&:filters_expected?) + + selected_unique_identifier = @metrics.first.identifier.value + event_count = selected_events.length + previous_inputs = { + 'label' => nil, + 'property' => nil, + 'value' => nil + } + + event_filters = selected_events.dup.flat_map.with_index do |event, idx| + print_event_filter_header(event, idx, event_count) + + next if deselect_nonfilterable_event?(event) # prompts user + + filter_values = event.additional_properties&.filter_map do |property, _| + next if selected_unique_identifier == property + + prompt_for_property_filter( + event.action, + property, + previous_inputs[property] + ) + end + + previous_inputs.merge!(@selected_filters[event.action] || {}) + + find_filter_permutations(event.action, filter_values) + end.compact + + bulk_assign(filters: event_filters) + end + + def prompt_for_descriptions + default_description = nil + default_key = nil + + separate_page_per_metric = @metrics.any? do |metric| + name_requirement_reason(metric) + end + + @metrics.each_with_index do |metric, idx| + if idx == 0 || separate_page_per_metric + new_page!(4, 9, STEPS) + + cli.say Text::METRIC_DESCRIPTION_INTRO + cli.say selected_event_descriptions.join + end + + cli.say "\n" + cli.say format_prompt(format_subheader( + 'DESCRIBING METRIC', + metric.technical_description, + idx, + @metrics.length + )) + + prompt_for_description(metric, default_description).tap do |description| + default_description = description + metric.description = "#{metric.description_prefix} #{description}" + end + + prompt_for_metric_name(metric, default_key)&.tap do |key| + default_key = key + metric.key = key + end + end + end + + def file_saved_context_message(attributes) + format_prefix " ", <<~TEXT.chomp + - Visit #{format_info('https://metrics.gitlab.com')} to find dashboard links for this metric + - Metric trend dashboard: #{format_info(metric_trend_path(attributes['key_path']))} + TEXT + end + + # Check existing event files for attributes to copy over + def prompt_for_copying_event_properties + shared_values = collect_values_for_shared_event_properties + defaults = shared_values.except(:stage, :section) + + return {} if shared_values.none? + + return shared_values if defaults.none? + + new_page!(5, 9, STEPS) + + cli.say <<~TEXT + #{format_info('Convenient! We can copy these attributes from the event definition(s):')} + + #{defaults.compact.transform_keys(&:to_s).to_yaml(line_width: 150)} + #{format_info('If any of these attributes are incorrect, you can also change them manually from your text editor later.')} + + TEXT + + cli.select('What would you like to do?', **select_opts) do |menu| + menu.enum '.' + menu.choice 'Copy & continue', -> { bulk_assign(defaults) } + menu.choice 'Modify attributes' + end + + shared_values + end + + def prompt_for_product_group(defaults) + assign_shared_attr(:product_group) do + new_page!(6, 9, STEPS) + + prompt_for_group_ownership('Which group owns the metric?', defaults) + end + end + + def prompt_for_url(defaults) + assign_shared_attr(:introduced_by_url) do + new_page!(7, 9, STEPS) + + prompt_for_text( + 'Which MR URL introduced the metric?', + defaults[:introduced_by_url] + ) + end + end + + def prompt_for_tier(defaults) + assign_shared_attr(:tier) do + new_page!(8, 9, STEPS) + + prompt_for_array_selection( + 'Which tiers will the metric be reported from?', + [%w[free premium ultimate], %w[premium ultimate], %w[ultimate]], + defaults[:tier] + ) + end + + assign_shared_attr(:tiers) { |metric| [*metric.tier] } + + assign_shared_attr(:distribution) do |metric| + metric.tier.include?('free') ? %w[ce ee] : %w[ee] + end + end + + def create_metric_files + @metrics.map.with_index do |metric, idx| + new_page!(9, 9, STEPS) # Repeat the same step number but increment metric counter + + cli.say format_prompt(format_subheader('SAVING FILE', metric.description, idx, @metrics.length)) + cli.say "\n" + + prompt_to_save_file(metric.file_path, metric.formatted_output) + end + end + + def prompt_for_next_steps(outcomes = []) + new_page! + + outcome = outcomes.any? ? outcomes.compact.join("\n") : ' No files saved.' + metric = @metrics.first + + cli.say <<~TEXT + #{divider} + #{format_info('Done with metric definitions!')} + + #{outcome} + #{divider} + + Have you instrumented the application code to trigger the event yet? View usage examples to easily copy/paste implementation! + + Want to verify the metrics? Check out the group::#{metric[:product_group]} Metrics Exploration Dashboard in Tableau + Note: The Metrics Exploration Dashboard data would be available ~1 week after deploy for Gitlab.com, ~1 week after next release for self-managed + Link: #{format_info(metric_exploration_group_path(metric[:product_group], find_stage(metric.product_group)))} + + Typical flow: Define event > Define metric > Instrument app code > Merge/Deploy MR > Verify data in Tableau/Snowflake + + TEXT + + actions = selected_events.map(&:action).join(', ') + next_step = cli.select("How would you like to proceed?", **select_opts) do |menu| + menu.enum "." + + menu.choice "New Event -- define a new event", :new_event + menu.choice "New Metric -- define another metric for #{actions}", :new_metric_with_events + menu.choice "New Metric -- define another metric", :new_metric + choice = "View Usage -- look at code examples for event #{selected_events.first.action}" + menu.default choice + menu.choice choice, :view_usage + menu.choice 'Exit', :exit + end + + case next_step + when :new_event + EventDefiner.new(cli).run + when :new_metric_with_events + MetricDefiner.new(cli, @selected_event_paths).run + when :new_metric + MetricDefiner.new(cli).run + when :view_usage + UsageViewer.new(cli, @selected_event_paths.first, selected_events.first).run + when :exit + cli.say Text::FEEDBACK_NOTICE + end + end + + # ----- Prompt-specific Helpers ------------- + + # Helper for #prompt_for_metrics + def selected_events_filter_options + filterable_events_selected = selected_events.any? { |event| event.additional_properties&.any? } + + selected_events.map do |event| + filters = event.additional_properties&.keys + filter_phrase = if filters + " (filterable by #{filters&.join(', ')})" + elsif filterable_events_selected + ' -- not filterable' + end + + " - #{event.action}#{format_help(filter_phrase)}\n" + end + end + + # Helper for #prompt_for_event_filters + def print_event_filter_header(event, idx, total) + cli.say "\n" + cli.say format_info(format_subheader('SETTING EVENT FILTERS', event.action, idx, total)) + + return unless event.additional_properties&.any? + + event_filter_options = event.additional_properties.map do |property, attrs| + " #{property}: #{attrs['description']}\n" + end + + cli.say event_filter_options.join + end + + # Helper for #prompt_for_event_filters + def deselect_nonfilterable_event?(event) + cli.say "\n" + + return false if event.additional_properties&.any? + return false if cli.yes?("This event is not filterable. Should it be included in the metric?", **yes_no_opts) + + selected_events.delete(event) + bulk_assign(actions: selected_events.map(&:action).sort) + + true + end + + # Helper for #prompt_for_event_filters + def prompt_for_property_filter(action, property, default) + formatted_prop = format_info(property) + prompt = "Count where #{formatted_prop} equals any of (comma-sep):" + + inputs = prompt_for_text(prompt, default, **input_opts) do |q| + if property == 'value' + q.convert ->(input) { input.split(',').map(&:to_i).uniq } + q.validate %r{^(\d|\s|,)*$} + q.messages[:valid?] = "Inputs for #{formatted_prop} must be numeric" + else + q.convert ->(input) { input.split(',').map(&:strip).uniq } + end + end + + return unless inputs&.any? + + @selected_filters[action] ||= {} + @selected_filters[action][property] = inputs.join(',') + + inputs.map { |input| { property => input } }.uniq + end + + # Helper for #prompt_for_event_filters + # + # Gets all the permutations of the provided property values. + # @param filters [Array] ex) [{ 'label' => 'red' }, { 'label' => 'blue' }, { value => 16 }] + # @return ex) [{ 'label' => 'red', value => 16 }, { 'label' => 'blue', value => 16 }] + def find_filter_permutations(action, filters) + # Define a filter for all events, regardless of the available props so NewMetric#events is correct + return [[action, {}]] unless filters&.any? + + # Uses proc syntax to avoid spliting & type-checking `filters` + :product.to_proc.call(*filters).map do |filter| + [action, filter.reduce(&:merge)] + end + end + + # Helper for #prompt_for_descriptions + def selected_event_descriptions + selected_events.map do |event| + filters = @selected_filters[event.action] + + if filters&.any? + filter_phrase = filters.map { |k, v| "#{k}=#{v}" }.join(' ') + filter_phrase = format_help("(#{filter_phrase})") + end + + " #{event.action}#{filter_phrase} - #{format_selection(event.description)}\n" + end + end + + # Helper for #prompt_for_descriptions + def prompt_for_description(metric, default) + description_start = format_info("#{metric.description_prefix}...") + + cli.say <<~TEXT + + #{input_opts[:prefix]} How would you describe this metric to a non-technical person? #{input_required_text} + + TEXT + + prompt_for_text(" Finish the description: #{description_start}", default, multiline: true) do |q| + q.required true + q.modify :trim + q.messages[:required?] = Text::METRIC_DESCRIPTION_HELP + end + end + + # Helper for #prompt_for_descriptions + def prompt_for_metric_name(metric, default) + name_reason = name_requirement_reason(metric) + default_name = metric.key.value + display_name = metric.key.value("\e[0m[REPLACE ME]\e[36m") + empty_name = metric.key.value('') + + return unless name_reason + + cli.say <<~TEXT + + #{input_opts[:prefix]} #{name_reason[:text]} How should we refererence this metric? #{input_required_text} + + ID: #{format_info(display_name)} + Filename: #{format_info(display_name)}#{format_info('.yml')} + + TEXT + + max_length = MAX_FILENAME_LENGTH - "#{empty_name}.yml".length + help_tokens = { name: default_name, count: max_length } + + prompt_for_text(' Replace with: ', default, multiline: true) do |q| + q.required true + q.messages[:required?] = name_reason[:help] % help_tokens + q.messages[:valid?] = Text::METRIC_NAME_ERROR % help_tokens + q.validate ->(input) do + input.length <= max_length && + input.match?(NAME_REGEX) && + !conflicting_key_path?(metric.key.value(input)) + end + end + end + + # Helper for #prompt_for_descriptions + def name_requirement_reason(metric) + if metric.filters.assigned? + NAME_REQUIREMENT_REASONS[:filters] + elsif metric.file_name.length > MAX_FILENAME_LENGTH + NAME_REQUIREMENT_REASONS[:length] + elsif conflicting_key_path?(metric.key_path) + NAME_REQUIREMENT_REASONS[:conflict] + end + end + + # Helper for #prompt_for_descriptions + def conflicting_key_path?(key_path) + cli.global.metrics.any? do |existing_metric| + existing_metric.key_path == key_path + end + end + + # Helper for #prompt_for_copying_event_properties + def collect_values_for_shared_event_properties + fields = Hash.new { |h, k| h[k] = [] } + + selected_events.each do |event| + fields[:introduced_by_url] << event.introduced_by_url + fields[:product_group] << event.product_group + fields[:stage] << find_stage(event.product_group) + fields[:section] << find_section(event.product_group) + fields[:distribution] << event.distributions&.sort + fields[:tier] << event.tiers&.sort + fields[:tiers] << event.tiers&.sort + end + + # Keep event values if every selected event is the same + fields.each_with_object({}) do |(attr, values), defaults| + next unless values.compact.uniq.length == 1 + + defaults[attr] ||= values.first + end + end + + # ----- Shared Helpers ---------------------- + + def assign_shared_attrs(...) + metric = @metrics.first + attrs = metric.to_h.slice(...) + attrs = yield(metric) unless attrs.values.all? + + bulk_assign(attrs) + end + + def assign_shared_attr(key) + assign_shared_attrs(key) do |metric| + { key => yield(metric) } + end + end + + def bulk_assign(attrs) + @metrics.each { |metric| metric.bulk_assign(attrs) } + end + end + end +end diff --git a/scripts/internal_events/cli/flows/usage_viewer.rb b/scripts/internal_events/cli/flows/usage_viewer.rb new file mode 100755 index 00000000000..1b84769a515 --- /dev/null +++ b/scripts/internal_events/cli/flows/usage_viewer.rb @@ -0,0 +1,438 @@ +# frozen_string_literal: true + +require_relative '../helpers' + +# Entrypoint for flow to print examples of how to trigger an +# event in different languages & different methods of testing +module InternalEventsCli + module Flows + class UsageViewer + include Helpers + + PROPERTY_EXAMPLES = { + 'label' => "'string'", + 'property' => "'string'", + 'value' => '72', + 'custom_key' => 'custom_value' + }.freeze + + attr_reader :cli, :event + + def initialize(cli, event_path = nil, event = nil) + @cli = cli + @event = event + @selected_event_path = event_path + end + + def run + prompt_for_eligible_event + prompt_for_usage_location + end + + def prompt_for_eligible_event + return if event + + event_details = events_by_filepath + + @selected_event_path = cli.select( + "Show examples for which event?", + get_event_options(event_details), + **select_opts, + **filter_opts + ) + + @event = event_details[@selected_event_path] + end + + def prompt_for_usage_location(default = '1. ruby/rails') + choices = [ + { name: '1. ruby/rails', value: :rails }, + { name: '2. rspec', value: :rspec }, + { name: '3. javascript (vue)', value: :vue }, + { name: '4. javascript (plain)', value: :js }, + { name: '5. vue template', value: :vue_template }, + { name: '6. haml', value: :haml }, + { name: '7. Manual testing in GDK', value: :gdk }, + { name: '8. Data verification in Tableau', value: :tableau }, + { name: '9. View examples for a different event', value: :other_event }, + { name: '10. Exit', value: :exit } + ] + + usage_location = cli.select( + 'Select a use-case to view examples for:', + choices, + **select_opts, + **filter_opts, + per_page: 10 + ) do |menu| + menu.default default + end + + case usage_location + when :rails + rails_examples + prompt_for_usage_location('1. ruby/rails') + when :rspec + rspec_examples + prompt_for_usage_location('2. rspec') + when :haml + haml_examples + prompt_for_usage_location('6. haml') + when :js + js_examples + prompt_for_usage_location('4. javascript (plain)') + when :vue + vue_examples + prompt_for_usage_location('3. javascript (vue)') + when :vue_template + vue_template_examples + prompt_for_usage_location('5. vue template') + when :gdk + gdk_examples + prompt_for_usage_location('7. Manual testing in GDK') + when :tableau + service_ping_dashboard_examples + prompt_for_usage_location('8. Data verification in Tableau') + when :other_event + self.class.new(cli).run + when :exit + cli.say(Text::FEEDBACK_NOTICE) + end + end + + def rails_examples + identifier_args = identifiers.map do |identifier| + " #{identifier}: #{identifier}" + end + + property_args = format_additional_properties do |property, value, description| + " #{property}: #{value}, # #{description}" + end + + if property_args.any? + # remove trailing comma after last arg but keep any other commas + property_args.last.sub!(',', '') + property_arg = " additional_properties: {\n#{property_args.join("\n")}\n }" + end + + args = ["'#{action}'", *identifier_args, property_arg].compact.join(",\n") + args = "\n #{args}\n" if args.lines.count > 1 + + cli.say format_warning <<~TEXT + #{divider} + #{format_help('# RAILS')} + + include Gitlab::InternalEventsTracking + + track_internal_event(#{args}) + + #{divider} + TEXT + end + + def rspec_examples + identifier_args = identifiers.map do |identifier| + " let(:#{identifier}) { create(:#{identifier}) }\n" + end.join('') + + property_args = format_additional_properties do |property, value| + " #{property}: #{value}" + end + + if property_args.any? + property_arg = format_prefix ' ', <<~TEXT + let(:additional_properties) do + { + #{property_args.join(",\n")} + } + end + TEXT + end + + args = [*identifier_args, *property_arg].join('') + + cli.say format_warning <<~TEXT + #{divider} + #{format_help('# RSPEC')} + + it_behaves_like 'internal event tracking' do + let(:event) { '#{action}' } + #{args}end + + #{divider} + TEXT + end + + def haml_examples + property_args = format_additional_properties do |property, value, _| + "event_#{property}: #{value}" + end + + args = ["event_tracking: '#{action}'", *property_args].join(', ') + + cli.say <<~TEXT + #{divider} + #{format_help('# HAML -- ON-CLICK')} + + .inline-block{ #{format_warning("data: { #{args} }")} } + = _('Important Text') + + #{divider} + #{format_help('# HAML -- COMPONENT ON-CLICK')} + + = render Pajamas::ButtonComponent.new(button_options: { #{format_warning("data: { #{args} }")} }) + + #{divider} + #{format_help('# HAML -- COMPONENT ON-LOAD')} + + = render Pajamas::ButtonComponent.new(button_options: { #{format_warning("data: { event_tracking_load: true, #{args} }")} }) + + #{divider} + TEXT + + cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n") + end + + def vue_template_examples + on_click_args = template_formatted_args('data-event-tracking', indent: 2) + on_load_args = template_formatted_args('data-event-tracking-load', indent: 2) + + cli.say <<~TEXT + #{divider} + #{format_help('// VUE TEMPLATE -- ON-CLICK')} + + + + + + #{divider} + #{format_help('// VUE TEMPLATE -- ON-LOAD')} + + + + + + #{divider} + TEXT + + cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n") + end + + def js_examples + args = js_formatted_args(indent: 2) + + cli.say <<~TEXT + #{divider} + #{format_help('// FRONTEND -- RAW JAVASCRIPT')} + + #{format_warning("import { InternalEvents } from '~/tracking';")} + + export const performAction = () => { + #{format_warning("InternalEvents.trackEvent#{args}")} + + return true; + }; + + #{divider} + TEXT + + # https://docs.snowplow.io/docs/understanding-your-pipeline/schemas/ + cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n") + end + + def vue_examples + args = js_formatted_args(indent: 6) + + cli.say <<~TEXT + #{divider} + #{format_help('// VUE')} + + + + + + #{divider} + TEXT + + cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n") + end + + private + + def action + event['action'] + end + + def identifiers + Array(event['identifiers']).tap do |ids| + # We always auto assign namespace if project is provided + ids.delete('namespace') if ids.include?('project') + end + end + + def additional_properties + Array(event['additional_properties']) + end + + def format_additional_properties + additional_properties.map do |property, details| + example_value = PROPERTY_EXAMPLES[property] + description = details['description'] || 'TODO' + + yield(property, example_value, description) + end + end + + def js_formatted_args(indent:) + return "('#{action}');" if additional_properties.none? + + property_args = format_additional_properties do |property, value, description| + " #{property}: #{value}, // #{description}" + end + + [ + '(', + " '#{action}',", + ' {', + *property_args, + ' },', + ');' + ].join("\n#{' ' * indent}") + end + + def service_ping_metrics_info + product_group = related_metrics.map(&:product_group).uniq + + <<~TEXT + #{product_group.map { |group| "#{group}: #{format_info(metric_exploration_group_path(group, find_stage(group)))}" }.join("\n")} + + #{divider} + #{format_help("# METRIC TRENDS -- view data for a service ping metric for #{event.action}")} + + #{related_metrics.map { |metric| "#{metric.key_path}: #{format_info(metric_trend_path(metric.key_path))}" }.join("\n")} + TEXT + end + + def service_ping_no_metric_info + <<~TEXT + #{format_help("# Warning: There are no metrics for #{event.action} yet.")} + #{event.product_group}: #{format_info(metric_exploration_group_path(event.product_group, find_stage(event.product_group)))} + TEXT + end + + def template_formatted_args(data_attr, indent:) + return " #{data_attr}=\"#{action}\">" if additional_properties.none? + + spacer = ' ' * indent + property_args = format_additional_properties do |property, value, _| + " data-event-#{property}=#{value.tr("'", '"')}" + end + + args = [ + '', # start args on next line + " #{data_attr}=\"#{action}\"", + *property_args + ].join("\n#{spacer}") + + "#{format_warning(args)}\n#{spacer}>" + end + + def related_metrics + cli.global.metrics.select { |metric| metric.actions&.include?(event.action) } + end + + def service_ping_dashboard_examples + cli.say <<~TEXT + #{divider} + #{format_help('# GROUP DASHBOARDS -- view all service ping metrics for a specific group')} + + #{related_metrics.any? ? service_ping_metrics_info : service_ping_no_metric_info} + #{divider} + Note: The metric dashboard links can also be accessed from #{format_info('https://metrics.gitlab.com/')} + + Not what you're looking for? Check this doc: + - #{format_info('https://docs.gitlab.com/ee/development/internal_analytics/#data-discovery')} + + TEXT + end + + def gdk_examples + key_paths = related_metrics.map(&:key_path) + + cli.say <<~TEXT + #{divider} + #{format_help('# TERMINAL -- monitor events & changes to service ping metrics as they occur')} + + 1. From `gitlab/` directory, run the monitor script: + + #{format_warning("bin/rails runner scripts/internal_events/monitor.rb #{event.action}")} + + 2. View metric updates within the terminal + + 3. [Optional] Configure gdk with snowplow micro to see individual events: https://gitlab.com/gitlab-org/gitlab-development-kit/-/blob/main/doc/howto/snowplow_micro.md + + #{divider} + #{format_help('# RAILS CONSOLE -- generate service ping payload, including most recent usage data')} + + #{format_warning("require_relative 'spec/support/helpers/service_ping_helpers.rb'")} + + #{format_help('# Get current value of a metric')} + #{ + if key_paths.any? + key_paths.map { |key_path| format_warning("ServicePingHelpers.get_current_usage_metric_value('#{key_path}')") }.join("\n") + else + format_help("# Warning: There are no metrics for #{event.action} yet. When there are, replace below.\n") + + format_warning('ServicePingHelpers.get_current_usage_metric_value()') + end + } + + #{format_help('# View entire service ping payload')} + #{format_warning('ServicePingHelpers.get_current_service_ping_payload')} + #{divider} + Need to test something else? Check these docs: + - https://docs.gitlab.com/ee/development/internal_analytics/internal_event_instrumentation/local_setup_and_debugging.html + - https://docs.gitlab.com/ee/development/internal_analytics/service_ping/troubleshooting.html + - https://docs.gitlab.com/ee/development/internal_analytics/review_guidelines.html + + TEXT + end + end + end +end diff --git a/scripts/internal_events/cli/metric_definer.rb b/scripts/internal_events/cli/metric_definer.rb deleted file mode 100755 index f8e9d3f090b..00000000000 --- a/scripts/internal_events/cli/metric_definer.rb +++ /dev/null @@ -1,568 +0,0 @@ -# frozen_string_literal: true - -require_relative './helpers' -require_relative './text' - -module InternalEventsCli - class MetricDefiner - include Helpers - - SCHEMA = ::JSONSchemer.schema(Pathname('config/metrics/schema/base.json')) - STEPS = [ - 'New Metric', - 'Type', - 'Events', - 'Scope', - 'Descriptions', - 'Copy event', - 'Group', - 'URL', - 'Tiers', - 'Save files' - ].freeze - - NAME_REQUIREMENT_REASONS = { - filters: { - text: 'Metrics using filters are too complex for default naming.', - help: Text::METRIC_NAME_FILTER_HELP - }, - length: { - text: 'The default filename will be too long.', - help: Text::METRIC_NAME_LENGTH_HELP - }, - conflict: { - text: 'The default key path is already in use.', - help: Text::METRIC_NAME_CONFLICT_HELP - } - }.freeze - - attr_reader :cli - - def initialize(cli, starting_event = nil) - @cli = cli - @selected_event_paths = Array(starting_event) - @metrics = [] - @selected_filters = {} - end - - def run - type = prompt_for_metric_type - prompt_for_events(type) - - return unless @selected_event_paths.any? - - prompt_for_metrics - prompt_for_event_filters - - return unless @metrics.any? - - prompt_for_descriptions - defaults = prompt_for_copying_event_properties - prompt_for_product_group(defaults) - prompt_for_url(defaults) - prompt_for_tier(defaults) - outcomes = create_metric_files - prompt_for_next_steps(outcomes) - end - - private - - # ----- Memoization Helpers ----------------- - - def events - @events ||= events_by_filepath(@selected_event_paths) - end - - def selected_events - @selected_events ||= events.values_at(*@selected_event_paths) - end - - # ----- Prompts ----------------------------- - - def prompt_for_metric_type - return if @selected_event_paths.any? - - new_page!(1, 9, STEPS) - - cli.select("Which best describes what the metric should track?", **select_opts) do |menu| - menu.enum "." - - menu.choice 'Single event -- count occurrences of a specific event or user interaction', :event_metric - menu.choice 'Multiple events -- count occurrences of several separate events or interactions', :aggregate_metric - menu.choice 'Database -- record value of a particular field or count of database rows', :database_metric - end - end - - def prompt_for_events(type) - return if @selected_event_paths.any? - - new_page!(2, 9, STEPS) - - case type - when :event_metric - cli.say "For robust event search, use the Metrics Dictionary: https://metrics.gitlab.com/snowplow\n\n" - - @selected_event_paths = [cli.select( - 'Which event does this metric track?', - get_event_options(events), - **select_opts, - **filter_opts(header_size: 7) - )] - when :aggregate_metric - cli.say "For robust event search, use the Metrics Dictionary: https://metrics.gitlab.com/snowplow\n\n" - - @selected_event_paths = cli.multi_select( - 'Which events does this metric track? (Space to select)', - get_event_options(events), - **multiselect_opts, - **filter_opts(header_size: 7) - ) - when :database_metric - cli.error Text::DATABASE_METRIC_NOTICE - cli.say Text::FEEDBACK_NOTICE - end - end - - def prompt_for_metrics - eligible_metrics = get_metric_options(selected_events) - - if eligible_metrics.all? { |metric| metric[:disabled] } - cli.error Text::ALL_METRICS_EXIST_NOTICE - cli.say Text::FEEDBACK_NOTICE - - return - end - - new_page!(3, 9, STEPS) - cli.say format_info('SELECTED EVENTS') - cli.say selected_events_filter_options.join - cli.say "\n" - - @metrics = cli.select( - 'Which metrics do you want to add?', - eligible_metrics, - **select_opts, - **filter_opts, - per_page: 20, - &disabled_format_callback - ) - - assign_shared_attrs(:actions, :milestone) do - { - actions: selected_events.map(&:action).sort, - milestone: MILESTONE - } - end - end - - def prompt_for_event_filters - return if @metrics.none?(&:filters_expected?) - - selected_unique_identifier = @metrics.first.identifier.value - event_count = selected_events.length - previous_inputs = { - 'label' => nil, - 'property' => nil, - 'value' => nil - } - - event_filters = selected_events.dup.flat_map.with_index do |event, idx| - print_event_filter_header(event, idx, event_count) - - next if deselect_nonfilterable_event?(event) # prompts user - - filter_values = event.additional_properties&.filter_map do |property, _| - next if selected_unique_identifier == property - - prompt_for_property_filter( - event.action, - property, - previous_inputs[property] - ) - end - - previous_inputs.merge!(@selected_filters[event.action] || {}) - - find_filter_permutations(event.action, filter_values) - end.compact - - bulk_assign(filters: event_filters) - end - - def prompt_for_descriptions - default_description = nil - default_key = nil - - separate_page_per_metric = @metrics.any? do |metric| - name_requirement_reason(metric) - end - - @metrics.each_with_index do |metric, idx| - if idx == 0 || separate_page_per_metric - new_page!(4, 9, STEPS) - - cli.say Text::METRIC_DESCRIPTION_INTRO - cli.say selected_event_descriptions.join - end - - cli.say "\n" - cli.say format_prompt(format_subheader( - 'DESCRIBING METRIC', - metric.technical_description, - idx, - @metrics.length - )) - - prompt_for_description(metric, default_description).tap do |description| - default_description = description - metric.description = "#{metric.description_prefix} #{description}" - end - - prompt_for_metric_name(metric, default_key)&.tap do |key| - default_key = key - metric.key = key - end - end - end - - def file_saved_context_message(attributes) - format_prefix " ", <<~TEXT.chomp - - Visit #{format_info('https://metrics.gitlab.com')} to find dashboard links for this metric - - Metric trend dashboard: #{format_info(metric_trend_path(attributes['key_path']))} - TEXT - end - - # Check existing event files for attributes to copy over - def prompt_for_copying_event_properties - shared_values = collect_values_for_shared_event_properties - defaults = shared_values.except(:stage, :section) - - return {} if shared_values.none? - - return shared_values if defaults.none? - - new_page!(5, 9, STEPS) - - cli.say <<~TEXT - #{format_info('Convenient! We can copy these attributes from the event definition(s):')} - - #{defaults.compact.transform_keys(&:to_s).to_yaml(line_width: 150)} - #{format_info('If any of these attributes are incorrect, you can also change them manually from your text editor later.')} - - TEXT - - cli.select('What would you like to do?', **select_opts) do |menu| - menu.enum '.' - menu.choice 'Copy & continue', -> { bulk_assign(defaults) } - menu.choice 'Modify attributes' - end - - shared_values - end - - def prompt_for_product_group(defaults) - assign_shared_attr(:product_group) do - new_page!(6, 9, STEPS) - - prompt_for_group_ownership('Which group owns the metric?', defaults) - end - end - - def prompt_for_url(defaults) - assign_shared_attr(:introduced_by_url) do - new_page!(7, 9, STEPS) - - prompt_for_text( - 'Which MR URL introduced the metric?', - defaults[:introduced_by_url] - ) - end - end - - def prompt_for_tier(defaults) - assign_shared_attr(:tier) do - new_page!(8, 9, STEPS) - - prompt_for_array_selection( - 'Which tiers will the metric be reported from?', - [%w[free premium ultimate], %w[premium ultimate], %w[ultimate]], - defaults[:tier] - ) - end - - assign_shared_attr(:tiers) { |metric| [*metric.tier] } - - assign_shared_attr(:distribution) do |metric| - metric.tier.include?('free') ? %w[ce ee] : %w[ee] - end - end - - def create_metric_files - @metrics.map.with_index do |metric, idx| - new_page!(9, 9, STEPS) # Repeat the same step number but increment metric counter - - cli.say format_prompt(format_subheader('SAVING FILE', metric.description, idx, @metrics.length)) - cli.say "\n" - - prompt_to_save_file(metric.file_path, metric.formatted_output) - end - end - - def prompt_for_next_steps(outcomes = []) - new_page! - - outcome = outcomes.any? ? outcomes.compact.join("\n") : ' No files saved.' - metric = @metrics.first - - cli.say <<~TEXT - #{divider} - #{format_info('Done with metric definitions!')} - - #{outcome} - #{divider} - - Have you instrumented the application code to trigger the event yet? View usage examples to easily copy/paste implementation! - - Want to verify the metrics? Check out the group::#{metric[:product_group]} Metrics Exploration Dashboard in Tableau - Note: The Metrics Exploration Dashboard data would be available ~1 week after deploy for Gitlab.com, ~1 week after next release for self-managed - Link: #{format_info(metric_exploration_group_path(metric[:product_group], find_stage(metric.product_group)))} - - Typical flow: Define event > Define metric > Instrument app code > Merge/Deploy MR > Verify data in Tableau/Snowflake - - TEXT - - actions = selected_events.map(&:action).join(', ') - next_step = cli.select("How would you like to proceed?", **select_opts) do |menu| - menu.enum "." - - menu.choice "New Event -- define a new event", :new_event - menu.choice "New Metric -- define another metric for #{actions}", :new_metric_with_events - menu.choice "New Metric -- define another metric", :new_metric - choice = "View Usage -- look at code examples for event #{selected_events.first.action}" - menu.default choice - menu.choice choice, :view_usage - menu.choice 'Exit', :exit - end - - case next_step - when :new_event - InternalEventsCli::EventDefiner.new(cli).run - when :new_metric_with_events - MetricDefiner.new(cli, @selected_event_paths).run - when :new_metric - MetricDefiner.new(cli).run - when :view_usage - UsageViewer.new(cli, @selected_event_paths.first, selected_events.first).run - when :exit - cli.say Text::FEEDBACK_NOTICE - end - end - - # ----- Prompt-specific Helpers ------------- - - # Helper for #prompt_for_metrics - def selected_events_filter_options - filterable_events_selected = selected_events.any? { |event| event.additional_properties&.any? } - - selected_events.map do |event| - filters = event.additional_properties&.keys - filter_phrase = if filters - " (filterable by #{filters&.join(', ')})" - elsif filterable_events_selected - ' -- not filterable' - end - - " - #{event.action}#{format_help(filter_phrase)}\n" - end - end - - # Helper for #prompt_for_event_filters - def print_event_filter_header(event, idx, total) - cli.say "\n" - cli.say format_info(format_subheader('SETTING EVENT FILTERS', event.action, idx, total)) - - return unless event.additional_properties&.any? - - event_filter_options = event.additional_properties.map do |property, attrs| - " #{property}: #{attrs['description']}\n" - end - - cli.say event_filter_options.join - end - - # Helper for #prompt_for_event_filters - def deselect_nonfilterable_event?(event) - cli.say "\n" - - return false if event.additional_properties&.any? - return false if cli.yes?("This event is not filterable. Should it be included in the metric?", **yes_no_opts) - - selected_events.delete(event) - bulk_assign(actions: selected_events.map(&:action).sort) - - true - end - - # Helper for #prompt_for_event_filters - def prompt_for_property_filter(action, property, default) - formatted_prop = format_info(property) - prompt = "Count where #{formatted_prop} equals any of (comma-sep):" - - inputs = prompt_for_text(prompt, default, **input_opts) do |q| - if property == 'value' - q.convert ->(input) { input.split(',').map(&:to_i).uniq } - q.validate %r{^(\d|\s|,)*$} - q.messages[:valid?] = "Inputs for #{formatted_prop} must be numeric" - else - q.convert ->(input) { input.split(',').map(&:strip).uniq } - end - end - - return unless inputs&.any? - - @selected_filters[action] ||= {} - @selected_filters[action][property] = inputs.join(',') - - inputs.map { |input| { property => input } }.uniq - end - - # Helper for #prompt_for_event_filters - # - # Gets all the permutations of the provided property values. - # @param filters [Array] ex) [{ 'label' => 'red' }, { 'label' => 'blue' }, { value => 16 }] - # @return ex) [{ 'label' => 'red', value => 16 }, { 'label' => 'blue', value => 16 }] - def find_filter_permutations(action, filters) - # Define a filter for all events, regardless of the available props so NewMetric#events is correct - return [[action, {}]] unless filters&.any? - - # Uses proc syntax to avoid spliting & type-checking `filters` - :product.to_proc.call(*filters).map do |filter| - [action, filter.reduce(&:merge)] - end - end - - # Helper for #prompt_for_descriptions - def selected_event_descriptions - selected_events.map do |event| - filters = @selected_filters[event.action] - - if filters&.any? - filter_phrase = filters.map { |k, v| "#{k}=#{v}" }.join(' ') - filter_phrase = format_help("(#{filter_phrase})") - end - - " #{event.action}#{filter_phrase} - #{format_selection(event.description)}\n" - end - end - - # Helper for #prompt_for_descriptions - def prompt_for_description(metric, default) - description_start = format_info("#{metric.description_prefix}...") - - cli.say <<~TEXT - - #{input_opts[:prefix]} How would you describe this metric to a non-technical person? #{input_required_text} - - TEXT - - prompt_for_text(" Finish the description: #{description_start}", default, multiline: true) do |q| - q.required true - q.modify :trim - q.messages[:required?] = Text::METRIC_DESCRIPTION_HELP - end - end - - # Helper for #prompt_for_descriptions - def prompt_for_metric_name(metric, default) - name_reason = name_requirement_reason(metric) - default_name = metric.key.value - display_name = metric.key.value("\e[0m[REPLACE ME]\e[36m") - empty_name = metric.key.value('') - - return unless name_reason - - cli.say <<~TEXT - - #{input_opts[:prefix]} #{name_reason[:text]} How should we refererence this metric? #{input_required_text} - - ID: #{format_info(display_name)} - Filename: #{format_info(display_name)}#{format_info('.yml')} - - TEXT - - max_length = MAX_FILENAME_LENGTH - "#{empty_name}.yml".length - help_tokens = { name: default_name, count: max_length } - - prompt_for_text(' Replace with: ', default, multiline: true) do |q| - q.required true - q.messages[:required?] = name_reason[:help] % help_tokens - q.messages[:valid?] = Text::METRIC_NAME_ERROR % help_tokens - q.validate ->(input) do - input.length <= max_length && - input.match?(NAME_REGEX) && - !conflicting_key_path?(metric.key.value(input)) - end - end - end - - # Helper for #prompt_for_descriptions - def name_requirement_reason(metric) - if metric.filters.assigned? - NAME_REQUIREMENT_REASONS[:filters] - elsif metric.file_name.length > MAX_FILENAME_LENGTH - NAME_REQUIREMENT_REASONS[:length] - elsif conflicting_key_path?(metric.key_path) - NAME_REQUIREMENT_REASONS[:conflict] - end - end - - # Helper for #prompt_for_descriptions - def conflicting_key_path?(key_path) - cli.global.metrics.any? do |existing_metric| - existing_metric.key_path == key_path - end - end - - # Helper for #prompt_for_copying_event_properties - def collect_values_for_shared_event_properties - fields = Hash.new { |h, k| h[k] = [] } - - selected_events.each do |event| - fields[:introduced_by_url] << event.introduced_by_url - fields[:product_group] << event.product_group - fields[:stage] << find_stage(event.product_group) - fields[:section] << find_section(event.product_group) - fields[:distribution] << event.distributions&.sort - fields[:tier] << event.tiers&.sort - fields[:tiers] << event.tiers&.sort - end - - # Keep event values if every selected event is the same - fields.each_with_object({}) do |(attr, values), defaults| - next unless values.compact.uniq.length == 1 - - defaults[attr] ||= values.first - end - end - - # ----- Shared Helpers ---------------------- - - def assign_shared_attrs(...) - metric = @metrics.first - attrs = metric.to_h.slice(...) - attrs = yield(metric) unless attrs.values.all? - - bulk_assign(attrs) - end - - def assign_shared_attr(key) - assign_shared_attrs(key) do |metric| - { key => yield(metric) } - end - end - - def bulk_assign(attrs) - @metrics.each { |metric| metric.bulk_assign(attrs) } - end - end -end diff --git a/scripts/internal_events/cli/usage_viewer.rb b/scripts/internal_events/cli/usage_viewer.rb deleted file mode 100755 index d91fa6f3c4e..00000000000 --- a/scripts/internal_events/cli/usage_viewer.rb +++ /dev/null @@ -1,434 +0,0 @@ -# frozen_string_literal: true - -require_relative './helpers' - -module InternalEventsCli - class UsageViewer - include Helpers - - PROPERTY_EXAMPLES = { - 'label' => "'string'", - 'property' => "'string'", - 'value' => '72', - 'custom_key' => 'custom_value' - }.freeze - - attr_reader :cli, :event - - def initialize(cli, event_path = nil, event = nil) - @cli = cli - @event = event - @selected_event_path = event_path - end - - def run - prompt_for_eligible_event - prompt_for_usage_location - end - - def prompt_for_eligible_event - return if event - - event_details = events_by_filepath - - @selected_event_path = cli.select( - "Show examples for which event?", - get_event_options(event_details), - **select_opts, - **filter_opts - ) - - @event = event_details[@selected_event_path] - end - - def prompt_for_usage_location(default = '1. ruby/rails') - choices = [ - { name: '1. ruby/rails', value: :rails }, - { name: '2. rspec', value: :rspec }, - { name: '3. javascript (vue)', value: :vue }, - { name: '4. javascript (plain)', value: :js }, - { name: '5. vue template', value: :vue_template }, - { name: '6. haml', value: :haml }, - { name: '7. Manual testing in GDK', value: :gdk }, - { name: '8. Data verification in Tableau', value: :tableau }, - { name: '9. View examples for a different event', value: :other_event }, - { name: '10. Exit', value: :exit } - ] - - usage_location = cli.select( - 'Select a use-case to view examples for:', - choices, - **select_opts, - **filter_opts, - per_page: 10 - ) do |menu| - menu.default default - end - - case usage_location - when :rails - rails_examples - prompt_for_usage_location('1. ruby/rails') - when :rspec - rspec_examples - prompt_for_usage_location('2. rspec') - when :haml - haml_examples - prompt_for_usage_location('6. haml') - when :js - js_examples - prompt_for_usage_location('4. javascript (plain)') - when :vue - vue_examples - prompt_for_usage_location('3. javascript (vue)') - when :vue_template - vue_template_examples - prompt_for_usage_location('5. vue template') - when :gdk - gdk_examples - prompt_for_usage_location('7. Manual testing in GDK') - when :tableau - service_ping_dashboard_examples - prompt_for_usage_location('8. Data verification in Tableau') - when :other_event - self.class.new(cli).run - when :exit - cli.say(Text::FEEDBACK_NOTICE) - end - end - - def rails_examples - identifier_args = identifiers.map do |identifier| - " #{identifier}: #{identifier}" - end - - property_args = format_additional_properties do |property, value, description| - " #{property}: #{value}, # #{description}" - end - - if property_args.any? - # remove trailing comma after last arg but keep any other commas - property_args.last.sub!(',', '') - property_arg = " additional_properties: {\n#{property_args.join("\n")}\n }" - end - - args = ["'#{action}'", *identifier_args, property_arg].compact.join(",\n") - args = "\n #{args}\n" if args.lines.count > 1 - - cli.say format_warning <<~TEXT - #{divider} - #{format_help('# RAILS')} - - include Gitlab::InternalEventsTracking - - track_internal_event(#{args}) - - #{divider} - TEXT - end - - def rspec_examples - identifier_args = identifiers.map do |identifier| - " let(:#{identifier}) { create(:#{identifier}) }\n" - end.join('') - - property_args = format_additional_properties do |property, value| - " #{property}: #{value}" - end - - if property_args.any? - property_arg = format_prefix ' ', <<~TEXT - let(:additional_properties) do - { - #{property_args.join(",\n")} - } - end - TEXT - end - - args = [*identifier_args, *property_arg].join('') - - cli.say format_warning <<~TEXT - #{divider} - #{format_help('# RSPEC')} - - it_behaves_like 'internal event tracking' do - let(:event) { '#{action}' } - #{args}end - - #{divider} - TEXT - end - - def haml_examples - property_args = format_additional_properties do |property, value, _| - "event_#{property}: #{value}" - end - - args = ["event_tracking: '#{action}'", *property_args].join(', ') - - cli.say <<~TEXT - #{divider} - #{format_help('# HAML -- ON-CLICK')} - - .inline-block{ #{format_warning("data: { #{args} }")} } - = _('Important Text') - - #{divider} - #{format_help('# HAML -- COMPONENT ON-CLICK')} - - = render Pajamas::ButtonComponent.new(button_options: { #{format_warning("data: { #{args} }")} }) - - #{divider} - #{format_help('# HAML -- COMPONENT ON-LOAD')} - - = render Pajamas::ButtonComponent.new(button_options: { #{format_warning("data: { event_tracking_load: true, #{args} }")} }) - - #{divider} - TEXT - - cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n") - end - - def vue_template_examples - on_click_args = template_formatted_args('data-event-tracking', indent: 2) - on_load_args = template_formatted_args('data-event-tracking-load', indent: 2) - - cli.say <<~TEXT - #{divider} - #{format_help('// VUE TEMPLATE -- ON-CLICK')} - - - - - - #{divider} - #{format_help('// VUE TEMPLATE -- ON-LOAD')} - - - - - - #{divider} - TEXT - - cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n") - end - - def js_examples - args = js_formatted_args(indent: 2) - - cli.say <<~TEXT - #{divider} - #{format_help('// FRONTEND -- RAW JAVASCRIPT')} - - #{format_warning("import { InternalEvents } from '~/tracking';")} - - export const performAction = () => { - #{format_warning("InternalEvents.trackEvent#{args}")} - - return true; - }; - - #{divider} - TEXT - - # https://docs.snowplow.io/docs/understanding-your-pipeline/schemas/ - cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n") - end - - def vue_examples - args = js_formatted_args(indent: 6) - - cli.say <<~TEXT - #{divider} - #{format_help('// VUE')} - - - - - - #{divider} - TEXT - - cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n") - end - - private - - def action - event['action'] - end - - def identifiers - Array(event['identifiers']).tap do |ids| - # We always auto assign namespace if project is provided - ids.delete('namespace') if ids.include?('project') - end - end - - def additional_properties - Array(event['additional_properties']) - end - - def format_additional_properties - additional_properties.map do |property, details| - example_value = PROPERTY_EXAMPLES[property] - description = details['description'] || 'TODO' - - yield(property, example_value, description) - end - end - - def js_formatted_args(indent:) - return "('#{action}');" if additional_properties.none? - - property_args = format_additional_properties do |property, value, description| - " #{property}: #{value}, // #{description}" - end - - [ - '(', - " '#{action}',", - ' {', - *property_args, - ' },', - ');' - ].join("\n#{' ' * indent}") - end - - def service_ping_metrics_info - product_group = related_metrics.map(&:product_group).uniq - - <<~TEXT - #{product_group.map { |group| "#{group}: #{format_info(metric_exploration_group_path(group, find_stage(group)))}" }.join("\n")} - - #{divider} - #{format_help("# METRIC TRENDS -- view data for a service ping metric for #{event.action}")} - - #{related_metrics.map { |metric| "#{metric.key_path}: #{format_info(metric_trend_path(metric.key_path))}" }.join("\n")} - TEXT - end - - def service_ping_no_metric_info - <<~TEXT - #{format_help("# Warning: There are no metrics for #{event.action} yet.")} - #{event.product_group}: #{format_info(metric_exploration_group_path(event.product_group, find_stage(event.product_group)))} - TEXT - end - - def template_formatted_args(data_attr, indent:) - return " #{data_attr}=\"#{action}\">" if additional_properties.none? - - spacer = ' ' * indent - property_args = format_additional_properties do |property, value, _| - " data-event-#{property}=#{value.tr("'", '"')}" - end - - args = [ - '', # start args on next line - " #{data_attr}=\"#{action}\"", - *property_args - ].join("\n#{spacer}") - - "#{format_warning(args)}\n#{spacer}>" - end - - def related_metrics - cli.global.metrics.select { |metric| metric.actions&.include?(event.action) } - end - - def service_ping_dashboard_examples - cli.say <<~TEXT - #{divider} - #{format_help('# GROUP DASHBOARDS -- view all service ping metrics for a specific group')} - - #{related_metrics.any? ? service_ping_metrics_info : service_ping_no_metric_info} - #{divider} - Note: The metric dashboard links can also be accessed from #{format_info('https://metrics.gitlab.com/')} - - Not what you're looking for? Check this doc: - - #{format_info('https://docs.gitlab.com/ee/development/internal_analytics/#data-discovery')} - - TEXT - end - - def gdk_examples - key_paths = related_metrics.map(&:key_path) - - cli.say <<~TEXT - #{divider} - #{format_help('# TERMINAL -- monitor events & changes to service ping metrics as they occur')} - - 1. From `gitlab/` directory, run the monitor script: - - #{format_warning("bin/rails runner scripts/internal_events/monitor.rb #{event.action}")} - - 2. View metric updates within the terminal - - 3. [Optional] Configure gdk with snowplow micro to see individual events: https://gitlab.com/gitlab-org/gitlab-development-kit/-/blob/main/doc/howto/snowplow_micro.md - - #{divider} - #{format_help('# RAILS CONSOLE -- generate service ping payload, including most recent usage data')} - - #{format_warning("require_relative 'spec/support/helpers/service_ping_helpers.rb'")} - - #{format_help('# Get current value of a metric')} - #{ - if key_paths.any? - key_paths.map { |key_path| format_warning("ServicePingHelpers.get_current_usage_metric_value('#{key_path}')") }.join("\n") - else - format_help("# Warning: There are no metrics for #{event.action} yet. When there are, replace below.\n") + - format_warning('ServicePingHelpers.get_current_usage_metric_value()') - end - } - - #{format_help('# View entire service ping payload')} - #{format_warning('ServicePingHelpers.get_current_service_ping_payload')} - #{divider} - Need to test something else? Check these docs: - - https://docs.gitlab.com/ee/development/internal_analytics/internal_event_instrumentation/local_setup_and_debugging.html - - https://docs.gitlab.com/ee/development/internal_analytics/service_ping/troubleshooting.html - - https://docs.gitlab.com/ee/development/internal_analytics/review_guidelines.html - - TEXT - end - end -end diff --git a/spec/lib/gitlab/ci/config/entry/kubernetes_spec.rb b/spec/lib/gitlab/ci/config/entry/kubernetes_spec.rb index 0ac8d01b8e4..9899502e8b7 100644 --- a/spec/lib/gitlab/ci/config/entry/kubernetes_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/kubernetes_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::Ci::Config::Entry::Kubernetes do +RSpec.describe Gitlab::Ci::Config::Entry::Kubernetes, feature_category: :kubernetes_management do let(:config) { Hash(namespace: 'namespace') } subject { described_class.new(config) } @@ -31,6 +31,12 @@ RSpec.describe Gitlab::Ci::Config::Entry::Kubernetes do it { is_expected.not_to be_valid } end + + context 'is empty' do + let(:config) { {} } + + it { is_expected.not_to be_valid } + end end describe 'namespace' do @@ -47,9 +53,19 @@ RSpec.describe Gitlab::Ci::Config::Entry::Kubernetes do it { is_expected.not_to be_valid } end + end - context 'is not present' do - let(:namespace) { '' } + describe 'agent' do + let(:config) { Hash(agent: agent) } + + context 'is a string' do + let(:agent) { 'path/to/project:example-agent' } + + it { is_expected.to be_valid } + end + + context 'is a hash' do + let(:agent) { { key: 'agent' } } it { is_expected.not_to be_valid } end diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index ebc45685d9a..212a3c3224a 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -1830,10 +1830,22 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category: %w[succeed! drop! cancel! skip!].each do |action| context "when the pipeline received #{action} event" do it 'performs AutoMergeProcessWorker' do - expect(AutoMergeProcessWorker).to receive(:perform_async).with(merge_request.id) + expect(AutoMergeProcessWorker).to receive(:perform_async).with({ 'pipeline_id' => pipeline.id }) pipeline.public_send(action) end + + context 'with auto_merge_process_worker_pipeline disabled' do + before do + stub_feature_flags(auto_merge_process_worker_pipeline: false) + end + + it 'performs AutoMergeProcessWorker by passing only the merge request' do + expect(AutoMergeProcessWorker).to receive(:perform_async).with(merge_request.id) + + pipeline.succeed! + end + end end end end @@ -1841,8 +1853,11 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category: context 'when auto merge is not enabled in the merge request' do let(:merge_request) { create(:merge_request) } + # We enqueue the job here because the check for whether or not to + # automatically merge happens in the worker itself. + # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/167095 it 'performs AutoMergeProcessWorker' do - expect(AutoMergeProcessWorker).not_to receive(:perform_async) + expect(AutoMergeProcessWorker).to receive(:perform_async).with({ 'pipeline_id' => pipeline.id }) pipeline.succeed! end diff --git a/spec/services/auto_merge/base_service_spec.rb b/spec/services/auto_merge/base_service_spec.rb index 0f4fed72fdc..8022f81f290 100644 --- a/spec/services/auto_merge/base_service_spec.rb +++ b/spec/services/auto_merge/base_service_spec.rb @@ -76,7 +76,7 @@ RSpec.describe AutoMerge::BaseService, feature_category: :code_review_workflow d end it 'calls AutoMergeProcessWorker' do - expect(AutoMergeProcessWorker).to receive(:perform_async).with(merge_request.id).once + expect(AutoMergeProcessWorker).to receive(:perform_async).with({ 'merge_request_id' => merge_request.id }).once subject end diff --git a/spec/services/deployments/update_environment_service_spec.rb b/spec/services/deployments/update_environment_service_spec.rb index bc6e244dc2f..97dd7431b53 100644 --- a/spec/services/deployments/update_environment_service_spec.rb +++ b/spec/services/deployments/update_environment_service_spec.rb @@ -3,14 +3,14 @@ require 'spec_helper' RSpec.describe Deployments::UpdateEnvironmentService, feature_category: :continuous_delivery do - let(:user) { create(:user) } - let(:project) { create(:project, :repository) } - let(:options) { { name: environment_name } } - let(:pipeline) do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :repository, developers: [user]) } + let_it_be(:pipeline) do create( :ci_pipeline, sha: 'b83d6e391c22777fca1ed3012fce84f633d7fed0', - project: project + project: project, + user: user ) end @@ -22,12 +22,14 @@ RSpec.describe Deployments::UpdateEnvironmentService, feature_category: :continu tag: false, environment: environment_name, options: { environment: options }, + user: user, project: project) end let(:deployment) { job.deployment } let(:environment) { deployment.environment } let(:environment_name) { 'production' } + let(:options) { { name: environment_name } } subject(:service) { described_class.new(deployment) } @@ -265,6 +267,58 @@ RSpec.describe Deployments::UpdateEnvironmentService, feature_category: :continu .to change { environment.reset.tier }.from(nil).to('other') end end + + context 'when cluster agent is specified' do + let(:agent) { create(:cluster_agent, project: project) } + + let(:options) { { name: environment_name, kubernetes: { agent: agent_path } } } + + context 'when the agent does not exist' do + let(:agent_path) { "#{project.full_path}:non-existent-agent" } + + it 'does not assign a cluster agent' do + expect { subject.execute }.not_to change { environment.cluster_agent } + end + end + + context 'when the agent exists' do + let(:agent_path) { "#{project.full_path}:#{agent.name}" } + + context 'and the user no longer exists' do + before do + job.update!(user: nil) + end + + it 'does not assign a cluster agent' do + expect { subject.execute }.not_to change { environment.cluster_agent } + end + end + + context 'and the user is not authorized' do + it 'does not assign a cluster agent' do + expect { subject.execute }.not_to change { environment.cluster_agent } + end + end + + context 'and the user is authorized' do + before do + agent.user_access_project_authorizations.create!(project: project, config: {}) + end + + it 'assigns the cluster agent to the environment' do + expect { subject.execute }.to change { environment.cluster_agent }.from(nil).to(agent) + end + + context 'when the agent path contains variables' do + let(:agent_path) { "$CI_PROJECT_PATH:#{agent.name}" } + + it 'expands variables and assigns the cluster agent to the environment' do + expect { subject.execute }.to change { environment.cluster_agent }.from(nil).to(agent) + end + end + end + end + end end describe '#expanded_environment_url' do diff --git a/spec/workers/auto_merge_process_worker_spec.rb b/spec/workers/auto_merge_process_worker_spec.rb index f208f162ee3..7d1c09182cc 100644 --- a/spec/workers/auto_merge_process_worker_spec.rb +++ b/spec/workers/auto_merge_process_worker_spec.rb @@ -20,7 +20,17 @@ RSpec.describe AutoMergeProcessWorker, feature_category: :continuous_delivery do end end - context 'when a pipeline is passed with auto mergeable MRs' do + context 'when merge request is not found' do + let(:args) { { 'merge_request_id' => -1 } } + + it 'does not execute AutoMergeService' do + expect(AutoMergeService).not_to receive(:new) + + subject + end + end + + context 'when a pipeline is passed with auto mergeable MRs', :aggregate_failures do let(:merge_service) { instance_double(AutoMergeService, process: true) } let(:mwps_merge_request) { create(:merge_request, :with_head_pipeline, :merge_when_pipeline_succeeds) } let(:mwcp_merge_request) { create(:merge_request, :with_head_pipeline, :merge_when_checks_pass) } @@ -43,8 +53,8 @@ RSpec.describe AutoMergeProcessWorker, feature_category: :continuous_delivery do end end - context 'when merge request is not found' do - let(:args) { { 'merge_request_id' => -1 } } + context 'when pipeline is not found' do + let(:args) { { 'pipeline_id' => -1 } } it 'does not execute AutoMergeService' do expect(AutoMergeService).not_to receive(:new) @@ -65,6 +75,7 @@ RSpec.describe AutoMergeProcessWorker, feature_category: :continuous_delivery do # Integer args are deprecated as of 17.5. IDs should be passed # as a hash with merge_request_id and pipeline_id keys. + # https://gitlab.com/gitlab-org/gitlab/-/issues/497247 context 'with integer args' do let(:args) { merge_request.id }