Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2024-03-08 00:09:42 +00:00
parent 824dc86379
commit 2b4a1d3772
31 changed files with 987 additions and 244 deletions

View File

@ -0,0 +1,89 @@
<script>
import { GlFormGroup, GlFormInput } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
name: 'GoogleCloudField',
components: {
GlFormGroup,
GlFormInput,
},
model: {
prop: 'value',
event: 'change',
},
props: {
value: {
type: Object,
required: false,
default: () => ({ value: '', state: null }),
validator: ({ value }) => typeof value === 'string',
},
invalidFeedbackIfEmpty: {
type: String,
required: false,
default: __('This field is required.'),
},
invalidFeedbackIfMalformed: {
type: String,
required: false,
default: __('This value is not valid.'),
},
regexp: {
type: RegExp,
required: false,
default: null,
},
name: {
type: String,
required: true,
},
label: {
type: String,
required: false,
default: null,
},
},
data() {
return {
state: null,
invalidFeedback: '',
};
},
methods: {
onChange(value) {
if (!value) {
this.state = false;
this.invalidFeedback = this.invalidFeedbackIfEmpty;
} else if (this.regexp && !value.match(this.regexp)) {
this.state = false;
this.invalidFeedback = this.invalidFeedbackIfMalformed;
} else {
this.state = true;
this.invalidFeedback = '';
}
this.$emit('change', { state: this.state, value });
},
},
};
</script>
<template>
<gl-form-group
:label-for="name"
:state="state"
:invalid-feedback="invalidFeedback"
:label="label"
>
<template v-for="slot in Object.keys($scopedSlots)" #[slot]>
<slot :name="slot"></slot>
</template>
<gl-form-input
:id="name"
:name="name"
:state="state"
:value="value ? value.value : ''"
type="text"
@change="onChange"
/>
</gl-form-group>
</template>

View File

@ -1,16 +1,7 @@
<script>
import {
GlAlert,
GlButton,
GlFormInput,
GlFormGroup,
GlLink,
GlIcon,
GlModal,
GlPopover,
GlSprintf,
} from '@gitlab/ui';
import { GlAlert, GlButton, GlLink, GlIcon, GlModal, GlPopover, GlSprintf } from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import GoogleCloudFieldGroup from '~/ci/runner/components/registration/google_cloud_field_group.vue';
import { createAlert } from '~/alert';
import { s__, __ } from '~/locale';
import { fetchPolicies } from '~/lib/graphql';
@ -19,6 +10,7 @@ import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants';
import runnerForRegistrationQuery from '../../graphql/register/runner_for_registration.query.graphql';
import provisionGoogleCloudRunnerGroup from '../../graphql/register/provision_google_cloud_runner_group.query.graphql';
import provisionGoogleCloudRunnerProject from '../../graphql/register/provision_google_cloud_runner_project.query.graphql';
import {
I18N_FETCH_ERROR,
STATUS_ONLINE,
@ -26,8 +18,17 @@ import {
} from '../../constants';
import { captureException } from '../../sentry_utils';
const GC_PROJECT_PATTERN = /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/; // https://cloud.google.com/resource-manager/reference/rest/v1/projects
const GC_REGION_PATTERN = /^[a-z]+-[a-z]+\d+$/;
const GC_ZONE_PATTERN = /^[a-z]+-[a-z]+\d+-[a-z]$/;
const GC_MACHINE_TYPE_PATTERN = /^[a-z]([-a-z0-9]*[a-z0-9])?$/;
export default {
name: 'GoogleCloudRegistrationInstructions',
GC_PROJECT_PATTERN,
GC_REGION_PATTERN,
GC_ZONE_PATTERN,
GC_MACHINE_TYPE_PATTERN,
i18n: {
heading: s__('Runners|Register runner'),
headingDescription: s__(
@ -122,10 +123,9 @@ export default {
},
components: {
ClipboardButton,
GoogleCloudFieldGroup,
GlAlert,
GlButton,
GlFormInput,
GlFormGroup,
GlIcon,
GlLink,
GlModal,
@ -150,19 +150,16 @@ export default {
},
data() {
return {
projectId: '',
region: '',
zone: '',
machineType: 'n2d-standard-2',
token: '',
runner: null,
showInstructionsModal: false,
showInstructionsButtonVariant: 'default',
validations: {
projectId: false,
region: false,
zone: false,
},
cloudProjectId: null,
region: null,
zone: null,
machineType: { state: true, value: 'n2d-standard-2' },
provisioningSteps: [],
setupBashScript: '',
showAlert: false,
@ -204,14 +201,12 @@ export default {
project: {
query: provisionGoogleCloudRunnerProject,
fetchPolicy: fetchPolicies.NETWORK_ONLY,
skip: true,
manual: true,
variables() {
return {
fullPath: this.projectPath,
cloudProjectId: this.projectId,
region: this.region,
zone: this.zone,
machineType: this.machineType,
runnerToken: this.token,
...this.variables,
};
},
result({ data, error }) {
@ -224,20 +219,16 @@ export default {
error(error) {
this.handleError(error);
},
skip() {
return !this.projectPath || this.invalidFields.length > 0;
},
},
group: {
query: provisionGoogleCloudRunnerGroup,
fetchPolicy: fetchPolicies.NETWORK_ONLY,
skip: true,
manual: true,
variables() {
return {
fullPath: this.groupPath,
cloudProjectId: this.projectId,
region: this.region,
zone: this.zone,
machineType: this.machineType,
runnerToken: this.token,
...this.variables,
};
},
result({ data, error }) {
@ -250,12 +241,18 @@ export default {
error(error) {
this.handleError(error);
},
skip() {
return !this.groupPath || this.invalidFields.length > 0;
},
},
},
computed: {
variables() {
return {
runnerToken: this.token,
cloudProjectId: this.cloudProjectId?.value,
region: this.region?.value,
zone: this.zone?.value,
machineType: this.machineType?.value,
};
},
isRunnerOnline() {
return this.runner?.status === STATUS_ONLINE;
},
@ -270,8 +267,8 @@ export default {
);
},
invalidFields() {
return Object.keys(this.validations).filter((field) => {
return this.validations[field] === false;
return ['cloudProjectId', 'region', 'zone', 'machineType'].filter((field) => {
return !this[field]?.state;
});
},
bashInstructions() {
@ -304,34 +301,19 @@ export default {
if (this.invalidFields.length > 0) {
this.showAlert = true;
} else {
if (this.projectPath) {
this.$apollo.queries.project.start();
} else {
this.$apollo.queries.group.start();
}
this.showAlert = false;
this.showInstructionsModal = true;
}
},
validateZone() {
if (this.zone.length > 0) {
this.validations.zone = true;
} else {
this.validations.zone = false;
}
},
validateRegion() {
if (this.region.length > 0) {
this.validations.region = true;
} else {
this.validations.region = false;
}
},
validateProjectId() {
if (this.projectId.length > 0) {
this.validations.projectId = true;
} else {
this.validations.projectId = false;
}
},
goToFirstInvalidField() {
if (this.invalidFields.length > 0) {
this.$refs[this.invalidFields[0]].$el.focus();
this.$refs[this.invalidFields[0]].$el.querySelector('input').focus();
}
},
handleError(error) {
@ -427,7 +409,20 @@ export default {
<h2 class="gl-heading-2">{{ $options.i18n.stepOneHeading }}</h2>
<p>{{ $options.i18n.stepOneDescription }}</p>
<gl-form-group :label="$options.i18n.projectIdLabel" label-for="project-id">
<google-cloud-field-group
ref="cloudProjectId"
v-model="cloudProjectId"
name="cloudProjectId"
:label="$options.i18n.projectIdLabel"
:invalid-feedback-if-empty="s__('Runners|Project ID is required.')"
:invalid-feedback-if-malformed="
s__(
'Runners|Project ID must be 6 to 30 lowercase letters, digits, or hyphens. It needs to start with a lowercase letter and end with a letter or number.',
)
"
:regexp="$options.GC_PROJECT_PATTERN"
data-testid="project-id-input"
>
<template #description>
<gl-sprintf :message="$options.i18n.projectIdDescription">
<template #link="{ content }">
@ -442,16 +437,19 @@ export default {
</template>
</gl-sprintf>
</template>
<gl-form-input
id="project-id"
ref="projectId"
v-model="projectId"
type="text"
data-testid="project-id-input"
@input="validateProjectId"
/>
</gl-form-group>
<gl-form-group label-for="region-id">
</google-cloud-field-group>
<google-cloud-field-group
ref="region"
v-model="region"
name="region"
:invalid-feedback-if-empty="s__('Runners|Region is required.')"
:invalid-feedback-if-malformed="
s__('Runners|Region must have the correct format. Example: us-central1')
"
:regexp="$options.GC_REGION_PATTERN"
data-testid="region-input"
>
<template #label>
<div>
{{ $options.i18n.regionLabel }}
@ -471,15 +469,19 @@ export default {
</gl-popover>
</div>
</template>
<gl-form-input
id="region-id"
ref="region"
v-model="region"
data-testid="region-input"
@input="validateRegion"
/>
</gl-form-group>
<gl-form-group label-for="zone-id">
</google-cloud-field-group>
<google-cloud-field-group
ref="zone"
v-model="zone"
name="zone"
:invalid-feedback-if-empty="s__('Runners|Zone is required.')"
:invalid-feedback-if-malformed="
s__('Runners|Zone must have the correct format. Example: us-central1-a')
"
:regexp="$options.GC_ZONE_PATTERN"
data-testid="zone-input"
>
<template #label>
<div>
{{ $options.i18n.zoneLabel }}
@ -505,15 +507,21 @@ export default {
<gl-icon name="external-link" :aria-label="$options.i18n.externalLink" />
</gl-link>
</template>
<gl-form-input
id="zone-id"
ref="zone"
v-model="zone"
data-testid="zone-input"
@input="validateZone"
/>
</gl-form-group>
<gl-form-group label-for="machine-type-id">
</google-cloud-field-group>
<google-cloud-field-group
ref="machineType"
v-model="machineType"
name="machineType"
:invalid-feedback-if-empty="s__('Runners|Machine type is required.')"
:invalid-feedback-if-malformed="
s__(
'Runners|Machine type must have the format `family-series-size`. Example: n2d-standard-2',
)
"
:regexp="$options.GC_MACHINE_TYPE_PATTERN"
data-testid="machine-type-input"
>
<template #label>
<div>
{{ $options.i18n.machineTypeLabel }}
@ -547,8 +555,7 @@ export default {
</template>
</gl-sprintf>
</template>
<gl-form-input id="machine-type-id" v-model="machineType" data-testid="machine-type-input" />
</gl-form-group>
</google-cloud-field-group>
<hr />
<!-- end: step one -->
@ -627,5 +634,11 @@ export default {
</gl-modal>
<hr />
<!-- end: step two -->
<section v-if="isRunnerOnline">
<h2 class="gl-heading-2">🎉 {{ s__("Runners|You've registered a new runner!") }}</h2>
<p>
{{ s__('Runners|Your runner is online and ready to run jobs.') }}
</p>
</section>
</div>
</template>

View File

@ -980,68 +980,76 @@ export default {
@select-issuable="handleSelectIssuable"
>
<template #nav-actions>
<local-storage-sync
v-if="gridViewFeatureEnabled"
:value="viewType"
:storage-key="$options.ISSUES_VIEW_TYPE_KEY"
@input="switchViewType"
>
<gl-button-group>
<gl-button
:variant="isGridView ? 'default' : 'confirm'"
data-testid="list-view-type"
@click="switchViewType($options.ISSUES_LIST_VIEW_KEY)"
>
{{ $options.i18n.listLabel }}
</gl-button>
<gl-button
:variant="isGridView ? 'confirm' : 'default'"
data-testid="grid-view-type"
@click="switchViewType($options.ISSUES_GRID_VIEW_KEY)"
>
{{ $options.i18n.gridLabel }}
</gl-button>
</gl-button-group>
</local-storage-sync>
<div class="gl-display-flex gl-gap-3">
<local-storage-sync
v-if="gridViewFeatureEnabled"
:value="viewType"
:storage-key="$options.ISSUES_VIEW_TYPE_KEY"
@input="switchViewType"
>
<gl-button-group>
<gl-button
:variant="isGridView ? 'default' : 'confirm'"
data-testid="list-view-type"
@click="switchViewType($options.ISSUES_LIST_VIEW_KEY)"
>
{{ $options.i18n.listLabel }}
</gl-button>
<gl-button
:variant="isGridView ? 'confirm' : 'default'"
data-testid="grid-view-type"
@click="switchViewType($options.ISSUES_GRID_VIEW_KEY)"
>
{{ $options.i18n.gridLabel }}
</gl-button>
</gl-button-group>
</local-storage-sync>
<gl-button
v-if="canBulkUpdate"
:disabled="isBulkEditButtonDisabled"
@click="handleBulkUpdateClick"
>
{{ $options.i18n.editIssues }}
</gl-button>
<slot name="new-issuable-button">
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
{{ $options.i18n.newIssueLabel }}
<gl-button
v-if="canBulkUpdate"
:disabled="isBulkEditButtonDisabled"
class="gl-flex-grow-1"
@click="handleBulkUpdateClick"
>
{{ $options.i18n.editIssues }}
</gl-button>
</slot>
<new-resource-dropdown
v-if="showNewIssueDropdown"
:query="$options.searchProjectsQuery"
:query-variables="newIssueDropdownQueryVariables"
:extract-projects="extractProjects"
:group-id="groupId"
/>
<gl-disclosure-dropdown
v-gl-tooltip.hover="$options.i18n.actionsLabel"
category="tertiary"
icon="ellipsis_v"
no-caret
:toggle-text="$options.i18n.actionsLabel"
text-sr-only
data-testid="issues-list-more-actions-dropdown"
>
<csv-import-export-buttons
v-if="showCsvButtons"
:export-csv-path="exportCsvPathWithQuery"
:issuable-count="currentTabCount"
<slot name="new-issuable-button">
<gl-button
v-if="showNewIssueLink"
:href="newIssuePath"
variant="confirm"
class="gl-flex-grow-1"
>
{{ $options.i18n.newIssueLabel }}
</gl-button>
</slot>
<new-resource-dropdown
v-if="showNewIssueDropdown"
:query="$options.searchProjectsQuery"
:query-variables="newIssueDropdownQueryVariables"
:extract-projects="extractProjects"
:group-id="groupId"
/>
<gl-disclosure-dropdown-group
:bordered="showCsvButtons"
:group="subscribeDropdownOptions"
/>
</gl-disclosure-dropdown>
<gl-disclosure-dropdown
v-gl-tooltip.hover="$options.i18n.actionsLabel"
category="tertiary"
icon="ellipsis_v"
no-caret
:toggle-text="$options.i18n.actionsLabel"
text-sr-only
data-testid="issues-list-more-actions-dropdown"
>
<csv-import-export-buttons
v-if="showCsvButtons"
:export-csv-path="exportCsvPathWithQuery"
:issuable-count="currentTabCount"
/>
<gl-disclosure-dropdown-group
:bordered="showCsvButtons"
:group="subscribeDropdownOptions"
/>
</gl-disclosure-dropdown>
</div>
</template>
<template #timeframe="{ issuable = {} }">

View File

@ -63,28 +63,30 @@ new Vue({
},
});
const {
graphEndpoint,
graphEndDate,
graphStartDate,
graphRef,
graphCsvPath,
} = codeCoverageContainer.dataset;
// eslint-disable-next-line no-new
new Vue({
el: codeCoverageContainer,
render(h) {
return h(CodeCoverage, {
props: {
graphEndpoint,
graphEndDate,
graphStartDate,
graphRef,
graphCsvPath,
},
});
},
});
if (codeCoverageContainer?.dataset) {
const {
graphEndpoint,
graphEndDate,
graphStartDate,
graphRef,
graphCsvPath,
} = codeCoverageContainer.dataset;
// eslint-disable-next-line no-new
new Vue({
el: codeCoverageContainer,
render(h) {
return h(CodeCoverage, {
props: {
graphEndpoint,
graphEndDate,
graphStartDate,
graphRef,
graphCsvPath,
},
});
},
});
}
// eslint-disable-next-line no-new
new Vue({

View File

@ -186,6 +186,8 @@ export default {
:toggle-text="$options.i18n.toggleButtonLabel"
variant="confirm"
data-testid="new-resource-dropdown"
class="gl-display-flex! gl-w-auto!"
toggle-class="gl-m-0! gl-w-auto! gl-flex-grow-0!"
@click="handleDropdownClick"
@shown="handleDropdownShown"
>

View File

@ -0,0 +1,8 @@
---
migration_job_name: BackfillCatalogResourceVersionSemVer
description: Backfills the semver columns of existing catalog resource versions.
feature_category: pipeline_composition
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/146688
milestone: '16.10'
queued_migration_version: 20240305182005
finalize_after: '2024-03-15'

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
class QueueBackfillCatalogResourceVersionSemVer < Gitlab::Database::Migration[2.2]
milestone '16.10'
restrict_gitlab_migration gitlab_schema: :gitlab_main
MIGRATION = 'BackfillCatalogResourceVersionSemVer'
DELAY_INTERVAL = 2.minutes
BATCH_SIZE = 500
SUB_BATCH_SIZE = 100
def up
queue_batched_background_migration(
MIGRATION,
:catalog_resource_versions,
:id,
job_interval: DELAY_INTERVAL,
batch_size: BATCH_SIZE,
sub_batch_size: SUB_BATCH_SIZE
)
end
def down
delete_batched_background_migration(MIGRATION, :catalog_resource_versions, :id, [])
end
end

View File

@ -0,0 +1 @@
a195ad9f1d08c0c54c2576c0e00c7f0877fa819594a2fb210bcccea67d3b1ea6

View File

@ -238,7 +238,7 @@ To get started, sign in to the [Azure Portal](https://portal.azure.com). For you
you need the following information:
- A tenant ID. You may already have one. For more information, see the
[Microsoft Azure Tenant](https://learn.microsoft.com/en-us/azure/active-directory/develop/quickstart-create-new-tenant) documentation.
[Microsoft Azure Tenant](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-create-new-tenant) documentation.
- A client ID and a client secret. Follow the instructions in the
[Microsoft Quickstart Register an Application](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app) documentation
to obtain the tenant ID, client ID, and client secret for your app.

View File

@ -60,7 +60,7 @@ Configure AWS Backup to back up S3 data. This can be done at the same time when
1. [Create Storage Transfer Service jobs](https://cloud.google.com/storage-transfer/docs/create-transfers) which copy each GitLab object storage bucket to a backup bucket. You can create these jobs once, and [schedule them to run daily](https://cloud.google.com/storage-transfer/docs/schedule-transfer-jobs). However this mixes new and old object storage data, so files that were deleted in GitLab will still exist in the backup. This wastes storage after restore, but it is otherwise not a problem. These files would be inaccessible to GitLab users since they do not exist in the GitLab database. You can delete [some of these orphaned files](../../raketasks/cleanup.md#clean-up-project-upload-files-from-object-storage) after restore, but this clean up Rake task only operates on a subset of files.
1. For `When to overwrite`, choose `Never`. GitLab object stored files are intended to be immutable. This selection could be helpful if a malicious actor succeeded at mutating GitLab files.
1. For `When to delete`, choose `Never`. If you sync the backup bucket to source, then you cannot recover if files are accidentally or maliciously deleted from source.
1. Alternatively, it is possible to backup object storage into buckets or subdirectories segregated by day. This avoids the problem of orphaned files after restore, and supports backup of file versions if needed. But it greatly increases backup storage costs. This can be done with [a Cloud Function triggered by Cloud Scheduler](https://cloud.google.com/scheduler/docs/tut-pub-sub), or with a script run by a cronjob. A partial example:
1. Alternatively, it is possible to backup object storage into buckets or subdirectories segregated by day. This avoids the problem of orphaned files after restore, and supports backup of file versions if needed. But it greatly increases backup storage costs. This can be done with [a Cloud Function triggered by Cloud Scheduler](https://cloud.google.com/scheduler/docs/tut-gcf-pub-sub), or with a script run by a cronjob. A partial example:
```shell
# Set GCP project so you don't have to specify it in every command

View File

@ -293,7 +293,7 @@ You can learn more about how to administer GitLab.
### Free GitLab training
- GitLab basics: Discover self-service guides on [Git and GitLab basics](../tutorials/index.md).
- GitLab Learn: Learn new GitLab skills in a structured course at [GitLab Learn](https://about.gitlab.com/learn/).
- GitLab University: Learn new GitLab skills in a structured course at [GitLab University](https://university.gitlab.com/learn/dashboard).
### Third-party training

View File

@ -1401,7 +1401,7 @@ Make sure everything continues to work as expected before replicating it in prod
### Docker Distribution Registry
The [Docker Distribution Registry](https://docs.docker.com/registry/) was donated to the CNCF
and is now known as the [Distribution Registry](https://distribution.github.io/distribution).
and is now known as the [Distribution Registry](https://distribution.github.io/distribution/).
This registry is the open source implementation that the GitLab container registry is based on.
The GitLab container registry is compatible with the basic functionality provided by the Distribution Registry,
including all the supported storage backends. To migrate to the GitLab container registry

View File

@ -166,7 +166,7 @@ a useless shell layer. However, that does not work for all Docker versions.
- For Docker 17.03 and earlier, the `entrypoint` can be set to
`/bin/sh -c`, `/bin/bash -c`, or an equivalent shell available in the image.
The syntax of `image:entrypoint` is similar to [Dockerfile `ENTRYPOINT`](https://docs.docker.com/engine/reference/builder/#entrypoint).
The syntax of `image:entrypoint` is similar to [Dockerfile `ENTRYPOINT`](https://docs.docker.com/reference/dockerfile/#entrypoint).
Let's assume you have a `super/sql:experimental` image with a SQL database
in it. You want to use it as a base image for your job because you

View File

@ -151,7 +151,7 @@ Automatic class path correction also works for a Java project with:
- A full path of `test-org/test-java-project`.
- The following files relative to the project root:
```shell
src/main/java/com/gitlab/security_products/tests/App.java
```
@ -300,7 +300,7 @@ run tests:
The following `.gitlab-ci.yml` example for PHP uses [PHPUnit](https://phpunit.readthedocs.io/)
to collect test coverage data and generate the report.
With a minimal [`phpunit.xml`](https://docs.phpunit.de/en/10.2/configuration.html) file (you may reference
With a minimal [`phpunit.xml`](https://docs.phpunit.de/en/11.0/configuration.html) file (you may reference
[this example repository](https://gitlab.com/yookoala/code-coverage-visualization-with-php/)), you can run the test and
generate the `coverage.xml`:

View File

@ -34,7 +34,7 @@ You can also integrate GitLab with the following security partners:
<!-- vale gitlab.Spelling = NO -->
- [Anchore](https://docs.anchore.com/current/docs/configuration/integration/ci_cd/gitlab/)
- [Anchore](https://docs.anchore.com/current/docs/integration/ci_cd/gitlab/)
- [Bridgecrew](https://docs.bridgecrew.io/docs/integrate-with-gitlab-self-managed)
- [Checkmarx](https://checkmarx.atlassian.net/wiki/spaces/SD/pages/1929937052/GitLab+Integration)
- [CodeSecure](https://codesecure.com/our-integrations/codesonar-sast-gitlab-ci-pipeline/)

View File

@ -63,7 +63,7 @@ Prerequisites:
- If your remote repository is on GitHub and you have
[two-factor authentication (2FA) configured](https://docs.github.com/en/authentication/securing-your-account-with-two-factor-authentication-2fa),
create a [personal access token for GitHub](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token)
create a [personal access token for GitHub](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)
with the `repo` scope. If 2FA is enabled, this personal access
token serves as your GitHub password.
- [GitLab Silent Mode](../../../../administration/silent_mode/index.md) is not enabled.

View File

@ -0,0 +1,66 @@
# frozen_string_literal: true
module Gitlab
module BackgroundMigration
class BackfillCatalogResourceVersionSemVer < BatchedMigrationJob
feature_category :pipeline_composition
scope_to ->(relation) { relation.where(semver_major: nil) }
operation_name :backfill_catalog_resource_versions_sem_var
def perform
each_sub_batch do |sub_batch|
with_release_tag(sub_batch).each do |version|
match_data = ::Gitlab::Regex.semver_regex.match(normalized_version_name(version.tag))
next unless match_data
version.update!(
semver_major: match_data[1].to_i,
semver_minor: match_data[2].to_i,
semver_patch: match_data[3].to_i,
semver_prerelease: match_data[4]
)
end
end
end
private
def with_release_tag(sub_batch)
sub_batch
.joins('INNER JOIN releases ON releases.id = catalog_resource_versions.release_id')
.select('catalog_resource_versions.*, releases.tag')
end
# Removes `v` prefix and converts partial or extended semver
# numbers into a normalized format. Examples:
#
# 1 => 1.0.0
# v1.2 => 1.2.0
# 1.2-alpha => 1.2.0-alpha
# 1.0+123 => 1.0.0+123
# 1.2.3.4 => 1.2.3
#
def normalized_version_name(name)
name = name.capitalize.delete_prefix('V')
first_part, *other_parts = name.split(/([-,+])/)
dot_count = first_part.count('.')
return name unless dot_count != 2 && /^[\d.]+$/.match?(first_part)
if dot_count < 2
add_zeroes = '.0' * (2 - dot_count)
[first_part, add_zeroes, *other_parts].join
else
# Assuming the format is 1.2.3.4, we only want to keep the first 3 digits.
truncated_first_part = first_part.split(/([.])/)[0..4]
[truncated_first_part, *other_parts].join
end
end
end
end
end

View File

@ -43271,6 +43271,12 @@ msgstr ""
msgid "Runners|Machine type"
msgstr ""
msgid "Runners|Machine type is required."
msgstr ""
msgid "Runners|Machine type must have the format `family-series-size`. Example: n2d-standard-2"
msgstr ""
msgid "Runners|Machine type with preset amounts of virtual machines processors (vCPUs) and memory"
msgstr ""
@ -43404,6 +43410,12 @@ msgstr ""
msgid "Runners|Project"
msgstr ""
msgid "Runners|Project ID is required."
msgstr ""
msgid "Runners|Project ID must be 6 to 30 lowercase letters, digits, or hyphens. It needs to start with a lowercase letter and end with a letter or number."
msgstr ""
msgid "Runners|Project runners"
msgstr ""
@ -43422,6 +43434,12 @@ msgstr ""
msgid "Runners|Region"
msgstr ""
msgid "Runners|Region is required."
msgstr ""
msgid "Runners|Region must have the correct format. Example: us-central1"
msgstr ""
msgid "Runners|Register"
msgstr ""
@ -43870,9 +43888,21 @@ msgstr ""
msgid "Runners|You've created a new runner!"
msgstr ""
msgid "Runners|You've registered a new runner!"
msgstr ""
msgid "Runners|Your runner is online and ready to run jobs."
msgstr ""
msgid "Runners|Zone"
msgstr ""
msgid "Runners|Zone is required."
msgstr ""
msgid "Runners|Zone must have the correct format. Example: us-central1-a"
msgstr ""
msgid "Runners|active"
msgstr ""
@ -51986,6 +52016,9 @@ msgstr ""
msgid "This user is the author of this %{workItemType}."
msgstr ""
msgid "This value is not valid."
msgstr ""
msgid "This vulnerability was automatically resolved because its vulnerability type was disabled in this project or removed from GitLab's default ruleset. For details about SAST rule changes, see https://docs.gitlab.com/ee/user/application_security/sast/rules#important-rule-changes."
msgstr ""

View File

@ -113,6 +113,9 @@ module QA
# @param [Hash] args the existing args passed to method
# @return [Hash] args or args with merged canary cookie if it exists
def with_canary(args)
canary_cookie = QA::Runtime::Env.canary_cookie
return args if canary_cookie.empty?
args.deep_merge(cookies: QA::Runtime::Env.canary_cookie)
end

View File

@ -12,7 +12,7 @@ module QA
DEFAULT_TEST_PATTERN = "qa/specs/features/**/*_spec.rb"
class << self
delegate :configure!, :move_regenerated_report, :download_report, :upload_report, to: :new
delegate :configure!, :move_regenerated_report, :download_report, :upload_report, :merged_report, to: :new
end
def initialize(report_name = nil)
@ -106,6 +106,16 @@ module QA
end
end
# Single merged knapsack report
#
# @return [Hash]
def merged_report
logger.info("Fetching all knapsack reports from GCS '#{BUCKET}' bucket")
client.list_objects(BUCKET).items.each_with_object({}) do |report, hash|
hash.merge!(JSON.parse(client.get_object(BUCKET, report.name)[:body]))
end
end
private
# Setup knapsack logger

View File

@ -0,0 +1,107 @@
# frozen_string_literal: true
module QA
module Tools
class KnapsackReportUpdater
include Support::API
include Ci::Helpers
GITLAB_PROJECT_ID = 278964
UPDATE_BRANCH_NAME = "qa-knapsack-master-report-update"
def self.run
new.update_master_report
end
# Create master_report.json merge request
#
# @return [void]
def update_master_report
create_branch
create_commit
create_mr
end
private
# Gitlab access token
#
# @return [String]
def gitlab_access_token
@gitlab_access_token ||= ENV["GITLAB_ACCESS_TOKEN"] || raise("Missing GITLAB_ACCESS_TOKEN env variable")
end
# Gitlab api url
#
# @return [String]
def gitlab_api_url
@gitlab_api_url ||= ENV["CI_API_V4_URL"] || raise("Missing CI_API_V4_URL env variable")
end
# Create branch for knapsack report update
#
# @return [void]
def create_branch
logger.info("Creating branch '#{UPDATE_BRANCH_NAME}' branch")
api_post("repository/branches", {
branch: UPDATE_BRANCH_NAME,
ref: "master"
})
end
# Create update commit for knapsack report
#
# @return [void]
def create_commit
logger.info("Creating master_report.json update commit")
api_post("repository/commits", {
branch: UPDATE_BRANCH_NAME,
commit_message: "Update master_report.json for E2E tests",
actions: [
{
action: "update",
file_path: "qa/knapsack/master_report.json",
content: JSON.pretty_generate(Support::KnapsackReport.merged_report.sort.to_h)
}
]
})
end
# Create merge request with updated knapsack master report
#
# @return [void]
def create_mr
logger.info("Creating merge request")
resp = api_post("merge_requests", {
source_branch: UPDATE_BRANCH_NAME,
target_branch: "master",
title: "Update master_report.json for E2E tests",
remove_source_branch: true,
squash: true,
labels: "Quality,team::Test and Tools Infrastructure,type::maintenance,maintenance::pipelines",
description: <<~DESCRIPTION
Update fallback knapsack report with latest spec runtime data.
@gl-quality/qe-maintainers please review and merge.
DESCRIPTION
})
logger.info("Merge request created: #{resp[:web_url]}")
end
# Api update request
#
# @param [String] path
# @param [Hash] payload
# @return [Hash, Array]
def api_post(path, payload)
response = post("#{gitlab_api_url}/projects/#{GITLAB_PROJECT_ID}/#{path}", payload, {
headers: { "PRIVATE-TOKEN" => gitlab_access_token }
})
raise "Api request to #{path} failed! Body: #{response.body}" unless success?(response.code)
parse_body(response)
end
end
end
end

View File

@ -25,7 +25,6 @@ RSpec.describe QA::Specs::Helpers::FastQuarantine do
expect(RSpec).to have_received(:configure)
expect(File).to have_received(:write).with(fq_path, fq_contents)
expect(RestClient::Request).to have_received(:execute).with(
cookies: {},
method: :get,
url: "https://gitlab-org.gitlab.io/quality/engineering-productivity/fast-quarantine/rspec/fast_quarantine-gitlab.txt",
verify_ssl: true

View File

@ -0,0 +1,62 @@
# frozen_string_literal: true
RSpec.describe QA::Tools::KnapsackReportUpdater do
include QA::Support::Helpers::StubEnv
let(:http_response) { instance_double("HTTPResponse", code: 200, body: {}.to_json) }
let(:logger) { instance_double("Logger", info: nil) }
let(:merged_report) { { spec_file: 0.0 } }
let(:branch) { "qa-knapsack-master-report-update" }
def request_args(path, payload)
{
method: :post,
url: "https://gitlab.com/api/v4/projects/278964/#{path}",
payload: payload,
verify_ssl: false,
headers: { "PRIVATE-TOKEN" => "token" }
}
end
before do
allow(RestClient::Request).to receive(:execute).and_return(http_response)
allow(QA::Support::KnapsackReport).to receive(:merged_report).and_return(merged_report)
allow(Gitlab::QA::TestLogger).to receive(:logger).and_return(logger)
stub_env("CI_API_V4_URL", "https://gitlab.com/api/v4")
stub_env("GITLAB_ACCESS_TOKEN", "token")
end
it "creates master report merge request", :aggregate_failures do
described_class.run
expect(RestClient::Request).to have_received(:execute).with(request_args("repository/branches", {
branch: branch,
ref: "master"
}))
expect(RestClient::Request).to have_received(:execute).with(request_args("repository/commits", {
branch: branch,
commit_message: "Update master_report.json for E2E tests",
actions: [
{
action: "update",
file_path: "qa/knapsack/master_report.json",
content: JSON.pretty_generate(merged_report)
}
]
}))
expect(RestClient::Request).to have_received(:execute).with(request_args("merge_requests", {
source_branch: branch,
target_branch: "master",
title: "Update master_report.json for E2E tests",
remove_source_branch: true,
squash: true,
labels: "Quality,team::Test and Tools Infrastructure,type::maintenance,maintenance::pipelines",
description: <<~DESCRIPTION
Update fallback knapsack report with latest spec runtime data.
@gl-quality/qe-maintainers please review and merge.
DESCRIPTION
}))
end
end

View File

@ -217,8 +217,7 @@ describe QA::Tools::ReliableReport do
let(:common_api_args) do
{
verify_ssl: false,
headers: { "PRIVATE-TOKEN" => "gitlab_token" },
cookies: {}
headers: { "PRIVATE-TOKEN" => "gitlab_token" }
}
end

View File

@ -69,4 +69,9 @@ namespace :knapsack do
task :notify_long_running_specs do
QA::Tools::LongRunningSpecReporter.execute
end
desc "Update fallback knapsack report"
task :update_fallback_report do
QA::Tools::KnapsackReportUpdater.run
end
end

View File

@ -0,0 +1,98 @@
import { mountExtended } from 'helpers/vue_test_utils_helper';
import GoogleCloudFieldGroup from '~/ci/runner/components/registration/google_cloud_field_group.vue';
describe('GoogleCloudRegistrationInstructions', () => {
let wrapper;
const findLabel = () => wrapper.find('label');
const findInput = () => wrapper.find('input');
const createComponent = ({ props = {}, ...options } = {}) => {
wrapper = mountExtended(GoogleCloudFieldGroup, {
propsData: {
name: 'field',
...props,
},
...options,
});
};
const changeValue = (textInput, value) => {
// eslint-disable-next-line no-param-reassign
textInput.element.value = value;
return textInput.trigger('change');
};
it('shows a form label and field', () => {
createComponent({
props: { label: 'My field', name: 'myField' },
});
expect(findLabel().attributes('for')).toBe('myField');
expect(findLabel().text()).toBe('My field');
expect(findInput().attributes()).toMatchObject({
id: 'myField',
name: 'myField',
type: 'text',
});
});
it('accepts a value without updating it', () => {
createComponent({
props: { value: { state: true, value: 'Prefilled' } },
});
expect(findInput().element.value).toEqual('Prefilled');
expect(wrapper.emitted('change')).toBeUndefined();
});
it('accepts arbitrary slots', () => {
createComponent({
slots: {
label: '<strong>Label</strong>',
description: '<div>Description</div>',
},
});
expect(findLabel().html()).toContain('<strong>Label</strong>');
expect(wrapper.html()).toContain('<div>Description</div>');
});
describe('field validation', () => {
beforeEach(() => {
createComponent({
props: {
regexp: /^[a-z][0-9]$/,
invalidFeedbackIfEmpty: 'Field is required.',
invalidFeedbackIfMalformed: 'Field is incorrect.',
},
});
});
it('validates a missing value', async () => {
await changeValue(findInput(), '');
expect(wrapper.emitted('change')).toEqual([[{ state: false, value: '' }]]);
expect(wrapper.text()).toBe('Field is required.');
expect(findInput().attributes('aria-invalid')).toBe('true');
});
it('validates a wrong value', async () => {
await changeValue(findInput(), '11');
expect(wrapper.emitted('change')).toEqual([[{ state: false, value: '11' }]]);
expect(wrapper.text()).toBe('Field is incorrect.');
expect(findInput().attributes('aria-invalid')).toBe('true');
});
it('validates a correct value', async () => {
await changeValue(findInput(), 'a1');
expect(wrapper.emitted('change')).toEqual([[{ state: true, value: 'a1' }]]);
expect(wrapper.text()).toBe('');
expect(findInput().attributes('aria-invalid')).toBeUndefined();
});
});
});

View File

@ -1,7 +1,8 @@
import { GlAlert } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
import { createWrapper } from '@vue/test-utils';
import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
@ -10,6 +11,7 @@ import GoogleCloudRegistrationInstructions from '~/ci/runner/components/registra
import runnerForRegistrationQuery from '~/ci/runner/graphql/register/runner_for_registration.query.graphql';
import provisionGoogleCloudRunnerQueryProject from '~/ci/runner/graphql/register/provision_google_cloud_runner_project.query.graphql';
import provisionGoogleCloudRunnerQueryGroup from '~/ci/runner/graphql/register/provision_google_cloud_runner_group.query.graphql';
import { STATUS_ONLINE } from '~/ci/runner/constants';
import {
runnerForRegistration,
mockAuthenticationToken,
@ -35,6 +37,14 @@ const mockRunnerWithoutTokenResponse = {
},
},
};
const mockRunnerOnlineResponse = {
data: {
runner: {
...runnerForRegistration.data.runner,
status: STATUS_ONLINE,
},
},
};
const mockProjectRunnerCloudSteps = {
data: {
@ -61,23 +71,33 @@ describe('GoogleCloudRegistrationInstructions', () => {
const findRegionInput = () => wrapper.findByTestId('region-input');
const findZoneInput = () => wrapper.findByTestId('zone-input');
const findMachineTypeInput = () => wrapper.findByTestId('machine-type-input');
const findProjectIdLink = () => wrapper.findByTestId('project-id-link');
const findZoneLink = () => wrapper.findByTestId('zone-link');
const findMachineTypeLink = () => wrapper.findByTestId('machine-types-link');
const findToken = () => wrapper.findByTestId('runner-token');
const findClipboardButton = () => wrapper.findComponent(ClipboardButton);
const findModalTerrarformInstructions = () =>
wrapper.findByTestId('terraform-script-instructions');
const findModalTerrarformApplyInstructions = () =>
wrapper.findByTestId('terraform-apply-instructions');
const findModalBashInstructions = () => wrapper.findByTestId('bash-instructions');
const findInstructionsButton = () => wrapper.findByTestId('show-instructions-button');
const findAlert = () => wrapper.findComponent(GlAlert);
const findInstructionsButton = () => wrapper.findByTestId('show-instructions-button');
const getModal = () => extendedWrapper(createWrapper(document.querySelector('.gl-modal')));
const findModalBashInstructions = () => getModal().findByTestId('bash-instructions');
const findModalTerraformApplyInstructions = () =>
getModal().findByTestId('terraform-apply-instructions');
const findModalTerraformInstructions = () =>
getModal().findByTestId('terraform-script-instructions');
const fillInTextField = (formGroup, value) => {
const input = formGroup.find('input');
input.element.value = value;
return input.trigger('change');
};
const fillInGoogleForm = () => {
findProjectIdInput().vm.$emit('input', 'dev-gcp-xxx-integrati-xxxxxxxx');
findRegionInput().vm.$emit('input', 'us-central1');
findZoneInput().vm.$emit('input', 'us-central1');
fillInTextField(findProjectIdInput(), 'dev-gcp-xxx-integrati-xxxxxxxx');
fillInTextField(findRegionInput(), 'us-central1');
fillInTextField(findZoneInput(), 'us-central1-a');
fillInTextField(findMachineTypeInput(), 'n2d-standard-4');
findInstructionsButton().vm.$emit('click');
@ -86,6 +106,7 @@ describe('GoogleCloudRegistrationInstructions', () => {
const runnerWithTokenResolver = jest.fn().mockResolvedValue(mockRunnerResponse);
const runnerWithoutTokenResolver = jest.fn().mockResolvedValue(mockRunnerWithoutTokenResponse);
const runnerOnlineResolver = jest.fn().mockResolvedValue(mockRunnerOnlineResponse);
const projectInstructionsResolver = jest.fn().mockResolvedValue(mockProjectRunnerCloudSteps);
const groupInstructionsResolver = jest.fn().mockResolvedValue(mockGroupRunnerCloudSteps);
@ -98,16 +119,13 @@ describe('GoogleCloudRegistrationInstructions', () => {
projectPath: 'test/project',
};
const createComponent = (
mountFn = shallowMountExtended,
handlers = defaultHandlers,
props = defaultProps,
) => {
wrapper = mountFn(GoogleCloudRegistrationInstructions, {
const createComponent = (handlers = defaultHandlers, props = defaultProps) => {
wrapper = mountExtended(GoogleCloudRegistrationInstructions, {
apolloProvider: createMockApollo(handlers),
propsData: {
...props,
},
attachTo: document.body,
});
};
@ -123,11 +141,11 @@ describe('GoogleCloudRegistrationInstructions', () => {
it('machine type input has a default value', () => {
createComponent();
expect(findMachineTypeInput().attributes('value')).toBe('n2d-standard-2');
expect(findMachineTypeInput().find('input').element.value).toEqual('n2d-standard-2');
});
it('contains external docs links', () => {
createComponent(mountExtended);
createComponent();
expect(findProjectIdLink().attributes('href')).toBe(
'https://cloud.google.com/resource-manager/docs/creating-managing-projects#identifying_projects',
@ -147,7 +165,7 @@ describe('GoogleCloudRegistrationInstructions', () => {
});
it('displays runner token', async () => {
createComponent(mountExtended);
createComponent();
await waitForPromises();
@ -158,7 +176,7 @@ describe('GoogleCloudRegistrationInstructions', () => {
});
it('does not display runner token', async () => {
createComponent(mountExtended, [[runnerForRegistrationQuery, runnerWithoutTokenResolver]]);
createComponent([[runnerForRegistrationQuery, runnerWithoutTokenResolver]]);
await waitForPromises();
@ -174,30 +192,22 @@ describe('GoogleCloudRegistrationInstructions', () => {
await waitForPromises();
expect(findAlert().exists()).toBe(true);
expect(findAlert().text()).toBe('To view the setup instructions, complete the previous form.');
expect(findAlert().text()).toContain(
'To view the setup instructions, complete the previous form.',
);
});
it('Hides an alert when the form is valid', async () => {
createComponent(mountExtended, [
[provisionGoogleCloudRunnerQueryProject, projectInstructionsResolver],
]);
createComponent([[provisionGoogleCloudRunnerQueryProject, projectInstructionsResolver]]);
findProjectIdInput().vm.$emit('input', 'dev-gcp-xxx-integrati-xxxxxxxx');
findRegionInput().vm.$emit('input', 'us-central1');
findZoneInput().vm.$emit('input', 'us-central1');
findMachineTypeInput().vm.$emit('input', 'n2d-standard-2');
findInstructionsButton().vm.$emit('click');
await waitForPromises();
await fillInGoogleForm();
expect(findAlert().exists()).toBe(false);
});
it('Shows a modal with the correspondent scripts for a project', async () => {
createComponent(shallowMountExtended, [
[provisionGoogleCloudRunnerQueryProject, projectInstructionsResolver],
]);
createComponent([[provisionGoogleCloudRunnerQueryProject, projectInstructionsResolver]]);
await fillInGoogleForm();
@ -205,16 +215,15 @@ describe('GoogleCloudRegistrationInstructions', () => {
expect(groupInstructionsResolver).not.toHaveBeenCalled();
expect(findModalBashInstructions().text()).not.toBeNull();
expect(findModalTerrarformInstructions().text()).not.toBeNull();
expect(findModalTerrarformApplyInstructions().text).not.toBeNull();
expect(findModalTerraformInstructions().text()).not.toBeNull();
expect(findModalTerraformApplyInstructions().text()).not.toBeNull();
});
it('Shows a modal with the correspondent scripts for a group', async () => {
createComponent(
shallowMountExtended,
[[provisionGoogleCloudRunnerQueryGroup, groupInstructionsResolver]],
{ runnerId: mockRunnerId, groupPath: 'groups/test' },
);
createComponent([[provisionGoogleCloudRunnerQueryGroup, groupInstructionsResolver]], {
runnerId: mockRunnerId,
groupPath: 'groups/test',
});
await fillInGoogleForm();
@ -222,27 +231,130 @@ describe('GoogleCloudRegistrationInstructions', () => {
expect(projectInstructionsResolver).not.toHaveBeenCalled();
expect(findModalBashInstructions().text()).not.toBeNull();
expect(findModalTerrarformInstructions().text()).not.toBeNull();
expect(findModalTerrarformApplyInstructions().text).not.toBeNull();
expect(findModalTerraformInstructions().text()).not.toBeNull();
expect(findModalTerraformApplyInstructions().text()).not.toBeNull();
});
it('Does not display a modal with text when validation errors occur', async () => {
createComponent(mountExtended, [[provisionGoogleCloudRunnerQueryProject, errorResolver]]);
findProjectIdInput().vm.$emit('input', 'dev-gcp-xxx-integrati-xxxxxxxx');
findRegionInput().vm.$emit('input', 'us-central1xxx');
findZoneInput().vm.$emit('input', 'us-central181263');
it('Shows feedback when runner is online', async () => {
createComponent([[runnerForRegistrationQuery, runnerOnlineResolver]]);
await waitForPromises();
expect(errorResolver).toHaveBeenCalled();
expect(runnerOnlineResolver).toHaveBeenCalledTimes(1);
expect(runnerOnlineResolver).toHaveBeenCalledWith({
id: expect.stringContaining(mockRunnerId),
});
expect(findAlert().text()).toContain(
'To view the setup instructions, make sure all form fields are completed and correct.',
);
expect(wrapper.text()).toContain('Your runner is online');
});
expect(findModalBashInstructions().exists()).toBe(false);
expect(findModalTerrarformInstructions().exists()).toBe(false);
expect(findModalTerrarformApplyInstructions().exists()).toBe(false);
describe('Field validation', () => {
const expectValidation = (fieldGroup, { ariaInvalid, feedback }) => {
expect(fieldGroup.attributes('aria-invalid')).toBe(ariaInvalid);
expect(fieldGroup.find('input').attributes('aria-invalid')).toBe(ariaInvalid);
expect(fieldGroup.text()).toContain(feedback);
};
beforeEach(() => {
createComponent();
});
describe('cloud project id validates', () => {
it.each`
case | input | ariaInvalid | feedback
${'correct'} | ${'correct-project-name'} | ${undefined} | ${''}
${'correct'} | ${'correct-project-name-1'} | ${undefined} | ${''}
${'correct'} | ${'project'} | ${undefined} | ${''}
${'invalid (too short)'} | ${'short'} | ${'true'} | ${'Project ID must be'}
${'invalid (starts with a number)'} | ${'1number'} | ${'true'} | ${'Project ID must be'}
${'invalid (starts with uppercase)'} | ${'Project'} | ${'true'} | ${'Project ID must be'}
${'invalid (contains uppercase)'} | ${'pRoject'} | ${'true'} | ${'Project ID must be'}
${'invalid (contains symbol)'} | ${'pro!ect'} | ${'true'} | ${'Project ID must be'}
${'invalid (too long)'} | ${'a-project-name-that-is-too-long'} | ${'true'} | ${'Project ID must be'}
${'invalid (ends with hyphen)'} | ${'a-project-'} | ${'true'} | ${'Project ID must be'}
${'invalid (missing)'} | ${''} | ${'true'} | ${'Project ID is required'}
`('"$input" as $case', async ({ input, ariaInvalid, feedback }) => {
await fillInTextField(findProjectIdInput(), input);
expectValidation(findProjectIdInput(), { ariaInvalid, feedback });
});
});
describe('region validates', () => {
it.each`
case | input | ariaInvalid | feedback
${'correct'} | ${'us-central1'} | ${undefined} | ${''}
${'correct'} | ${'europe-west8'} | ${undefined} | ${''}
${'correct'} | ${'moon-up99'} | ${undefined} | ${''}
${'invalid (is zone)'} | ${'us-central1-a'} | ${'true'} | ${'Region must have'}
${'invalid (one part)'} | ${'one2'} | ${'true'} | ${'Region must have'}
${'invalid (three parts)'} | ${'one-two-three4'} | ${'true'} | ${'Region must have'}
${'invalid (contains symbol)'} | ${'one!-two-three4'} | ${'true'} | ${'Region must have'}
${'invalid (typo)'} | ${'one--two3'} | ${'true'} | ${'Region must have'}
${'invalid (too short)'} | ${'wrong'} | ${'true'} | ${'Region must have'}
${'invalid (missing)'} | ${''} | ${'true'} | ${'Region is required'}
`('"$input" as $case', async ({ input, ariaInvalid, feedback }) => {
await fillInTextField(findRegionInput(), input);
expectValidation(findRegionInput(), { ariaInvalid, feedback });
});
});
describe('zone validates', () => {
it.each`
case | input | ariaInvalid | feedback
${'correct'} | ${'us-central1-a'} | ${undefined} | ${''}
${'correct'} | ${'europe-west8-b'} | ${undefined} | ${''}
${'correct'} | ${'moon-up99-z'} | ${undefined} | ${''}
${'invalid (one part)'} | ${'one2-a'} | ${'true'} | ${'Zone must have'}
${'invalid (three parts)'} | ${'one-two-three4-b'} | ${'true'} | ${'Zone must have'}
${'invalid (contains symbol)'} | ${'one!-two-three4-c'} | ${'true'} | ${'Zone must have'}
${'invalid (typo)'} | ${'one--two3-d'} | ${'true'} | ${'Zone must have'}
${'invalid (too short)'} | ${'wrong'} | ${'true'} | ${'Zone must have'}
${'invalid (missing)'} | ${''} | ${'true'} | ${'Zone is required'}
`('"$input" as $case', async ({ input, ariaInvalid, feedback }) => {
await fillInTextField(findZoneInput(), input);
expectValidation(findZoneInput(), { ariaInvalid, feedback });
});
});
describe('machine type validates', () => {
it.each`
case | input | ariaInvalid | feedback
${'correct'} | ${'n2-standard-2'} | ${undefined} | ${''}
${'correct'} | ${'t2d-standard-1'} | ${undefined} | ${''}
${'correct'} | ${'t2a-standard-48'} | ${undefined} | ${''}
${'correct'} | ${'t2d-standard-1'} | ${undefined} | ${''}
${'correct'} | ${'c3-standard-4-lssd'} | ${undefined} | ${''}
${'correct'} | ${'f1-micro'} | ${undefined} | ${''}
${'correct'} | ${'f1'} | ${undefined} | ${''}
${'invalid (uppercase letter)'} | ${'N2-standard-2'} | ${'true'} | ${'Machine type must have'}
${'invalid (number)'} | ${'22-standard-2'} | ${'true'} | ${'Machine type must have'}
${'invalid (ends in dash)'} | ${'22-standard-2-'} | ${'true'} | ${'Machine type must have'}
${'invalid (contains space)'} | ${'n2-standard-2 '} | ${'true'} | ${'Machine type must have'}
${'invalid (missing)'} | ${''} | ${'true'} | ${'Machine type is required'}
`('"$input" as $case', async ({ input, ariaInvalid, feedback }) => {
await fillInTextField(findMachineTypeInput(), input);
expectValidation(findMachineTypeInput(), { ariaInvalid, feedback });
});
});
it('Does not display a modal with text when validation errors occur', async () => {
createComponent([[provisionGoogleCloudRunnerQueryProject, errorResolver]]);
await fillInGoogleForm();
expect(errorResolver).toHaveBeenCalled();
expect(findAlert().text()).toContain(
'To view the setup instructions, make sure all form fields are completed and correct.',
);
expect(findModalBashInstructions().text()).toBe('');
expect(findModalTerraformInstructions().text()).toBe('');
expect(findModalTerraformApplyInstructions().text()).toBe('');
});
});
});

View File

@ -19,6 +19,7 @@ import PlatformsDrawer from '~/ci/runner/components/registration/platforms_drawe
import { runnerForRegistration } from '../mock_data';
const mockRunnerId = runnerForRegistration.data.runner.id;
const mockGroupPath = '/groups/group1';
const mockRunnersPath = '/groups/group1/-/runners';
jest.mock('~/lib/utils/url_utility', () => ({
@ -40,6 +41,7 @@ describe('GroupRegisterRunnerApp', () => {
propsData: {
runnerId: mockRunnerId,
runnersPath: mockRunnersPath,
groupPath: mockGroupPath,
},
provide: {
glFeatures: {

View File

@ -19,6 +19,7 @@ import PlatformsDrawer from '~/ci/runner/components/registration/platforms_drawe
import { runnerForRegistration } from '../mock_data';
const mockRunnerId = runnerForRegistration.data.runner.id;
const mockProjectPath = '/group1/project1';
const mockRunnersPath = '/group1/project1/-/settings/ci_cd';
jest.mock('~/lib/utils/url_utility', () => ({
@ -40,6 +41,7 @@ describe('ProjectRegisterRunnerApp', () => {
propsData: {
runnerId: mockRunnerId,
runnersPath: mockRunnersPath,
projectPath: mockProjectPath,
},
provide: {
glFeatures: {

View File

@ -0,0 +1,69 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::BackgroundMigration::BackfillCatalogResourceVersionSemVer, feature_category: :pipeline_composition do
let(:namespace) { table(:namespaces).create!(name: 'name', path: 'path') }
let(:project) { table(:projects).create!(namespace_id: namespace.id, project_namespace_id: namespace.id) }
let(:resource) { table(:catalog_resources).create!(project_id: project.id) }
let(:releases_table) { table(:releases) }
let(:versions_table) { table(:catalog_resource_versions) }
let(:tag_to_expected_semver) do
{
'2.0.5' => [2, 0, 5, nil],
'1.2.3-alpha' => [1, 2, 3, 'alpha'],
'4.5.6-beta+12345' => [4, 5, 6, 'beta'],
'1' => [1, 0, 0, nil],
'v1.2' => [1, 2, 0, nil],
'1.3-alpha' => [1, 3, 0, 'alpha'],
'4.0+123' => [4, 0, 0, nil],
'0.5-beta+123' => [0, 5, 0, 'beta'],
'1.2.3.4' => [1, 2, 3, nil],
'v123.34.5.6-beta' => [123, 34, 5, 'beta'],
'test-name' => [nil, nil, nil, nil],
'semver_already_exists' => [2, 1, 0, nil]
}
end
before do
tag_to_expected_semver.each_key do |tag|
releases_table.create!(tag: tag, released_at: Time.zone.now)
end
releases_table.find_each do |release|
versions_table.create!(release_id: release.id, catalog_resource_id: resource.id, project_id: project.id)
end
# Pre-set the semver values for one version to ensure they're not modified by the backfill.
release = releases_table.find_by(tag: 'semver_already_exists')
versions_table.find_by(release_id: release.id).update!(semver_major: 2, semver_minor: 1, semver_patch: 0)
end
subject(:perform_migration) do
described_class.new(
start_id: versions_table.minimum(:id),
end_id: versions_table.maximum(:id),
batch_table: :catalog_resource_versions,
batch_column: :id,
sub_batch_size: 3,
pause_ms: 0,
connection: ActiveRecord::Base.connection
).perform
end
it 'updates the semver columns with the expected values' do
perform_migration
query = versions_table
.joins('INNER JOIN releases ON releases.id = catalog_resource_versions.release_id')
.select('catalog_resource_versions.*, releases.tag')
results = query.each_with_object({}) do |row, obj|
obj[row.tag] = [row.semver_major, row.semver_minor, row.semver_patch, row.semver_prerelease]
end
expect(results).to eq(tag_to_expected_semver)
end
end

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
require 'spec_helper'
require_migration!
RSpec.describe QueueBackfillCatalogResourceVersionSemVer, feature_category: :pipeline_composition do
let!(:batched_migration) { described_class::MIGRATION }
it 'schedules a new batched migration' do
reversible_migration do |migration|
migration.before -> {
expect(batched_migration).not_to have_scheduled_batched_migration
}
migration.after -> {
expect(batched_migration).to have_scheduled_batched_migration(
table_name: :catalog_resource_versions,
column_name: :id,
interval: described_class::DELAY_INTERVAL,
batch_size: described_class::BATCH_SIZE,
sub_batch_size: described_class::SUB_BATCH_SIZE
)
}
end
end
end