summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-07-05 10:20:03 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2022-07-05 10:20:03 +0000
commitd2612b42b9da6638d70b9d7144f6d427070d042d (patch)
treeed7de87d4b112cae8a45ba186d717ca9768c7d4e
parentd80373b353005e70f44eca8a3bc4a4c5cfbf0e9e (diff)
downloadgitlab-ce-d2612b42b9da6638d70b9d7144f6d427070d042d.tar.gz
Add latest changes from gitlab-org/gitlab@15-1-stable-ee
-rw-r--r--app/assets/javascripts/clusters/agents/components/create_token_button.vue216
-rw-r--r--app/assets/javascripts/clusters/agents/components/create_token_modal.vue218
-rw-r--r--app/assets/javascripts/clusters/agents/components/revoke_token_button.vue2
-rw-r--r--app/assets/javascripts/clusters/agents/components/token_table.vue92
-rw-r--r--app/assets/javascripts/projects/project_new.js35
-rw-r--r--app/uploaders/object_storage.rb14
-rw-r--r--app/workers/namespaces/process_sync_events_worker.rb2
-rw-r--r--app/workers/projects/process_sync_events_worker.rb2
-rw-r--r--config/object_store_settings.rb24
-rw-r--r--doc/administration/geo/disaster_recovery/planned_failover.md2
-rw-r--r--doc/administration/geo/replication/datatypes.md59
-rw-r--r--doc/administration/geo/replication/geo_validation_tests.md18
-rw-r--r--doc/administration/geo/replication/object_storage.md12
-rw-r--r--doc/administration/gitaly/configure_gitaly.md8
-rw-r--r--doc/administration/gitaly/monitoring.md4
-rw-r--r--doc/administration/object_storage.md9
-rw-r--r--lib/gitlab/kubernetes/kube_client.rb44
-rw-r--r--locale/gitlab.pot3
-rw-r--r--spec/config/object_store_settings_spec.rb76
-rw-r--r--spec/features/projects/new_project_spec.rb14
-rw-r--r--spec/frontend/clusters/agents/components/create_token_button_spec.js255
-rw-r--r--spec/frontend/clusters/agents/components/create_token_modal_spec.js223
-rw-r--r--spec/frontend/clusters/agents/components/token_table_spec.js6
-rw-r--r--spec/lib/gitlab/kubernetes/kube_client_spec.rb98
-rw-r--r--spec/support/helpers/kubernetes_helpers.rb37
-rw-r--r--spec/uploaders/object_storage_spec.rb44
26 files changed, 804 insertions, 713 deletions
diff --git a/app/assets/javascripts/clusters/agents/components/create_token_button.vue b/app/assets/javascripts/clusters/agents/components/create_token_button.vue
index 74155d7819a..67a178b5f98 100644
--- a/app/assets/javascripts/clusters/agents/components/create_token_button.vue
+++ b/app/assets/javascripts/clusters/agents/components/create_token_button.vue
@@ -1,154 +1,23 @@
<script>
-import {
- GlButton,
- GlModalDirective,
- GlTooltip,
- GlModal,
- GlFormGroup,
- GlFormInput,
- GlFormTextarea,
- GlAlert,
-} from '@gitlab/ui';
-import { s__, __ } from '~/locale';
-import Tracking from '~/tracking';
-import AgentToken from '~/clusters_list/components/agent_token.vue';
-import {
- CREATE_TOKEN_MODAL,
- EVENT_LABEL_MODAL,
- EVENT_ACTIONS_OPEN,
- EVENT_ACTIONS_CLICK,
- TOKEN_NAME_LIMIT,
- TOKEN_STATUS_ACTIVE,
-} from '../constants';
-import createNewAgentToken from '../graphql/mutations/create_new_agent_token.mutation.graphql';
-import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql';
-import { addAgentTokenToStore } from '../graphql/cache_update';
-
-const trackingMixin = Tracking.mixin({ label: EVENT_LABEL_MODAL });
+import { GlButton, GlModalDirective, GlTooltip } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { CREATE_TOKEN_MODAL } from '../constants';
export default {
components: {
- AgentToken,
GlButton,
GlTooltip,
- GlModal,
- GlFormGroup,
- GlFormInput,
- GlFormTextarea,
- GlAlert,
},
directives: {
GlModalDirective,
},
- mixins: [trackingMixin],
- inject: ['agentName', 'projectPath', 'canAdminCluster'],
- props: {
- clusterAgentId: {
- required: true,
- type: String,
- },
- cursor: {
- required: true,
- type: Object,
- },
- },
+ inject: ['canAdminCluster'],
modalId: CREATE_TOKEN_MODAL,
- EVENT_ACTIONS_OPEN,
- EVENT_ACTIONS_CLICK,
- EVENT_LABEL_MODAL,
- TOKEN_NAME_LIMIT,
i18n: {
createTokenButton: s__('ClusterAgents|Create token'),
- modalTitle: s__('ClusterAgents|Create agent access token'),
- unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'),
- errorTitle: s__('ClusterAgents|Failed to create a token'),
dropdownDisabledHint: s__(
'ClusterAgents|Requires a Maintainer or greater role to perform these actions',
),
- modalCancel: __('Cancel'),
- modalClose: __('Close'),
- tokenNameLabel: __('Name'),
- tokenDescriptionLabel: __('Description (optional)'),
- },
- data() {
- return {
- token: {
- name: null,
- description: null,
- },
- agentToken: null,
- error: null,
- loading: false,
- variables: {
- agentName: this.agentName,
- projectPath: this.projectPath,
- tokenStatus: TOKEN_STATUS_ACTIVE,
- ...this.cursor,
- },
- };
- },
- computed: {
- modalBtnDisabled() {
- return this.loading || !this.hasTokenName;
- },
- hasTokenName() {
- return Boolean(this.token.name?.length);
- },
- },
- methods: {
- async createToken() {
- this.loading = true;
- this.error = null;
-
- try {
- const { errors: tokenErrors, secret } = await this.createAgentTokenMutation();
-
- if (tokenErrors?.length > 0) {
- throw new Error(tokenErrors[0]);
- }
-
- this.agentToken = secret;
- } catch (error) {
- if (error) {
- this.error = error.message;
- } else {
- this.error = this.$options.i18n.unknownError;
- }
- } finally {
- this.loading = false;
- }
- },
- resetModal() {
- this.agentToken = null;
- this.token.name = null;
- this.token.description = null;
- this.error = null;
- },
- closeModal() {
- this.$refs.modal.hide();
- },
- createAgentTokenMutation() {
- return this.$apollo
- .mutate({
- mutation: createNewAgentToken,
- variables: {
- input: {
- clusterAgentId: this.clusterAgentId,
- name: this.token.name,
- description: this.token.description,
- },
- },
- update: (store, { data: { clusterAgentTokenCreate } }) => {
- addAgentTokenToStore(
- store,
- clusterAgentTokenCreate,
- getClusterAgentQuery,
- this.variables,
- );
- },
- })
- .then(({ data: { clusterAgentTokenCreate } }) => clusterAgentTokenCreate);
- },
},
};
</script>
@@ -170,82 +39,5 @@ export default {
:title="$options.i18n.dropdownDisabledHint"
/>
</div>
-
- <gl-modal
- ref="modal"
- :modal-id="$options.modalId"
- :title="$options.i18n.modalTitle"
- static
- lazy
- @hidden="resetModal"
- @show="track($options.EVENT_ACTIONS_OPEN)"
- >
- <gl-alert
- v-if="error"
- :title="$options.i18n.errorTitle"
- :dismissible="false"
- variant="danger"
- class="gl-mb-5"
- >
- {{ error }}
- </gl-alert>
-
- <template v-if="!agentToken">
- <gl-form-group :label="$options.i18n.tokenNameLabel">
- <gl-form-input
- v-model="token.name"
- :max-length="$options.TOKEN_NAME_LIMIT"
- :disabled="loading"
- required
- />
- </gl-form-group>
-
- <gl-form-group :label="$options.i18n.tokenDescriptionLabel">
- <gl-form-textarea v-model="token.description" :disabled="loading" name="description" />
- </gl-form-group>
- </template>
-
- <agent-token
- v-else
- :agent-name="agentName"
- :agent-token="agentToken"
- :modal-id="$options.modalId"
- />
-
- <template #modal-footer>
- <gl-button
- v-if="!agentToken && !loading"
- :data-track-action="$options.EVENT_ACTIONS_CLICK"
- :data-track-label="$options.EVENT_LABEL_MODAL"
- data-track-property="close"
- data-testid="agent-token-close-button"
- @click="closeModal"
- >{{ $options.i18n.modalCancel }}
- </gl-button>
-
- <gl-button
- v-if="!agentToken"
- :disabled="modalBtnDisabled"
- :loading="loading"
- :data-track-action="$options.EVENT_ACTIONS_CLICK"
- :data-track-label="$options.EVENT_LABEL_MODAL"
- data-track-property="create-token"
- variant="confirm"
- type="submit"
- @click="createToken"
- >{{ $options.i18n.createTokenButton }}
- </gl-button>
-
- <gl-button
- v-else
- :data-track-action="$options.EVENT_ACTIONS_CLICK"
- :data-track-label="$options.EVENT_LABEL_MODAL"
- data-track-property="close"
- variant="confirm"
- @click="closeModal"
- >{{ $options.i18n.modalClose }}
- </gl-button>
- </template>
- </gl-modal>
</div>
</template>
diff --git a/app/assets/javascripts/clusters/agents/components/create_token_modal.vue b/app/assets/javascripts/clusters/agents/components/create_token_modal.vue
new file mode 100644
index 00000000000..451e1ee1d67
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/components/create_token_modal.vue
@@ -0,0 +1,218 @@
+<script>
+import { GlButton, GlModal, GlFormGroup, GlFormInput, GlFormTextarea, GlAlert } from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import Tracking from '~/tracking';
+import AgentToken from '~/clusters_list/components/agent_token.vue';
+import {
+ CREATE_TOKEN_MODAL,
+ EVENT_LABEL_MODAL,
+ EVENT_ACTIONS_OPEN,
+ EVENT_ACTIONS_CLICK,
+ TOKEN_NAME_LIMIT,
+ TOKEN_STATUS_ACTIVE,
+} from '../constants';
+import createNewAgentToken from '../graphql/mutations/create_new_agent_token.mutation.graphql';
+import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql';
+import { addAgentTokenToStore } from '../graphql/cache_update';
+
+const trackingMixin = Tracking.mixin({ label: EVENT_LABEL_MODAL });
+
+export default {
+ components: {
+ AgentToken,
+ GlButton,
+ GlModal,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlAlert,
+ },
+ mixins: [trackingMixin],
+ inject: ['agentName', 'projectPath'],
+ props: {
+ clusterAgentId: {
+ required: true,
+ type: String,
+ },
+ cursor: {
+ required: true,
+ type: Object,
+ },
+ },
+ modalId: CREATE_TOKEN_MODAL,
+ EVENT_ACTIONS_OPEN,
+ EVENT_ACTIONS_CLICK,
+ EVENT_LABEL_MODAL,
+ TOKEN_NAME_LIMIT,
+ i18n: {
+ createTokenButton: s__('ClusterAgents|Create token'),
+ modalTitle: s__('ClusterAgents|Create agent access token'),
+ unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'),
+ errorTitle: s__('ClusterAgents|Failed to create a token'),
+ modalCancel: __('Cancel'),
+ modalClose: __('Close'),
+ tokenNameLabel: __('Name'),
+ tokenDescriptionLabel: __('Description (optional)'),
+ },
+ data() {
+ return {
+ token: {
+ name: null,
+ description: null,
+ },
+ agentToken: null,
+ error: null,
+ loading: false,
+ variables: {
+ agentName: this.agentName,
+ projectPath: this.projectPath,
+ tokenStatus: TOKEN_STATUS_ACTIVE,
+ ...this.cursor,
+ },
+ };
+ },
+ computed: {
+ modalBtnDisabled() {
+ return this.loading || !this.hasTokenName;
+ },
+ hasTokenName() {
+ return Boolean(this.token.name?.length);
+ },
+ },
+ methods: {
+ async createToken() {
+ this.loading = true;
+ this.error = null;
+
+ try {
+ const { errors: tokenErrors, secret } = await this.createAgentTokenMutation();
+
+ if (tokenErrors?.length > 0) {
+ throw new Error(tokenErrors[0]);
+ }
+ this.agentToken = secret;
+ } catch (error) {
+ this.error = error ? error.message : this.$options.i18n.unknownError;
+ } finally {
+ this.loading = false;
+ }
+ },
+ resetModal() {
+ this.agentToken = null;
+ this.token.name = null;
+ this.token.description = null;
+ this.error = null;
+ },
+ closeModal() {
+ this.$refs.modal.hide();
+ },
+ createAgentTokenMutation() {
+ return this.$apollo
+ .mutate({
+ mutation: createNewAgentToken,
+ variables: {
+ input: {
+ clusterAgentId: this.clusterAgentId,
+ name: this.token.name,
+ description: this.token.description,
+ },
+ },
+ update: (store, { data: { clusterAgentTokenCreate } }) => {
+ addAgentTokenToStore(
+ store,
+ clusterAgentTokenCreate,
+ getClusterAgentQuery,
+ this.variables,
+ );
+ },
+ })
+ .then(({ data: { clusterAgentTokenCreate } }) => clusterAgentTokenCreate);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="modal"
+ :modal-id="$options.modalId"
+ :title="$options.i18n.modalTitle"
+ static
+ lazy
+ @hidden="resetModal"
+ @show="track($options.EVENT_ACTIONS_OPEN)"
+ >
+ <gl-alert
+ v-if="error"
+ :title="$options.i18n.errorTitle"
+ :dismissible="false"
+ variant="danger"
+ class="gl-mb-5"
+ >
+ {{ error }}
+ </gl-alert>
+
+ <template v-if="!agentToken">
+ <gl-form-group :label="$options.i18n.tokenNameLabel" label-for="token-name">
+ <gl-form-input
+ id="token-name"
+ v-model="token.name"
+ :max-length="$options.TOKEN_NAME_LIMIT"
+ :disabled="loading"
+ required
+ />
+ </gl-form-group>
+
+ <gl-form-group :label="$options.i18n.tokenDescriptionLabel" label-for="token-description">
+ <gl-form-textarea
+ id="token-description"
+ v-model="token.description"
+ :disabled="loading"
+ name="description"
+ />
+ </gl-form-group>
+ </template>
+
+ <agent-token
+ v-else
+ :agent-name="agentName"
+ :agent-token="agentToken"
+ :modal-id="$options.modalId"
+ />
+
+ <template #modal-footer>
+ <gl-button
+ v-if="!agentToken && !loading"
+ :data-track-action="$options.EVENT_ACTIONS_CLICK"
+ :data-track-label="$options.EVENT_LABEL_MODAL"
+ data-track-property="close"
+ data-testid="agent-token-close-button"
+ @click="closeModal"
+ >{{ $options.i18n.modalCancel }}
+ </gl-button>
+
+ <gl-button
+ v-if="!agentToken"
+ :disabled="modalBtnDisabled"
+ :loading="loading"
+ :data-track-action="$options.EVENT_ACTIONS_CLICK"
+ :data-track-label="$options.EVENT_LABEL_MODAL"
+ data-track-property="create-token"
+ variant="confirm"
+ type="submit"
+ @click="createToken"
+ >{{ $options.i18n.createTokenButton }}
+ </gl-button>
+
+ <gl-button
+ v-else
+ :data-track-action="$options.EVENT_ACTIONS_CLICK"
+ :data-track-label="$options.EVENT_LABEL_MODAL"
+ data-track-property="close"
+ variant="confirm"
+ @click="closeModal"
+ >{{ $options.i18n.modalClose }}
+ </gl-button>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/clusters/agents/components/revoke_token_button.vue b/app/assets/javascripts/clusters/agents/components/revoke_token_button.vue
index 7d36cbb170d..f0af0da4bb4 100644
--- a/app/assets/javascripts/clusters/agents/components/revoke_token_button.vue
+++ b/app/assets/javascripts/clusters/agents/components/revoke_token_button.vue
@@ -148,7 +148,7 @@ export default {
},
hideModal() {
this.resetModal();
- this.$refs.modal.hide();
+ this.$refs.modal?.hide();
},
},
};
diff --git a/app/assets/javascripts/clusters/agents/components/token_table.vue b/app/assets/javascripts/clusters/agents/components/token_table.vue
index 9e64c9da712..f74d66f6b8f 100644
--- a/app/assets/javascripts/clusters/agents/components/token_table.vue
+++ b/app/assets/javascripts/clusters/agents/components/token_table.vue
@@ -3,6 +3,7 @@ import { GlEmptyState, GlTable, GlTooltip, GlTruncate } from '@gitlab/ui';
import { s__ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import CreateTokenButton from './create_token_button.vue';
+import CreateTokenModal from './create_token_modal.vue';
import RevokeTokenButton from './revoke_token_button.vue';
export default {
@@ -13,6 +14,7 @@ export default {
GlTruncate,
TimeAgoTooltip,
CreateTokenButton,
+ CreateTokenModal,
RevokeTokenButton,
},
i18n: {
@@ -85,57 +87,57 @@ export default {
</script>
<template>
- <div v-if="tokens.length">
- <create-token-button
- class="gl-text-right gl-my-5"
- :cluster-agent-id="clusterAgentId"
- :cursor="cursor"
- />
+ <div>
+ <div v-if="tokens.length">
+ <create-token-button class="gl-text-right gl-my-5" />
- <gl-table
- :items="tokens"
- :fields="fields"
- fixed
- stacked="md"
- head-variant="white"
- thead-class="gl-border-b-solid gl-border-b-2 gl-border-b-gray-100"
- >
- <template #cell(lastUsed)="{ item }">
- <time-ago-tooltip v-if="item.lastUsedAt" :time="item.lastUsedAt" />
- <span v-else>{{ $options.i18n.neverUsed }}</span>
- </template>
+ <gl-table
+ :items="tokens"
+ :fields="fields"
+ fixed
+ stacked="md"
+ head-variant="white"
+ thead-class="gl-border-b-solid gl-border-b-2 gl-border-b-gray-100"
+ >
+ <template #cell(lastUsed)="{ item }">
+ <time-ago-tooltip v-if="item.lastUsedAt" :time="item.lastUsedAt" />
+ <span v-else>{{ $options.i18n.neverUsed }}</span>
+ </template>
- <template #cell(createdAt)="{ item }">
- <time-ago-tooltip :time="item.createdAt" />
- </template>
+ <template #cell(createdAt)="{ item }">
+ <time-ago-tooltip :time="item.createdAt" />
+ </template>
- <template #cell(createdBy)="{ item }">
- <span>{{ createdByName(item) }}</span>
- </template>
+ <template #cell(createdBy)="{ item }">
+ <span>{{ createdByName(item) }}</span>
+ </template>
- <template #cell(description)="{ item }">
- <div v-if="item.description" :id="`tooltip-description-container-${item.id}`">
- <gl-truncate :id="`tooltip-description-${item.id}`" :text="item.description" />
+ <template #cell(description)="{ item }">
+ <div v-if="item.description" :id="`tooltip-description-container-${item.id}`">
+ <gl-truncate :id="`tooltip-description-${item.id}`" :text="item.description" />
- <gl-tooltip
- :container="`tooltip-description-container-${item.id}`"
- :target="`tooltip-description-${item.id}`"
- placement="top"
- >
- {{ item.description }}
- </gl-tooltip>
- </div>
- </template>
+ <gl-tooltip
+ :container="`tooltip-description-container-${item.id}`"
+ :target="`tooltip-description-${item.id}`"
+ placement="top"
+ >
+ {{ item.description }}
+ </gl-tooltip>
+ </div>
+ </template>
- <template #cell(actions)="{ item }">
- <revoke-token-button :token="item" :cluster-agent-id="clusterAgentId" :cursor="cursor" />
+ <template #cell(actions)="{ item }">
+ <revoke-token-button :token="item" :cluster-agent-id="clusterAgentId" :cursor="cursor" />
+ </template>
+ </gl-table>
+ </div>
+
+ <gl-empty-state v-else :title="$options.i18n.noTokens">
+ <template #actions>
+ <create-token-button />
</template>
- </gl-table>
- </div>
+ </gl-empty-state>
- <gl-empty-state v-else :title="$options.i18n.noTokens">
- <template #actions>
- <create-token-button :cluster-agent-id="clusterAgentId" :cursor="cursor" />
- </template>
- </gl-empty-state>
+ <create-token-modal :cluster-agent-id="clusterAgentId" :cursor="cursor" />
+ </div>
</template>
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index 2c2f957a75d..186946a83ad 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -276,28 +276,33 @@ const bindEvents = () => {
);
let isProjectImportUrlDirty = false;
- $projectImportUrl.addEventListener('blur', () => {
- isProjectImportUrlDirty = true;
- debouncedUpdateUrlPathWarningVisibility();
- });
- $projectImportUrl.addEventListener('keyup', () => {
- deriveProjectPathFromUrl($projectImportUrl);
- });
+
+ if ($projectImportUrl) {
+ $projectImportUrl.addEventListener('blur', () => {
+ isProjectImportUrlDirty = true;
+ debouncedUpdateUrlPathWarningVisibility();
+ });
+ $projectImportUrl.addEventListener('keyup', () => {
+ deriveProjectPathFromUrl($projectImportUrl);
+ });
+ }
[$projectImportUrl, $projectImportUrlUser, $projectImportUrlPassword].forEach(($f) => {
- if ($f?.on) {
- $f.on('input', () => {
- if (isProjectImportUrlDirty) {
- debouncedUpdateUrlPathWarningVisibility();
- }
- });
- } else {
- $f.addEventListener('input', () => {
+ if (!$f) return false;
+
+ if ($f.on) {
+ return $f.on('input', () => {
if (isProjectImportUrlDirty) {
debouncedUpdateUrlPathWarningVisibility();
}
});
}
+
+ return $f.addEventListener('input', () => {
+ if (isProjectImportUrlDirty) {
+ debouncedUpdateUrlPathWarningVisibility();
+ }
+ });
});
$projectImportForm.on('submit', async (e) => {
diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb
index 1d56cddca63..891df5180d8 100644
--- a/app/uploaders/object_storage.rb
+++ b/app/uploaders/object_storage.rb
@@ -353,6 +353,20 @@ module ObjectStorage
}
end
+ def store_path(*args)
+ if self.object_store == Store::REMOTE
+ # We allow administrators to create "sub buckets" by setting a prefix.
+ # This makes it possible to deploy GitLab with only one object storage
+ # bucket. Because the prefix is configuration data we do not want to
+ # store it in the uploads table via RecordsUploads. That means that the
+ # prefix cannot be part of store_dir. This is why we chose to implement
+ # the prefix support here in store_path.
+ File.join([self.class.object_store_options.bucket_prefix, super].compact)
+ else
+ super
+ end
+ end
+
# Returns all the possible paths for an upload.
# the `upload.path` is a lookup parameter, and it may change
# depending on the `store` param.
diff --git a/app/workers/namespaces/process_sync_events_worker.rb b/app/workers/namespaces/process_sync_events_worker.rb
index 36c4ab2058d..2bf2a4a6ef8 100644
--- a/app/workers/namespaces/process_sync_events_worker.rb
+++ b/app/workers/namespaces/process_sync_events_worker.rb
@@ -13,7 +13,7 @@ module Namespaces
urgency :high
idempotent!
- deduplicate :until_executed
+ deduplicate :until_executing
def perform
results = ::Ci::ProcessSyncEventsService.new(
diff --git a/app/workers/projects/process_sync_events_worker.rb b/app/workers/projects/process_sync_events_worker.rb
index 92322a9ea99..57f3e3dee5e 100644
--- a/app/workers/projects/process_sync_events_worker.rb
+++ b/app/workers/projects/process_sync_events_worker.rb
@@ -13,7 +13,7 @@ module Projects
urgency :high
idempotent!
- deduplicate :until_executed
+ deduplicate :until_executing
def perform
results = ::Ci::ProcessSyncEventsService.new(
diff --git a/config/object_store_settings.rb b/config/object_store_settings.rb
index ea954b7061a..3280bc284ad 100644
--- a/config/object_store_settings.rb
+++ b/config/object_store_settings.rb
@@ -26,7 +26,9 @@ class ObjectStoreSettings
def self.legacy_parse(object_store, object_store_type)
object_store ||= Settingslogic.new({})
object_store['enabled'] = false if object_store['enabled'].nil?
- object_store['remote_directory'] ||= nil
+ object_store['remote_directory'], object_store['bucket_prefix'] = split_bucket_prefix(
+ object_store['remote_directory']
+ )
if support_legacy_background_upload?(object_store_type)
object_store['direct_upload'] = false
@@ -48,6 +50,22 @@ class ObjectStoreSettings
ENV[LEGACY_BACKGROUND_UPLOADS_ENV].to_s.split(',').map(&:strip).include?(object_store_type)
end
+ def self.split_bucket_prefix(bucket)
+ return [nil, nil] unless bucket.present?
+
+ # Strictly speaking, object storage keys are not Unix paths and
+ # characters like '/' and '.' have no special meaning. But in practice,
+ # we do treat them like paths, and somewhere along the line something or
+ # somebody may turn '//' into '/' or try to resolve '/..'. To guard
+ # against this we reject "bad" combinations of '/' and '.'.
+ [%r{\A\.*/}, %r{/\.*/}, %r{/\.*\z}].each do |re|
+ raise 'invalid bucket' if re.match(bucket)
+ end
+
+ bucket, prefix = bucket.split('/', 2)
+ [bucket, prefix]
+ end
+
def initialize(settings)
@settings = settings
end
@@ -156,7 +174,9 @@ class ObjectStoreSettings
next if allowed_storage_specific_settings?(store_type, section.to_h)
# Map bucket (external name) -> remote_directory (internal representation)
- target_config['remote_directory'] = target_config.delete('bucket')
+ target_config['remote_directory'], target_config['bucket_prefix'] = self.class.split_bucket_prefix(
+ target_config.delete('bucket')
+ )
target_config['consolidated_settings'] = true
section['object_store'] = target_config
# Settingslogic internally stores data as a Hash, but it also
diff --git a/doc/administration/geo/disaster_recovery/planned_failover.md b/doc/administration/geo/disaster_recovery/planned_failover.md
index 7d8dd7d5d2a..c351b4031b5 100644
--- a/doc/administration/geo/disaster_recovery/planned_failover.md
+++ b/doc/administration/geo/disaster_recovery/planned_failover.md
@@ -97,7 +97,7 @@ If you have a large GitLab installation or cannot tolerate downtime, consider
Doing so reduces both the length of the maintenance window, and the risk of data
loss as a result of a poorly executed planned failover.
-In GitLab 12.4, you can optionally allow GitLab to manage replication of Object Storage for
+In GitLab 15.1, you can optionally allow GitLab to manage replication of Object Storage for
**secondary** sites. For more information, see [Object Storage replication](../replication/object_storage.md).
### Review the configuration of each **secondary** site
diff --git a/doc/administration/geo/replication/datatypes.md b/doc/administration/geo/replication/datatypes.md
index 8a5aa935d11..e49d5260233 100644
--- a/doc/administration/geo/replication/datatypes.md
+++ b/doc/administration/geo/replication/datatypes.md
@@ -182,36 +182,29 @@ replicating data from those features causes the data to be **lost**.
To use those features on a **secondary** site, or to execute a failover
successfully, you must replicate their data using some other means.
-|Feature | Replicated (added in GitLab version) | Verified (added in GitLab version) | Object Storage replication (see [Geo with Object Storage](object_storage.md)) | Notes |
-|:--------------------------------------------------------------------------------------------------------------|:------------------------------------------------------------------------|:---------------------------------------------------------------------------|:------------------------------------------------------------------------------|:------|
-|[Application data in PostgreSQL](../../postgresql/index.md) | **Yes** (10.2) | **Yes** (10.2) | No | |
-|[Project repository](../../../user/project/repository/) | **Yes** (10.2) | **Yes** (10.7) | No | |
-|[Project wiki repository](../../../user/project/wiki/) | **Yes** (10.2) | **Yes** (10.7) | No | |
-|[Group wiki repository](../../../user/project/wiki/group.md) | [**Yes** (13.10)](https://gitlab.com/gitlab-org/gitlab/-/issues/208147) | No | No | Behind feature flag `geo_group_wiki_repository_replication`, enabled by default. |
-|[Uploads](../../uploads.md) | **Yes** (10.2) | **Yes** (14.6) | Via Object Storage provider if supported. Native Geo support (Beta). | Replication is behind the feature flag `geo_upload_replication`, enabled by default. Verification was behind the feature flag `geo_upload_verification`, removed in 14.8. |
-|[LFS objects](../../lfs/index.md) | **Yes** (10.2) | **Yes** (14.6) | Via Object Storage provider if supported. Native Geo support (Beta). | GitLab versions 11.11.x and 12.0.x are affected by [a bug that prevents any new LFS objects from replicating](https://gitlab.com/gitlab-org/gitlab/-/issues/32696).<br /><br />Replication is behind the feature flag `geo_lfs_object_replication`, enabled by default. Verification was behind the feature flag `geo_lfs_object_verification`, removed in 14.7. |
-|[Personal snippets](../../../user/snippets.md) | **Yes** (10.2) | **Yes** (10.2) | No | |
-|[Project snippets](../../../user/snippets.md) | **Yes** (10.2) | **Yes** (10.2) | No | |
-|[CI job artifacts](../../../ci/pipelines/job_artifacts.md) | **Yes** (10.4) | **Yes** (14.10) | Via Object Storage provider if supported. Native Geo support (Beta). | Verification is behind the feature flag `geo_job_artifact_replication`, enabled by default in 14.10. |
-|[CI Pipeline Artifacts](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/models/ci/pipeline_artifact.rb) | [**Yes** (13.11)](https://gitlab.com/gitlab-org/gitlab/-/issues/238464) | [**Yes** (13.11)](https://gitlab.com/gitlab-org/gitlab/-/issues/238464) | Via Object Storage provider if supported. Native Geo support (Beta). | Persists additional artifacts after a pipeline completes. |
-|[Container Registry](../../packages/container_registry.md) | **Yes** (12.3) | No | No | Disabled by default. See [instructions](docker_registry.md) to enable. |
-|[Content in object storage (beta)](object_storage.md) | **Yes** (12.4) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/13845) | No | |
-|[Infrastructure Registry](../../../user/packages/infrastructure_registry/index.md) | **Yes** (14.0) | [**Yes**](#limitation-of-verification-for-files-in-object-storage) (14.0) | Via Object Storage provider if supported. Native Geo support (Beta). | Behind feature flag `geo_package_file_replication`, enabled by default. |
-|[Project designs repository](../../../user/project/issues/design_management.md) | **Yes** (12.7) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/32467) | No | Designs also require replication of LFS objects and Uploads. |
-|[Package Registry](../../../user/packages/package_registry/index.md) | **Yes** (13.2) | [**Yes**](#limitation-of-verification-for-files-in-object-storage) (13.10) | Via Object Storage provider if supported. Native Geo support (Beta). | Behind feature flag `geo_package_file_replication`, enabled by default. |
-|[Versioned Terraform State](../../terraform_state.md) | **Yes** (13.5) | [**Yes**](#limitation-of-verification-for-files-in-object-storage) (13.12) | Via Object Storage provider if supported. Native Geo support (Beta). | Replication is behind the feature flag `geo_terraform_state_version_replication`, enabled by default. Verification was behind the feature flag `geo_terraform_state_version_verification`, which was removed in 14.0. |
-|[External merge request diffs](../../merge_request_diffs.md) | **Yes** (13.5) | **Yes** (14.6) | Via Object Storage provider if supported. Native Geo support (Beta). | Replication is behind the feature flag `geo_merge_request_diff_replication`, enabled by default. Verification was behind the feature flag `geo_merge_request_diff_verification`, removed in 14.7.|
-|[Versioned snippets](../../../user/snippets.md#versioned-snippets) | [**Yes** (13.7)](https://gitlab.com/groups/gitlab-org/-/epics/2809) | [**Yes** (14.2)](https://gitlab.com/groups/gitlab-org/-/epics/2810) | No | Verification was implemented behind the feature flag `geo_snippet_repository_verification` in 13.11, and the feature flag was removed in 14.2. |
-|[GitLab Pages](../../pages/index.md) | [**Yes** (14.3)](https://gitlab.com/groups/gitlab-org/-/epics/589) | [**Yes**](#limitation-of-verification-for-files-in-object-storage) (14.6) | Via Object Storage provider if supported. Native Geo support (Beta). | Behind feature flag `geo_pages_deployment_replication`, enabled by default. Verification was behind the feature flag `geo_pages_deployment_verification`, removed in 14.7. |
-|[Incident Metric Images](../../../operations/incident_management/incidents.md#metrics) | [Planned](https://gitlab.com/gitlab-org/gitlab/-/issues/352326) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/362561) | No | |
-|[Alert Metric Images](../../../operations/incident_management/alerts.md#metrics-tab) | [Planned](https://gitlab.com/gitlab-org/gitlab/-/issues/352326) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/362561) | No | |
-|[Server-side Git hooks](../../server_hooks.md) | [Not planned](https://gitlab.com/groups/gitlab-org/-/epics/1867) | No | No | Not planned because of current implementation complexity, low customer interest, and availability of alternatives to hooks. |
-|[Elasticsearch integration](../../../integration/advanced_search/elasticsearch.md) | [Not planned](https://gitlab.com/gitlab-org/gitlab/-/issues/1186) | No | No | Not planned because further product discovery is required and Elasticsearch (ES) clusters can be rebuilt. Secondaries use the same ES cluster as the primary. |
-|[Dependency proxy images](../../../user/packages/dependency_proxy/index.md) | [Not planned](https://gitlab.com/gitlab-org/gitlab/-/issues/259694) | No | No | Blocked by [Geo: Secondary Mimicry](https://gitlab.com/groups/gitlab-org/-/epics/1528). Replication of this cache is not needed for disaster recovery purposes because it can be recreated from external sources. |
-|[Vulnerability Export](../../../user/application_security/vulnerability_report/#export-vulnerability-details) | [Not planned](https://gitlab.com/groups/gitlab-org/-/epics/3111) | No | No | Not planned because they are ephemeral and sensitive information. They can be regenerated on demand. |
-
-#### Limitation of verification for files in Object Storage
-
-GitLab managed Object Storage replication support [is in beta](object_storage.md#enabling-gitlab-managed-object-storage-replication).
-
-Locally stored files are verified but remote stored files are not.
+|Feature | Replicated (added in GitLab version) | Verified (added in GitLab version) | GitLab-managed object storage replication (added in GitLab version) | GitLab-managed object storage verification (added in GitLab version) | Notes |
+|:--------------------------------------------------------------------------------------------------------------|:------------------------------------------------------------------------|:---------------------------------------------------------------------------|:--------------------------------------------------------------------|:----------------------------------------------------------------|:------|
+|[Application data in PostgreSQL](../../postgresql/index.md) | **Yes** (10.2) | **Yes** (10.2) | N/A | N/A | |
+|[Project repository](../../../user/project/repository/) | **Yes** (10.2) | **Yes** (10.7) | N/A | N/A | |
+|[Project wiki repository](../../../user/project/wiki/) | **Yes** (10.2) | **Yes** (10.7) | N/A | N/A | |
+|[Group wiki repository](../../../user/project/wiki/group.md) | [**Yes** (13.10)](https://gitlab.com/gitlab-org/gitlab/-/issues/208147) | No | N/A | N/A | Behind feature flag `geo_group_wiki_repository_replication`, enabled by default. |
+|[Uploads](../../uploads.md) | **Yes** (10.2) | **Yes** (14.6) | [**Yes** (15.1)](https://gitlab.com/groups/gitlab-org/-/epics/5551) | [No](object_storage.md#verification-of-files-in-object-storage) | Replication is behind the feature flag `geo_upload_replication`, enabled by default. Verification was behind the feature flag `geo_upload_verification`, removed in 14.8. |
+|[LFS objects](../../lfs/index.md) | **Yes** (10.2) | **Yes** (14.6) | [**Yes** (15.1)](https://gitlab.com/groups/gitlab-org/-/epics/5551) | [No](object_storage.md#verification-of-files-in-object-storage) | GitLab versions 11.11.x and 12.0.x are affected by [a bug that prevents any new LFS objects from replicating](https://gitlab.com/gitlab-org/gitlab/-/issues/32696).<br /><br />Replication is behind the feature flag `geo_lfs_object_replication`, enabled by default. Verification was behind the feature flag `geo_lfs_object_verification`, removed in 14.7. |
+|[Personal snippets](../../../user/snippets.md) | **Yes** (10.2) | **Yes** (10.2) | N/A | N/A | |
+|[Project snippets](../../../user/snippets.md) | **Yes** (10.2) | **Yes** (10.2) | N/A | N/A | |
+|[CI job artifacts](../../../ci/pipelines/job_artifacts.md) | **Yes** (10.4) | **Yes** (14.10) | [**Yes** (15.1)](https://gitlab.com/groups/gitlab-org/-/epics/5551) | [No](object_storage.md#verification-of-files-in-object-storage) | Verification is behind the feature flag `geo_job_artifact_replication`, enabled by default in 14.10. |
+|[CI Pipeline Artifacts](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/models/ci/pipeline_artifact.rb) | [**Yes** (13.11)](https://gitlab.com/gitlab-org/gitlab/-/issues/238464) | [**Yes** (13.11)](https://gitlab.com/gitlab-org/gitlab/-/issues/238464) | [**Yes** (15.1)](https://gitlab.com/groups/gitlab-org/-/epics/5551) | [No](object_storage.md#verification-of-files-in-object-storage) | Persists additional artifacts after a pipeline completes. |
+|[Container Registry](../../packages/container_registry.md) | **Yes** (12.3) | No | No | No | Disabled by default. See [instructions](docker_registry.md) to enable. |
+|[Infrastructure Registry](../../../user/packages/infrastructure_registry/index.md) | **Yes** (14.0) | **Yes** (14.0) | [**Yes** (15.1)](https://gitlab.com/groups/gitlab-org/-/epics/5551) | [No](object_storage.md#verification-of-files-in-object-storage) | Behind feature flag `geo_package_file_replication`, enabled by default. |
+|[Project designs repository](../../../user/project/issues/design_management.md) | **Yes** (12.7) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/32467) | N/A | N/A | Designs also require replication of LFS objects and Uploads. |
+|[Package Registry](../../../user/packages/package_registry/index.md) | **Yes** (13.2) | **Yes** (13.10) | [**Yes** (15.1)](https://gitlab.com/groups/gitlab-org/-/epics/5551) | [No](object_storage.md#verification-of-files-in-object-storage) | Behind feature flag `geo_package_file_replication`, enabled by default. |
+|[Versioned Terraform State](../../terraform_state.md) | **Yes** (13.5) | **Yes** (13.12) | [**Yes** (15.1)](https://gitlab.com/groups/gitlab-org/-/epics/5551) | [No](object_storage.md#verification-of-files-in-object-storage) | Replication is behind the feature flag `geo_terraform_state_version_replication`, enabled by default. Verification was behind the feature flag `geo_terraform_state_version_verification`, which was removed in 14.0. |
+|[External merge request diffs](../../merge_request_diffs.md) | **Yes** (13.5) | **Yes** (14.6) | [**Yes** (15.1)](https://gitlab.com/groups/gitlab-org/-/epics/5551) | [No](object_storage.md#verification-of-files-in-object-storage) | Replication is behind the feature flag `geo_merge_request_diff_replication`, enabled by default. Verification was behind the feature flag `geo_merge_request_diff_verification`, removed in 14.7.|
+|[Versioned snippets](../../../user/snippets.md#versioned-snippets) | [**Yes** (13.7)](https://gitlab.com/groups/gitlab-org/-/epics/2809) | [**Yes** (14.2)](https://gitlab.com/groups/gitlab-org/-/epics/2810) | N/A | N/A | Verification was implemented behind the feature flag `geo_snippet_repository_verification` in 13.11, and the feature flag was removed in 14.2. |
+|[GitLab Pages](../../pages/index.md) | [**Yes** (14.3)](https://gitlab.com/groups/gitlab-org/-/epics/589) | **Yes** (14.6) | [**Yes** (15.1)](https://gitlab.com/groups/gitlab-org/-/epics/5551) | [No](object_storage.md#verification-of-files-in-object-storage) | Behind feature flag `geo_pages_deployment_replication`, enabled by default. Verification was behind the feature flag `geo_pages_deployment_verification`, removed in 14.7. |
+|[Incident Metric Images](../../../operations/incident_management/incidents.md#metrics) | [Planned](https://gitlab.com/gitlab-org/gitlab/-/issues/352326) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/362561) | No | No | |
+|[Alert Metric Images](../../../operations/incident_management/alerts.md#metrics-tab) | [Planned](https://gitlab.com/gitlab-org/gitlab/-/issues/352326) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/362561) | No | No | |
+|[Server-side Git hooks](../../server_hooks.md) | [Not planned](https://gitlab.com/groups/gitlab-org/-/epics/1867) | No | N/A | N/A | Not planned because of current implementation complexity, low customer interest, and availability of alternatives to hooks. |
+|[Elasticsearch integration](../../../integration/elasticsearch.md) | [Not planned](https://gitlab.com/gitlab-org/gitlab/-/issues/1186) | No | No | No | Not planned because further product discovery is required and Elasticsearch (ES) clusters can be rebuilt. Secondaries use the same ES cluster as the primary. |
+|[Dependency proxy images](../../../user/packages/dependency_proxy/index.md) | [Not planned](https://gitlab.com/gitlab-org/gitlab/-/issues/259694) | No | No | No | Blocked by [Geo: Secondary Mimicry](https://gitlab.com/groups/gitlab-org/-/epics/1528). Replication of this cache is not needed for disaster recovery purposes because it can be recreated from external sources. |
+|[Vulnerability Export](../../../user/application_security/vulnerability_report/#export-vulnerability-details) | [Not planned](https://gitlab.com/groups/gitlab-org/-/epics/3111) | No | No | No | Not planned because they are ephemeral and sensitive information. They can be regenerated on demand. |
diff --git a/doc/administration/geo/replication/geo_validation_tests.md b/doc/administration/geo/replication/geo_validation_tests.md
index 6540366bf7f..7b59cdda1aa 100644
--- a/doc/administration/geo/replication/geo_validation_tests.md
+++ b/doc/administration/geo/replication/geo_validation_tests.md
@@ -181,7 +181,7 @@ The following are PostgreSQL upgrade validation tests we performed.
- [Geo multi-node upgrade from 12.0.9 to 12.1.9 does not upgrade PostgreSQL](https://gitlab.com/gitlab-org/omnibus-gitlab/-/issues/4705).
- [Refresh foreign tables fails on app server in multi-node setup after upgrade to 12.1.9](https://gitlab.com/gitlab-org/gitlab/-/issues/32119).
-## Other tests
+## Object storage replication tests
The following are additional validation tests we performed.
@@ -194,13 +194,6 @@ The following are additional validation tests we performed.
- Follow up issues:
- [Geo: Failing to replicate initial Monitoring project](https://gitlab.com/gitlab-org/gitlab/-/issues/330485)
-### August 2020
-
-[Test Gitaly Cluster on a Geo Deployment](https://gitlab.com/gitlab-org/gitlab/-/issues/223210):
-
-- Description: Tested a Geo deployment with Gitaly clusters configured on both the primary and secondary Geo sites. Triggered automatic Gitaly cluster failover on the primary Geo site, and ran end-to-end Geo tests. Then triggered Gitaly cluster failover on the secondary Geo site, and re-ran the end-to-end Geo tests.
-- Outcome: Successful end-to-end tests before and after Gitaly cluster failover on the primary site, and before and after Gitaly cluster failover on the secondary site.
-
### January 2022
[Validate Object storage replication using Azure based object storage](https://gitlab.com/gitlab-org/gitlab/-/issues/348804#note_821294631):
@@ -221,3 +214,12 @@ The following are additional validation tests we performed.
- Description: Tested the average time it takes for a single image to replicate from the primary object storage location to the secondary when using GCP based object storage replication and [GitLab based object storage replication](object_storage.md#enabling-gitlab-managed-object-storage-replication). This was tested by uploading a 1mb image to a project on the primary site every second for 60 seconds. The time was then measured until a image was available on the secondary site. This was achieved using a [Ruby Script](https://gitlab.com/gitlab-org/quality/geo-replication-tester).
- Outcome: GCP handles replication differently than other Cloud Providers. In GCP, the process is to a create single bucket that is either multi, dual or single region based. This means that the bucket will automatically store replicas in a region based on the option chosen. Even when using multi region, this will still only replicate within a single continent, the options being America, Europe, or Asia. At current there doesn't seem to be any way to replicate objects between continents using GCP based replication. For Geo managed replication the average time when replicating within the same region was 6 seconds, and when replicating cross region this rose to just 9 seconds.
+
+## Other tests
+
+### August 2020
+
+[Test Gitaly Cluster on a Geo Deployment](https://gitlab.com/gitlab-org/gitlab/-/issues/223210):
+
+- Description: Tested a Geo deployment with Gitaly clusters configured on both the primary and secondary Geo sites. Triggered automatic Gitaly cluster failover on the primary Geo site, and ran end-to-end Geo tests. Then triggered Gitaly cluster failover on the secondary Geo site, and re-ran the end-to-end Geo tests.
+- Outcome: Successful end-to-end tests before and after Gitaly cluster failover on the primary site, and before and after Gitaly cluster failover on the secondary site.
diff --git a/doc/administration/geo/replication/object_storage.md b/doc/administration/geo/replication/object_storage.md
index 9ea226fd93b..dd1f982b3a1 100644
--- a/doc/administration/geo/replication/object_storage.md
+++ b/doc/administration/geo/replication/object_storage.md
@@ -28,14 +28,14 @@ To have:
- GitLab manage replication, follow [Enabling GitLab replication](#enabling-gitlab-managed-object-storage-replication).
- Third-party services manage replication, follow [Third-party replication services](#third-party-replication-services).
+See [Object storage replication tests](geo_validation_tests.md#object-storage-replication-tests) for comparisons between GitLab-managed replication and third-party replication.
+
[Read more about using object storage with GitLab](../../object_storage.md).
## Enabling GitLab-managed object storage replication
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/10586) in GitLab 12.4.
-
-WARNING:
-This is a [**Beta** feature](../../../policy/alpha-beta-support.md#beta-features) and is not ready yet for production use at any scale. The main limitations are a lack of testing at scale and no verification of any replicated data.
+> The beta feature was [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/10586) in GitLab 12.4.
+> The feature was made [generally available] in GitLab 15.1.
**Secondary** sites can replicate files stored on the **primary** site regardless of
whether they are stored on the local file system or in object storage.
@@ -86,3 +86,7 @@ For manual synchronization, or scheduled by `cron`, see:
- [`s3cmd sync`](https://s3tools.org/s3cmd-sync)
- [`gsutil rsync`](https://cloud.google.com/storage/docs/gsutil/commands/rsync)
+
+## Verification of files in object storage
+
+[Files stored in object storage are not verified.](https://gitlab.com/groups/gitlab-org/-/epics/8056)
diff --git a/doc/administration/gitaly/configure_gitaly.md b/doc/administration/gitaly/configure_gitaly.md
index 26aa750900b..426d07b154d 100644
--- a/doc/administration/gitaly/configure_gitaly.md
+++ b/doc/administration/gitaly/configure_gitaly.md
@@ -825,7 +825,9 @@ information, see the [relevant documentation](monitoring.md#monitor-gitaly-concu
## Control groups
-> Introduced in GitLab 13.10.
+FLAG:
+On self-managed GitLab, by default cgroups are not available. To make it available, ask an administrator to
+[enable the feature flag](../feature_flags.md) named `gitaly_run_cmds_in_cgroup`.
Gitaly shells out to Git for many of its operations. Git can consume a lot of resources for certain operations,
especially for large repositories.
@@ -862,10 +864,6 @@ Two ways of configuring cgroups are available.
> This method of configuring cgroups introduced in GitLab 15.1.
-FLAG:
-On self-managed GitLab, by default this method of configuring cgroups is not available. To make it available, ask an administrator to
-[enable the feature flag](../feature_flags.md) named `gitaly_run_cmds_in_cgroup`.
-
Gitaly creates a pool of cgroups that are isolated based on the repository used in the Git command to be placed under one of these cgroups.
To configure cgroups in Gitaly, add `gitaly['cgroups']` to `/etc/gitlab/gitlab.rb`.
diff --git a/doc/administration/gitaly/monitoring.md b/doc/administration/gitaly/monitoring.md
index a0a2e43569c..ac0c4cf139d 100644
--- a/doc/administration/gitaly/monitoring.md
+++ b/doc/administration/gitaly/monitoring.md
@@ -48,8 +48,8 @@ the Gitaly logs and Prometheus:
You can observe the status of [control groups (cgroups)](configure_gitaly.md#control-groups) using Prometheus:
-- `gitaly_cgroups_memory_failed_total`, a gauge for the total number of times
- the memory limit has been hit. This number resets each time a server is
+- `gitaly_cgroups_reclaim_attempts_total`, a gauge for the total number of times
+ there has been a memory relcaim attempt. This number resets each time a server is
restarted.
- `gitaly_cgroups_cpu_usage`, a gauge that measures CPU usage per cgroup.
- `gitaly_cgroup_procs_total`, a gauge that measures the total number of
diff --git a/doc/administration/object_storage.md b/doc/administration/object_storage.md
index 7e416ed560a..7e6dc51d27c 100644
--- a/doc/administration/object_storage.md
+++ b/doc/administration/object_storage.md
@@ -573,6 +573,15 @@ This ensures there are no collisions across the various types of data GitLab sto
There are plans to [enable the use of a single bucket](https://gitlab.com/gitlab-org/gitlab/-/issues/292958)
in the future.
+With Omnibus and source installations it is possible to split a single
+real bucket into multiple virtual buckets. If your object storage
+bucket is called `my-gitlab-objects` you can configure uploads to go
+into `my-gitlab-objects/uploads`, artifacts into
+`my-gitlab-objects/artifacts`, etc. The application will act as if
+these are separate buckets. Note that use of bucket prefixes [may not
+work correctly with Helm
+backups](https://gitlab.com/gitlab-org/charts/gitlab/-/issues/3376).
+
Helm-based installs require separate buckets to
[handle backup restorations](https://docs.gitlab.com/charts/advanced/external-object-storage/#lfs-artifacts-uploads-packages-external-diffs-terraform-state-dependency-proxy).
diff --git a/lib/gitlab/kubernetes/kube_client.rb b/lib/gitlab/kubernetes/kube_client.rb
index cd03e332175..92ffa65fe74 100644
--- a/lib/gitlab/kubernetes/kube_client.rb
+++ b/lib/gitlab/kubernetes/kube_client.rb
@@ -81,6 +81,10 @@ module Gitlab
:update_gateway,
to: :istio_client
+ delegate :get_ingresses, :patch_ingress, to: :networking_client
+
+ delegate :get_deployments, to: :apps_client
+
attr_reader :api_prefix, :kubeclient_options
DEFAULT_KUBECLIENT_OPTIONS = {
@@ -127,46 +131,6 @@ module Gitlab
validate_url!
end
- # Deployments resource is currently on the apis/extensions api group
- # until Kubernetes 1.15. Kubernetest 1.16+ has deployments resources in
- # the apis/apps api group.
- #
- # As we still support Kubernetes 1.12+, we will need to support both.
- def get_deployments(**args)
- extensions_client.discover unless extensions_client.discovered
-
- if extensions_client.respond_to?(:get_deployments)
- extensions_client.get_deployments(**args)
- else
- apps_client.get_deployments(**args)
- end
- end
-
- # Ingresses resource is currently on the apis/extensions api group
- # until Kubernetes 1.21. Kubernetest 1.22+ has ingresses resources in
- # the networking.k8s.io/v1 api group.
- #
- # As we still support Kubernetes 1.12+, we will need to support both.
- def get_ingresses(**args)
- extensions_client.discover unless extensions_client.discovered
-
- if extensions_client.respond_to?(:get_ingresses)
- extensions_client.get_ingresses(**args)
- else
- networking_client.get_ingresses(**args)
- end
- end
-
- def patch_ingress(*args)
- extensions_client.discover unless extensions_client.discovered
-
- if extensions_client.respond_to?(:patch_ingress)
- extensions_client.patch_ingress(*args)
- else
- networking_client.patch_ingress(*args)
- end
- end
-
def create_or_update_cluster_role_binding(resource)
update_cluster_role_binding(resource)
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index c859cfa2618..566ec3633fc 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -16617,9 +16617,6 @@ msgstr ""
msgid "Geo|Allowed Geo IP should contain valid IP addresses"
msgstr ""
-msgid "Geo|Beta"
-msgstr ""
-
msgid "Geo|Checksummed"
msgstr ""
diff --git a/spec/config/object_store_settings_spec.rb b/spec/config/object_store_settings_spec.rb
index 56ad0943377..1555124fe03 100644
--- a/spec/config/object_store_settings_spec.rb
+++ b/spec/config/object_store_settings_spec.rb
@@ -73,6 +73,7 @@ RSpec.describe ObjectStoreSettings do
expect(settings.artifacts['object_store']['background_upload']).to be false
expect(settings.artifacts['object_store']['proxy_download']).to be false
expect(settings.artifacts['object_store']['remote_directory']).to eq('artifacts')
+ expect(settings.artifacts['object_store']['bucket_prefix']).to eq(nil)
expect(settings.artifacts['object_store']['consolidated_settings']).to be true
expect(settings.artifacts).to eq(settings['artifacts'])
@@ -83,6 +84,7 @@ RSpec.describe ObjectStoreSettings do
expect(settings.lfs['object_store']['background_upload']).to be false
expect(settings.lfs['object_store']['proxy_download']).to be true
expect(settings.lfs['object_store']['remote_directory']).to eq('lfs-objects')
+ expect(settings.lfs['object_store']['bucket_prefix']).to eq(nil)
expect(settings.lfs['object_store']['consolidated_settings']).to be true
expect(settings.lfs).to eq(settings['lfs'])
@@ -90,6 +92,7 @@ RSpec.describe ObjectStoreSettings do
expect(settings.pages['object_store']['enabled']).to be true
expect(settings.pages['object_store']['connection']).to eq(connection)
expect(settings.pages['object_store']['remote_directory']).to eq('pages')
+ expect(settings.pages['object_store']['bucket_prefix']).to eq(nil)
expect(settings.pages['object_store']['consolidated_settings']).to be true
expect(settings.pages).to eq(settings['pages'])
@@ -98,6 +101,18 @@ RSpec.describe ObjectStoreSettings do
expect(settings.external_diffs).to eq(settings['external_diffs'])
end
+ it 'supports bucket prefixes' do
+ config['object_store']['objects']['artifacts']['bucket'] = 'gitlab/artifacts'
+ config['object_store']['objects']['lfs']['bucket'] = 'gitlab/lfs'
+
+ subject
+
+ expect(settings.artifacts['object_store']['remote_directory']).to eq('gitlab')
+ expect(settings.artifacts['object_store']['bucket_prefix']).to eq('artifacts')
+ expect(settings.lfs['object_store']['remote_directory']).to eq('gitlab')
+ expect(settings.lfs['object_store']['bucket_prefix']).to eq('lfs')
+ end
+
it 'raises an error when a bucket is missing' do
config['object_store']['objects']['lfs'].delete('bucket')
@@ -152,6 +167,7 @@ RSpec.describe ObjectStoreSettings do
expect(settings.artifacts['enabled']).to be true
expect(settings.artifacts['object_store']['remote_directory']).to be_nil
+ expect(settings.artifacts['object_store']['bucket_prefix']).to be_nil
expect(settings.artifacts['object_store']['enabled']).to be_falsey
expect(settings.artifacts['object_store']['consolidated_settings']).to be_falsey
end
@@ -177,6 +193,7 @@ RSpec.describe ObjectStoreSettings do
expect(settings.artifacts['object_store']).to be_nil
expect(settings.lfs['object_store']['remote_directory']).to eq('some-bucket')
+ expect(settings.lfs['object_store']['bucket_prefix']).to eq(nil)
# Disable background_upload, regardless of the input config
expect(settings.lfs['object_store']['direct_upload']).to eq(true)
expect(settings.lfs['object_store']['background_upload']).to eq(false)
@@ -203,6 +220,7 @@ RSpec.describe ObjectStoreSettings do
expect(settings.artifacts['object_store']).to be_nil
expect(settings.lfs['object_store']['remote_directory']).to eq('some-bucket')
+ expect(settings.lfs['object_store']['bucket_prefix']).to eq(nil)
# Enable background_upload if the environment variable is available
expect(settings.lfs['object_store']['direct_upload']).to eq(false)
expect(settings.lfs['object_store']['background_upload']).to eq(true)
@@ -220,6 +238,7 @@ RSpec.describe ObjectStoreSettings do
expect(settings['direct_upload']).to be true
expect(settings['background_upload']).to be false
expect(settings['remote_directory']).to be nil
+ expect(settings['bucket_prefix']).to be nil
end
it 'respects original values' do
@@ -234,6 +253,18 @@ RSpec.describe ObjectStoreSettings do
expect(settings['direct_upload']).to be true
expect(settings['background_upload']).to be false
expect(settings['remote_directory']).to eq 'artifacts'
+ expect(settings['bucket_prefix']).to be nil
+ end
+
+ it 'supports bucket prefixes' do
+ original_settings = Settingslogic.new({
+ 'enabled' => true,
+ 'remote_directory' => 'gitlab/artifacts'
+ })
+
+ settings = described_class.legacy_parse(original_settings, 'artifacts')
+ expect(settings['remote_directory']).to eq 'gitlab'
+ expect(settings['bucket_prefix']).to eq 'artifacts'
end
context 'legacy background upload environment variable is enabled' do
@@ -253,6 +284,7 @@ RSpec.describe ObjectStoreSettings do
expect(settings['direct_upload']).to be false
expect(settings['background_upload']).to be true
expect(settings['remote_directory']).to eq 'artifacts'
+ expect(settings['bucket_prefix']).to eq nil
end
end
@@ -273,6 +305,50 @@ RSpec.describe ObjectStoreSettings do
expect(settings['direct_upload']).to be true
expect(settings['background_upload']).to be false
expect(settings['remote_directory']).to eq 'artifacts'
+ expect(settings['bucket_prefix']).to eq nil
+ end
+ end
+ end
+
+ describe '.split_bucket_prefix' do
+ using RSpec::Parameterized::TableSyntax
+
+ subject { described_class.split_bucket_prefix(input) }
+
+ context 'valid inputs' do
+ where(:input, :bucket, :prefix) do
+ nil | nil | nil
+ '' | nil | nil
+ 'bucket' | 'bucket' | nil
+ 'bucket/prefix' | 'bucket' | 'prefix'
+ 'bucket/pre/fix' | 'bucket' | 'pre/fix'
+ end
+
+ with_them do
+ it { expect(subject).to eq([bucket, prefix]) }
+ end
+ end
+
+ context 'invalid inputs' do
+ where(:input) do
+ [
+ ['bucket/'],
+ ['bucket/.'],
+ ['bucket/..'],
+ ['bucket/prefix/'],
+ ['bucket/prefix/.'],
+ ['bucket/prefix/..'],
+ ['/bucket/prefix'],
+ ['./bucket/prefix'],
+ ['../bucket/prefix'],
+ ['bucket//prefix'],
+ ['bucket/./prefix'],
+ ['bucket/../prefix']
+ ]
+ end
+
+ with_them do
+ it { expect { subject }.to raise_error(/invalid bucket/) }
end
end
end
diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb
index c323e60bb71..a1e92a79516 100644
--- a/spec/features/projects/new_project_spec.rb
+++ b/spec/features/projects/new_project_spec.rb
@@ -298,6 +298,20 @@ RSpec.describe 'New project', :js do
end
end
+ context 'Import project options without any sources', :js do
+ before do
+ stub_application_setting(import_sources: [])
+
+ visit new_project_path
+ click_link 'Import project'
+ end
+
+ it 'displays the no import options message' do
+ expect(page).to have_text s_('ProjectsNew|No import options available')
+ expect(page).to have_text s_('ProjectsNew|Contact an administrator to enable options for importing your project.')
+ end
+ end
+
context 'Import project options', :js do
before do
visit new_project_path
diff --git a/spec/frontend/clusters/agents/components/create_token_button_spec.js b/spec/frontend/clusters/agents/components/create_token_button_spec.js
index fb1a3aa2963..73856b74a8d 100644
--- a/spec/frontend/clusters/agents/components/create_token_button_spec.js
+++ b/spec/frontend/clusters/agents/components/create_token_button_spec.js
@@ -1,262 +1,71 @@
-import { GlButton, GlTooltip, GlModal, GlFormInput, GlFormTextarea, GlAlert } from '@gitlab/ui';
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import createMockApollo from 'helpers/mock_apollo_helper';
-import waitForPromises from 'helpers/wait_for_promises';
+import { GlButton, GlTooltip } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import { mockTracking } from 'helpers/tracking_helper';
-import {
- EVENT_LABEL_MODAL,
- EVENT_ACTIONS_OPEN,
- TOKEN_NAME_LIMIT,
- TOKEN_STATUS_ACTIVE,
- MAX_LIST_COUNT,
- CREATE_TOKEN_MODAL,
-} from '~/clusters/agents/constants';
-import createNewAgentToken from '~/clusters/agents/graphql/mutations/create_new_agent_token.mutation.graphql';
-import getClusterAgentQuery from '~/clusters/agents/graphql/queries/get_cluster_agent.query.graphql';
-import AgentToken from '~/clusters_list/components/agent_token.vue';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import CreateTokenButton from '~/clusters/agents/components/create_token_button.vue';
-import {
- clusterAgentToken,
- getTokenResponse,
- createAgentTokenErrorResponse,
-} from '../../mock_data';
-
-Vue.use(VueApollo);
+import { CREATE_TOKEN_MODAL } from '~/clusters/agents/constants';
describe('CreateTokenButton', () => {
let wrapper;
- let apolloProvider;
- let trackingSpy;
- let createResponse;
-
- const clusterAgentId = 'cluster-agent-id';
- const cursor = {
- first: MAX_LIST_COUNT,
- last: null,
- };
- const agentName = 'cluster-agent';
- const projectPath = 'path/to/project';
const defaultProvide = {
- agentName,
- projectPath,
canAdminCluster: true,
};
- const propsData = {
- clusterAgentId,
- cursor,
- };
- const findModal = () => wrapper.findComponent(GlModal);
- const findBtn = () => wrapper.findComponent(GlButton);
- const findInput = () => wrapper.findComponent(GlFormInput);
- const findTextarea = () => wrapper.findComponent(GlFormTextarea);
- const findAlert = () => wrapper.findComponent(GlAlert);
+ const findButton = () => wrapper.findComponent(GlButton);
const findTooltip = () => wrapper.findComponent(GlTooltip);
- const findAgentInstructions = () => findModal().findComponent(AgentToken);
- const findButtonByVariant = (variant) =>
- findModal()
- .findAll(GlButton)
- .wrappers.find((button) => button.props('variant') === variant);
- const findActionButton = () => findButtonByVariant('confirm');
- const findCancelButton = () => wrapper.findByTestId('agent-token-close-button');
-
- const expectDisabledAttribute = (element, disabled) => {
- if (disabled) {
- expect(element.attributes('disabled')).toBe('true');
- } else {
- expect(element.attributes('disabled')).toBeUndefined();
- }
- };
-
- const createMockApolloProvider = ({ mutationResponse }) => {
- createResponse = jest.fn().mockResolvedValue(mutationResponse);
-
- return createMockApollo([[createNewAgentToken, createResponse]]);
- };
-
- const writeQuery = () => {
- apolloProvider.clients.defaultClient.cache.writeQuery({
- query: getClusterAgentQuery,
- data: getTokenResponse.data,
- variables: {
- agentName,
- projectPath,
- tokenStatus: TOKEN_STATUS_ACTIVE,
- ...cursor,
- },
- });
- };
- const createWrapper = async ({ provideData = {} } = {}) => {
+ const createWrapper = ({ provideData = {} } = {}) => {
wrapper = shallowMountExtended(CreateTokenButton, {
- apolloProvider,
provide: {
...defaultProvide,
...provideData,
},
- propsData,
+ directives: {
+ GlModalDirective: createMockDirective(),
+ },
stubs: {
- GlModal,
GlTooltip,
},
});
- wrapper.vm.$refs.modal.hide = jest.fn();
-
- trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
};
- const mockCreatedResponse = (mutationResponse) => {
- apolloProvider = createMockApolloProvider({ mutationResponse });
- writeQuery();
-
- createWrapper();
-
- findInput().vm.$emit('input', 'new-token');
- findTextarea().vm.$emit('input', 'new-token-description');
- findActionButton().vm.$emit('click');
-
- return waitForPromises();
- };
-
- beforeEach(() => {
- createWrapper();
- });
-
afterEach(() => {
wrapper.destroy();
- apolloProvider = null;
- createResponse = null;
});
- describe('create agent token action', () => {
- it('displays create agent token button', () => {
- expect(findBtn().text()).toBe('Create token');
+ describe('when user can create token', () => {
+ beforeEach(() => {
+ createWrapper();
});
- describe('when user cannot create token', () => {
- beforeEach(() => {
- createWrapper({ provideData: { canAdminCluster: false } });
- });
-
- it('disabled the button', () => {
- expect(findBtn().attributes('disabled')).toBe('true');
- });
-
- it('shows a disabled tooltip', () => {
- expect(findTooltip().attributes('title')).toBe(
- 'Requires a Maintainer or greater role to perform these actions',
- );
- });
+ it('displays create agent token button', () => {
+ expect(findButton().text()).toBe('Create token');
});
- describe('when user can create a token and clicks the button', () => {
- beforeEach(() => {
- findBtn().vm.$emit('click');
- });
-
- it('displays a token creation modal', () => {
- expect(findModal().isVisible()).toBe(true);
- });
-
- describe('initial state', () => {
- it('renders an input for the token name', () => {
- expect(findInput().exists()).toBe(true);
- expectDisabledAttribute(findInput(), false);
- expect(findInput().attributes('max-length')).toBe(TOKEN_NAME_LIMIT.toString());
- });
-
- it('renders a textarea for the token description', () => {
- expect(findTextarea().exists()).toBe(true);
- expectDisabledAttribute(findTextarea(), false);
- });
-
- it('renders a cancel button', () => {
- expect(findCancelButton().isVisible()).toBe(true);
- expectDisabledAttribute(findCancelButton(), false);
- });
-
- it('renders a disabled next button', () => {
- expect(findActionButton().text()).toBe('Create token');
- expectDisabledAttribute(findActionButton(), true);
- });
-
- it('sends tracking event for modal shown', () => {
- findModal().vm.$emit('show');
- expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTIONS_OPEN, {
- label: EVENT_LABEL_MODAL,
- });
- });
- });
-
- describe('when user inputs the token name', () => {
- beforeEach(() => {
- expectDisabledAttribute(findActionButton(), true);
- findInput().vm.$emit('input', 'new-token');
- });
-
- it('enables the next button', () => {
- expectDisabledAttribute(findActionButton(), false);
- });
- });
-
- describe('when user clicks the create-token button', () => {
- beforeEach(async () => {
- const loadingResponse = new Promise(() => {});
- await mockCreatedResponse(loadingResponse);
-
- findInput().vm.$emit('input', 'new-token');
- findActionButton().vm.$emit('click');
- });
-
- it('disables the create-token button', () => {
- expectDisabledAttribute(findActionButton(), true);
- });
-
- it('hides the cancel button', () => {
- expect(findCancelButton().exists()).toBe(false);
- });
- });
-
- describe('creating a new token', () => {
- beforeEach(async () => {
- await mockCreatedResponse(clusterAgentToken);
- });
+ it('displays create agent token button as not disabled', () => {
+ expect(findButton().attributes('disabled')).toBeUndefined();
+ });
- it('creates a token', () => {
- expect(createResponse).toHaveBeenCalledWith({
- input: { clusterAgentId, name: 'new-token', description: 'new-token-description' },
- });
- });
+ it('triggers the modal', () => {
+ const binding = getBinding(findButton().element, 'gl-modal-directive');
- it('shows agent instructions', () => {
- expect(findAgentInstructions().props()).toMatchObject({
- agentName,
- agentToken: 'token-secret',
- modalId: CREATE_TOKEN_MODAL,
- });
- });
+ expect(binding.value).toBe(CREATE_TOKEN_MODAL);
+ });
+ });
- it('renders a close button', () => {
- expect(findActionButton().isVisible()).toBe(true);
- expect(findActionButton().text()).toBe('Close');
- expectDisabledAttribute(findActionButton(), false);
- });
- });
+ describe('when user cannot create token', () => {
+ beforeEach(() => {
+ createWrapper({ provideData: { canAdminCluster: false } });
+ });
- describe('error creating a new token', () => {
- beforeEach(async () => {
- await mockCreatedResponse(createAgentTokenErrorResponse);
- });
+ it('disabled the button', () => {
+ expect(findButton().attributes('disabled')).toBe('true');
+ });
- it('displays the error message', async () => {
- expect(findAlert().text()).toBe(
- createAgentTokenErrorResponse.data.clusterAgentTokenCreate.errors[0],
- );
- });
- });
+ it('shows a disabled tooltip', () => {
+ expect(findTooltip().attributes('title')).toBe(
+ 'Requires a Maintainer or greater role to perform these actions',
+ );
});
});
});
diff --git a/spec/frontend/clusters/agents/components/create_token_modal_spec.js b/spec/frontend/clusters/agents/components/create_token_modal_spec.js
new file mode 100644
index 00000000000..ad48afe10b6
--- /dev/null
+++ b/spec/frontend/clusters/agents/components/create_token_modal_spec.js
@@ -0,0 +1,223 @@
+import { GlButton, GlModal, GlFormInput, GlFormTextarea, GlAlert } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { mockTracking } from 'helpers/tracking_helper';
+import {
+ EVENT_LABEL_MODAL,
+ EVENT_ACTIONS_OPEN,
+ TOKEN_NAME_LIMIT,
+ TOKEN_STATUS_ACTIVE,
+ MAX_LIST_COUNT,
+ CREATE_TOKEN_MODAL,
+} from '~/clusters/agents/constants';
+import createNewAgentToken from '~/clusters/agents/graphql/mutations/create_new_agent_token.mutation.graphql';
+import getClusterAgentQuery from '~/clusters/agents/graphql/queries/get_cluster_agent.query.graphql';
+import AgentToken from '~/clusters_list/components/agent_token.vue';
+import CreateTokenModal from '~/clusters/agents/components/create_token_modal.vue';
+import {
+ clusterAgentToken,
+ getTokenResponse,
+ createAgentTokenErrorResponse,
+} from '../../mock_data';
+
+Vue.use(VueApollo);
+
+describe('CreateTokenModal', () => {
+ let wrapper;
+ let apolloProvider;
+ let trackingSpy;
+ let createResponse;
+
+ const clusterAgentId = 'cluster-agent-id';
+ const cursor = {
+ first: MAX_LIST_COUNT,
+ last: null,
+ };
+ const agentName = 'cluster-agent';
+ const projectPath = 'path/to/project';
+
+ const provide = {
+ agentName,
+ projectPath,
+ };
+ const propsData = {
+ clusterAgentId,
+ cursor,
+ };
+
+ const findModal = () => wrapper.findComponent(GlModal);
+ const findInput = () => wrapper.findComponent(GlFormInput);
+ const findTextarea = () => wrapper.findComponent(GlFormTextarea);
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findAgentInstructions = () => findModal().findComponent(AgentToken);
+ const findButtonByVariant = (variant) =>
+ findModal()
+ .findAll(GlButton)
+ .wrappers.find((button) => button.props('variant') === variant);
+ const findActionButton = () => findButtonByVariant('confirm');
+ const findCancelButton = () => wrapper.findByTestId('agent-token-close-button');
+
+ const expectDisabledAttribute = (element, disabled) => {
+ if (disabled) {
+ expect(element.attributes('disabled')).toBe('true');
+ } else {
+ expect(element.attributes('disabled')).toBeUndefined();
+ }
+ };
+
+ const createMockApolloProvider = ({ mutationResponse }) => {
+ createResponse = jest.fn().mockResolvedValue(mutationResponse);
+
+ return createMockApollo([[createNewAgentToken, createResponse]]);
+ };
+
+ const writeQuery = () => {
+ apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: getClusterAgentQuery,
+ data: getTokenResponse.data,
+ variables: {
+ agentName,
+ projectPath,
+ tokenStatus: TOKEN_STATUS_ACTIVE,
+ ...cursor,
+ },
+ });
+ };
+
+ const createWrapper = () => {
+ wrapper = shallowMountExtended(CreateTokenModal, {
+ apolloProvider,
+ provide,
+ propsData,
+ stubs: {
+ GlModal,
+ },
+ });
+ wrapper.vm.$refs.modal.hide = jest.fn();
+
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ };
+
+ const mockCreatedResponse = (mutationResponse) => {
+ apolloProvider = createMockApolloProvider({ mutationResponse });
+ writeQuery();
+
+ createWrapper();
+
+ findInput().vm.$emit('input', 'new-token');
+ findTextarea().vm.$emit('input', 'new-token-description');
+ findActionButton().vm.$emit('click');
+
+ return waitForPromises();
+ };
+
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ apolloProvider = null;
+ createResponse = null;
+ });
+
+ describe('initial state', () => {
+ it('renders an input for the token name', () => {
+ expect(findInput().exists()).toBe(true);
+ expectDisabledAttribute(findInput(), false);
+ expect(findInput().attributes('max-length')).toBe(TOKEN_NAME_LIMIT.toString());
+ });
+
+ it('renders a textarea for the token description', () => {
+ expect(findTextarea().exists()).toBe(true);
+ expectDisabledAttribute(findTextarea(), false);
+ });
+
+ it('renders a cancel button', () => {
+ expect(findCancelButton().isVisible()).toBe(true);
+ expectDisabledAttribute(findCancelButton(), false);
+ });
+
+ it('renders a disabled next button', () => {
+ expect(findActionButton().text()).toBe('Create token');
+ expectDisabledAttribute(findActionButton(), true);
+ });
+
+ it('sends tracking event for modal shown', () => {
+ findModal().vm.$emit('show');
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTIONS_OPEN, {
+ label: EVENT_LABEL_MODAL,
+ });
+ });
+ });
+
+ describe('when user inputs the token name', () => {
+ beforeEach(() => {
+ expectDisabledAttribute(findActionButton(), true);
+ findInput().vm.$emit('input', 'new-token');
+ });
+
+ it('enables the next button', () => {
+ expectDisabledAttribute(findActionButton(), false);
+ });
+ });
+
+ describe('when user clicks the create-token button', () => {
+ beforeEach(async () => {
+ const loadingResponse = new Promise(() => {});
+ await mockCreatedResponse(loadingResponse);
+
+ findInput().vm.$emit('input', 'new-token');
+ findActionButton().vm.$emit('click');
+ });
+
+ it('disables the create-token button', () => {
+ expectDisabledAttribute(findActionButton(), true);
+ });
+
+ it('hides the cancel button', () => {
+ expect(findCancelButton().exists()).toBe(false);
+ });
+ });
+
+ describe('creating a new token', () => {
+ beforeEach(async () => {
+ await mockCreatedResponse(clusterAgentToken);
+ });
+
+ it('creates a token', () => {
+ expect(createResponse).toHaveBeenCalledWith({
+ input: { clusterAgentId, name: 'new-token', description: 'new-token-description' },
+ });
+ });
+
+ it('shows agent instructions', () => {
+ expect(findAgentInstructions().props()).toMatchObject({
+ agentName,
+ agentToken: 'token-secret',
+ modalId: CREATE_TOKEN_MODAL,
+ });
+ });
+
+ it('renders a close button', () => {
+ expect(findActionButton().isVisible()).toBe(true);
+ expect(findActionButton().text()).toBe('Close');
+ expectDisabledAttribute(findActionButton(), false);
+ });
+ });
+
+ describe('error creating a new token', () => {
+ beforeEach(async () => {
+ await mockCreatedResponse(createAgentTokenErrorResponse);
+ });
+
+ it('displays the error message', async () => {
+ expect(findAlert().text()).toBe(
+ createAgentTokenErrorResponse.data.clusterAgentTokenCreate.errors[0],
+ );
+ });
+ });
+});
diff --git a/spec/frontend/clusters/agents/components/token_table_spec.js b/spec/frontend/clusters/agents/components/token_table_spec.js
index f6baaf87fa4..6caeaf5c192 100644
--- a/spec/frontend/clusters/agents/components/token_table_spec.js
+++ b/spec/frontend/clusters/agents/components/token_table_spec.js
@@ -2,6 +2,7 @@ import { GlEmptyState, GlTooltip, GlTruncate } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import TokenTable from '~/clusters/agents/components/token_table.vue';
import CreateTokenButton from '~/clusters/agents/components/create_token_button.vue';
+import CreateTokenModal from '~/clusters/agents/components/create_token_modal.vue';
import { useFakeDate } from 'helpers/fake_date';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { MAX_LIST_COUNT } from '~/clusters/agents/constants';
@@ -50,6 +51,7 @@ describe('ClusterAgentTokenTable', () => {
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findCreateTokenBtn = () => wrapper.findComponent(CreateTokenButton);
+ const findCreateModal = () => wrapper.findComponent(CreateTokenModal);
beforeEach(() => {
return createComponent(defaultTokens);
@@ -63,8 +65,8 @@ describe('ClusterAgentTokenTable', () => {
expect(findCreateTokenBtn().exists()).toBe(true);
});
- it('passes the correct params to the create token component', () => {
- expect(findCreateTokenBtn().props()).toMatchObject({
+ it('passes the correct params to the create token modal component', () => {
+ expect(findCreateModal().props()).toMatchObject({
clusterAgentId,
cursor,
});
diff --git a/spec/lib/gitlab/kubernetes/kube_client_spec.rb b/spec/lib/gitlab/kubernetes/kube_client_spec.rb
index dfd5092b54d..8abd041fd4e 100644
--- a/spec/lib/gitlab/kubernetes/kube_client_spec.rb
+++ b/spec/lib/gitlab/kubernetes/kube_client_spec.rb
@@ -171,20 +171,6 @@ RSpec.describe Gitlab::Kubernetes::KubeClient do
end
end
- describe '#extensions_client' do
- subject { client.extensions_client }
-
- it_behaves_like 'a Kubeclient'
-
- it 'has the extensions API group endpoint' do
- expect(subject.api_endpoint.to_s).to match(%r{\/apis\/extensions\Z})
- end
-
- it 'has the api_version' do
- expect(subject.instance_variable_get(:@api_version)).to eq('v1beta1')
- end
- end
-
describe '#istio_client' do
subject { client.istio_client }
@@ -307,86 +293,38 @@ RSpec.describe Gitlab::Kubernetes::KubeClient do
end
end
- describe '#get_deployments' do
- let(:extensions_client) { client.extensions_client }
+ describe 'apps/v1 API group' do
let(:apps_client) { client.apps_client }
- include_examples 'redirection not allowed', 'get_deployments'
- include_examples 'dns rebinding not allowed', 'get_deployments'
-
- it 'delegates to the extensions client' do
- expect(extensions_client).to receive(:get_deployments)
-
- client.get_deployments
- end
-
- context 'extensions does not have deployments for Kubernetes 1.16+ clusters' do
- before do
- WebMock
- .stub_request(:get, api_url + '/apis/extensions/v1beta1')
- .to_return(kube_response(kube_1_16_extensions_v1beta1_discovery_body))
- end
+ describe 'get_deployments' do
+ include_examples 'redirection not allowed', 'get_deployments'
+ include_examples 'dns rebinding not allowed', 'get_deployments'
it 'delegates to the apps client' do
- expect(apps_client).to receive(:get_deployments)
-
- client.get_deployments
+ expect(client).to delegate_method(:get_deployments).to(:apps_client)
end
- end
- end
-
- describe '#get_ingresses' do
- let(:extensions_client) { client.extensions_client }
- let(:networking_client) { client.networking_client }
-
- include_examples 'redirection not allowed', 'get_ingresses'
- include_examples 'dns rebinding not allowed', 'get_ingresses'
- it 'delegates to the extensions client' do
- expect(extensions_client).to receive(:get_ingresses)
-
- client.get_ingresses
- end
-
- context 'extensions does not have deployments for Kubernetes 1.22+ clusters' do
- before do
- WebMock
- .stub_request(:get, api_url + '/apis/extensions/v1beta1')
- .to_return(kube_response(kube_1_22_extensions_v1beta1_discovery_body))
- end
-
- it 'delegates to the apps client' do
- expect(networking_client).to receive(:get_ingresses)
-
- client.get_ingresses
+ it 'responds to the method' do
+ expect(client).to respond_to :get_deployments
end
end
end
- describe '#patch_ingress' do
- let(:extensions_client) { client.extensions_client }
+ describe 'networking.k8s.io/v1 API group' do
let(:networking_client) { client.networking_client }
- include_examples 'redirection not allowed', 'patch_ingress'
- include_examples 'dns rebinding not allowed', 'patch_ingress'
-
- it 'delegates to the extensions client' do
- expect(extensions_client).to receive(:patch_ingress)
-
- client.patch_ingress
- end
-
- context 'extensions does not have ingress for Kubernetes 1.22+ clusters' do
- before do
- WebMock
- .stub_request(:get, api_url + '/apis/extensions/v1beta1')
- .to_return(kube_response(kube_1_22_extensions_v1beta1_discovery_body))
- end
+ [:get_ingresses, :patch_ingress].each do |method|
+ describe "##{method}" do
+ include_examples 'redirection not allowed', method
+ include_examples 'dns rebinding not allowed', method
- it 'delegates to the apps client' do
- expect(networking_client).to receive(:patch_ingress)
+ it 'delegates to the networking client' do
+ expect(client).to delegate_method(method).to(:networking_client)
+ end
- client.patch_ingress
+ it 'responds to the method' do
+ expect(client).to respond_to method
+ end
end
end
end
diff --git a/spec/support/helpers/kubernetes_helpers.rb b/spec/support/helpers/kubernetes_helpers.rb
index ff61cceba06..29064f01913 100644
--- a/spec/support/helpers/kubernetes_helpers.rb
+++ b/spec/support/helpers/kubernetes_helpers.rb
@@ -40,9 +40,6 @@ module KubernetesHelpers
def stub_kubeclient_discover_base(api_url)
WebMock.stub_request(:get, api_url + '/api/v1').to_return(kube_response(kube_v1_discovery_body))
WebMock
- .stub_request(:get, api_url + '/apis/extensions/v1beta1')
- .to_return(kube_response(kube_extensions_v1beta1_discovery_body))
- WebMock
.stub_request(:get, api_url + '/apis/apps/v1')
.to_return(kube_response(kube_apps_v1_discovery_body))
WebMock
@@ -149,7 +146,7 @@ module KubernetesHelpers
def stub_kubeclient_deployments(namespace, status: nil)
stub_kubeclient_discover(service.api_url)
- deployments_url = service.api_url + "/apis/extensions/v1beta1/namespaces/#{namespace}/deployments"
+ deployments_url = service.api_url + "/apis/apps/v1/namespaces/#{namespace}/deployments"
response = { status: status } if status
WebMock.stub_request(:get, deployments_url).to_return(response || kube_deployments_response)
@@ -157,7 +154,7 @@ module KubernetesHelpers
def stub_kubeclient_ingresses(namespace, status: nil, method: :get, resource_path: "", response: kube_ingresses_response)
stub_kubeclient_discover(service.api_url)
- ingresses_url = service.api_url + "/apis/extensions/v1beta1/namespaces/#{namespace}/ingresses#{resource_path}"
+ ingresses_url = service.api_url + "/apis/networking.k8s.io/v1/namespaces/#{namespace}/ingresses#{resource_path}"
response = { status: status } if status
WebMock.stub_request(method, ingresses_url).to_return(response)
@@ -314,24 +311,6 @@ module KubernetesHelpers
}
end
- # From Kubernetes 1.16+ Deployments are no longer served from apis/extensions
- def kube_1_16_extensions_v1beta1_discovery_body
- {
- "kind" => "APIResourceList",
- "resources" => [
- { "name" => "ingresses", "namespaced" => true, "kind" => "Deployment" }
- ]
- }
- end
-
- # From Kubernetes 1.22+ Ingresses are no longer served from apis/extensions
- def kube_1_22_extensions_v1beta1_discovery_body
- {
- "kind" => "APIResourceList",
- "resources" => []
- }
- end
-
def kube_knative_discovery_body
{
"kind" => "APIResourceList",
@@ -339,18 +318,6 @@ module KubernetesHelpers
}
end
- def kube_extensions_v1beta1_discovery_body
- {
- "kind" => "APIResourceList",
- "resources" => [
- { "name" => "deployments", "namespaced" => true, "kind" => "Deployment" },
- { "name" => "ingresses", "namespaced" => true, "kind" => "Ingress" }
- ]
- }
- end
-
- # Yes, deployments are defined in both apis/extensions/v1beta1 and apis/v1
- # (for Kubernetes < 1.16). This matches what Kubenetes API server returns.
def kube_apps_v1_discovery_body
{
"kind" => "APIResourceList",
diff --git a/spec/uploaders/object_storage_spec.rb b/spec/uploaders/object_storage_spec.rb
index 13f70e3f85b..1bcc43b81a8 100644
--- a/spec/uploaders/object_storage_spec.rb
+++ b/spec/uploaders/object_storage_spec.rb
@@ -48,6 +48,28 @@ RSpec.describe ObjectStorage do
expect(uploader.store_dir).to start_with("uploads/-/system/user/")
end
end
+
+ describe '#store_path' do
+ subject { uploader.store_path('filename') }
+
+ it 'uses store_dir' do
+ expect(subject).to eq("uploads/-/system/user/#{object.id}/filename")
+ end
+
+ context 'when a bucket prefix is configured' do
+ before do
+ allow(uploader_class).to receive(:object_store_options) do
+ double(
+ bucket_prefix: 'my/prefix'
+ )
+ end
+ end
+
+ it 'uses store_dir and ignores prefix' do
+ expect(subject).to eq("uploads/-/system/user/#{object.id}/filename")
+ end
+ end
+ end
end
context 'object_store is Store::REMOTE' do
@@ -60,6 +82,28 @@ RSpec.describe ObjectStorage do
expect(uploader.store_dir).to start_with("user/")
end
end
+
+ describe '#store_path' do
+ subject { uploader.store_path('filename') }
+
+ it 'uses store_dir' do
+ expect(subject).to eq("user/#{object.id}/filename")
+ end
+
+ context 'when a bucket prefix is configured' do
+ before do
+ allow(uploader_class).to receive(:object_store_options) do
+ double(
+ bucket_prefix: 'my/prefix'
+ )
+ end
+ end
+
+ it 'uses the prefix and store_dir' do
+ expect(subject).to eq("my/prefix/user/#{object.id}/filename")
+ end
+ end
+ end
end
end