diff --git a/ee/app/assets/javascripts/workspaces/agent_mapping/components/agent_mapping_status_toggle.vue b/ee/app/assets/javascripts/workspaces/agent_mapping/components/agent_mapping_status_toggle.vue
new file mode 100644
index 0000000000000000000000000000000000000000..137e9c58cf07bd23fdf96264c4c3f8bdc984be75
--- /dev/null
+++ b/ee/app/assets/javascripts/workspaces/agent_mapping/components/agent_mapping_status_toggle.vue
@@ -0,0 +1,104 @@
+
+
+
+ {{ buttonLabel }}
+
+
+ {{ paragraph }}
+
+
+
+
diff --git a/ee/app/assets/javascripts/workspaces/agent_mapping/components/agents_table.vue b/ee/app/assets/javascripts/workspaces/agent_mapping/components/agents_table.vue
index ac204e5b222687c8198e6c7609cba1615c284c0d..ddde0da08816891a6966d779b4bb322631a1d049 100644
--- a/ee/app/assets/javascripts/workspaces/agent_mapping/components/agents_table.vue
+++ b/ee/app/assets/javascripts/workspaces/agent_mapping/components/agents_table.vue
@@ -1,35 +1,60 @@
diff --git a/ee/app/assets/javascripts/workspaces/agent_mapping/graphql/mutations/create_cluster_agent_mapping.mutation.graphql b/ee/app/assets/javascripts/workspaces/agent_mapping/graphql/mutations/create_cluster_agent_mapping.mutation.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..f16f9a68eed3cd208a49cfab485bf3f56f63427b
--- /dev/null
+++ b/ee/app/assets/javascripts/workspaces/agent_mapping/graphql/mutations/create_cluster_agent_mapping.mutation.graphql
@@ -0,0 +1,7 @@
+mutation createClusterAgentMapping(
+ $input: NamespaceCreateRemoteDevelopmentClusterAgentMappingInput!
+) {
+ namespaceCreateRemoteDevelopmentClusterAgentMapping(input: $input) {
+ clientMutationId
+ }
+}
diff --git a/ee/app/assets/javascripts/workspaces/agent_mapping/graphql/mutations/delete_cluster_agent_mapping.mutation.graphql b/ee/app/assets/javascripts/workspaces/agent_mapping/graphql/mutations/delete_cluster_agent_mapping.mutation.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..2ac54d398f2229b98ae3a7e8ca78cbd2a75911b4
--- /dev/null
+++ b/ee/app/assets/javascripts/workspaces/agent_mapping/graphql/mutations/delete_cluster_agent_mapping.mutation.graphql
@@ -0,0 +1,7 @@
+mutation deleteClusterAgentMapping(
+ $input: NamespaceDeleteRemoteDevelopmentClusterAgentMappingInput!
+) {
+ namespaceDeleteRemoteDevelopmentClusterAgentMapping(input: $input) {
+ clientMutationId
+ }
+}
diff --git a/ee/spec/features/groups/settings/remote_development/workspaces_spec.rb b/ee/spec/features/groups/settings/remote_development/workspaces_spec.rb
index be64b1852891fb257a86ae86c45800539bcd6d48..cf721af51c35ffd6bada9e08b707fa21b970e153 100644
--- a/ee/spec/features/groups/settings/remote_development/workspaces_spec.rb
+++ b/ee/spec/features/groups/settings/remote_development/workspaces_spec.rb
@@ -16,7 +16,7 @@
end
before_all do
- group.add_developer(user)
+ group.add_owner(user)
end
before do
@@ -71,6 +71,40 @@
expect(page).to have_content 'Allowed'
expect(page).to have_content 'Blocked'
end
+
+ it 'allows mapping or unmapping agents' do
+ first_agent_row_selector = 'tbody tr:first-child'
+
+ click_link 'All agents'
+
+ # Executes block action on the first agent
+ within first_agent_row_selector do
+ expect(page).to have_content('Allowed')
+
+ click_button 'Block'
+ end
+
+ expect(page).to have_content('Block agent')
+
+ click_button 'Block agent'
+
+ wait_for_requests
+
+ # Reverts the block action by allowing the agent
+ within first_agent_row_selector do
+ expect(page).to have_content('Blocked')
+
+ click_button 'Allow'
+
+ wait_for_requests
+ end
+
+ expect(page).to have_content('Allow agent')
+
+ click_button 'Allow agent'
+
+ expect(page).to have_content('Allowed')
+ end
end
end
end
diff --git a/ee/spec/frontend/workspaces/agent_mapping/components/agent_mapping_status_toggle_spec.js b/ee/spec/frontend/workspaces/agent_mapping/components/agent_mapping_status_toggle_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..9d9edd34c788669bbce02b60ac5eb4233dd93a50
--- /dev/null
+++ b/ee/spec/frontend/workspaces/agent_mapping/components/agent_mapping_status_toggle_spec.js
@@ -0,0 +1,85 @@
+import { GlModal, GlButton } from '@gitlab/ui';
+import AgentMappingStatusToggle from 'ee_component/workspaces/agent_mapping/components/agent_mapping_status_toggle.vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { MAPPED_CLUSTER_AGENT, UNMAPPED_CLUSTER_AGENT, NAMESPACE_ID } from '../../mock_data';
+
+describe('workspaces/agent_mapping/components/agent_mapping_status_toggle', () => {
+ let wrapper;
+
+ const buildWrapper = ({ propsData = {} } = {}) => {
+ wrapper = shallowMountExtended(AgentMappingStatusToggle, {
+ propsData: {
+ namespaceId: NAMESPACE_ID,
+ ...propsData,
+ },
+ });
+ };
+ const findToggleButton = () => wrapper.findComponent(GlButton);
+ const findConfirmBlockModal = () => wrapper.findComponent(GlModal);
+
+ it('adds a primary action to the block modal', () => {
+ buildWrapper({ propsData: { agent: MAPPED_CLUSTER_AGENT } });
+
+ expect(findConfirmBlockModal().props('actionPrimary')).toEqual({
+ text: 'Block agent',
+ attributes: {
+ variant: 'danger',
+ },
+ });
+ });
+
+ it.each`
+ agent | buttonLabel
+ ${MAPPED_CLUSTER_AGENT} | ${'Block'}
+ ${UNMAPPED_CLUSTER_AGENT} | ${'Allow'}
+ `('displays $buttonLabel when agent is $agent', ({ agent, buttonLabel }) => {
+ buildWrapper({ propsData: { agent } });
+
+ expect(findToggleButton().text()).toContain(buttonLabel);
+ });
+
+ it.each`
+ agent | modalActionPrimary | modalTitle
+ ${MAPPED_CLUSTER_AGENT} | ${{ text: 'Block agent', variant: 'danger' }} | ${'Block this agent for all group members'}
+ `(
+ 'customizes confirmation modal based on agent status',
+ ({ agent, modalActionPrimary, modalTitle }) => {
+ buildWrapper({ propsData: { agent } });
+
+ expect(findConfirmBlockModal().props().actionPrimary).toMatchObject({
+ text: modalActionPrimary.text,
+ attributes: {
+ variant: modalActionPrimary.variant,
+ },
+ });
+ expect(findConfirmBlockModal().props().title).toContain(modalTitle);
+ },
+ );
+
+ describe('when clicking toggle', () => {
+ beforeEach(() => {
+ buildWrapper({ propsData: { agent: MAPPED_CLUSTER_AGENT } });
+ });
+
+ it('makes confirm block modal visible', async () => {
+ await findToggleButton().vm.$emit('click');
+
+ expect(findConfirmBlockModal().props('visible')).toBe(true);
+ });
+ });
+
+ describe('when confirm block modal triggers primary event', () => {
+ it('triggers toggle event', async () => {
+ buildWrapper({ propsData: { agent: MAPPED_CLUSTER_AGENT } });
+ await findConfirmBlockModal().vm.$emit('primary');
+
+ expect(wrapper.emitted('toggle')).toHaveLength(1);
+ });
+ });
+
+ it.each([true, false])('sets toggle button as loading based on loading property', (loading) => {
+ buildWrapper({ propsData: { agent: MAPPED_CLUSTER_AGENT, loading } });
+
+ expect(findToggleButton().props('loading')).toBe(loading);
+ });
+});
diff --git a/ee/spec/frontend/workspaces/agent_mapping/components/agents_table_spec.js b/ee/spec/frontend/workspaces/agent_mapping/components/agents_table_spec.js
index 637175089c75f3aaa97c2da7c0219aae3c639612..785e624c6b6396a447ae1b719a11c9401e0f1cbe 100644
--- a/ee/spec/frontend/workspaces/agent_mapping/components/agents_table_spec.js
+++ b/ee/spec/frontend/workspaces/agent_mapping/components/agents_table_spec.js
@@ -1,27 +1,42 @@
import { GlTable, GlSkeletonLoader } from '@gitlab/ui';
+import { stubComponent } from 'helpers/stub_component';
import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
import AgentsTable from 'ee_component/workspaces/agent_mapping/components/agents_table.vue';
-import {
- AGENT_MAPPING_STATUS_MAPPED,
- AGENT_MAPPING_STATUS_UNMAPPED,
-} from 'ee_component/workspaces/agent_mapping/constants';
+import AgentMappingStatusToggle from 'ee_component/workspaces/agent_mapping/components/agent_mapping_status_toggle.vue';
+import ToggleAgentMappingStatusMutation from 'ee_component/workspaces/agent_mapping/components/toggle_agent_mapping_status_mutation.vue';
+import { MAPPED_CLUSTER_AGENT, UNMAPPED_CLUSTER_AGENT, NAMESPACE_ID } from '../../mock_data';
-describe('workspaces/agent_mapping/components/agents_table.vue', () => {
+describe('workspaces/agent_mapping/components/agents_table', () => {
let wrapper;
const EMPTY_STATE_MESSAGE = 'No agents found';
- const agents = [
- { name: 'agent-1', mappingStatus: AGENT_MAPPING_STATUS_MAPPED },
- { name: 'agent-1', mappingStatus: AGENT_MAPPING_STATUS_UNMAPPED },
- ];
+ const agents = [MAPPED_CLUSTER_AGENT, UNMAPPED_CLUSTER_AGENT];
+ let ToggleAgentMappingStatusMutationStub;
+ let executeToggleAgentMappingStatusMutationFn;
+
+ const buildWrapper = ({ propsData = {}, provide = {} } = {}, mountFn = shallowMountExtended) => {
+ executeToggleAgentMappingStatusMutationFn = jest.fn();
+ ToggleAgentMappingStatusMutationStub = stubComponent(ToggleAgentMappingStatusMutation, {
+ render() {
+ return this.$scopedSlots.default?.({
+ execute: executeToggleAgentMappingStatusMutationFn,
+ });
+ },
+ });
- const buildWrapper = ({ propsData = {} } = {}, mountFn = shallowMountExtended) => {
wrapper = mountFn(AgentsTable, {
propsData: {
agents: [],
emptyStateMessage: EMPTY_STATE_MESSAGE,
isLoading: false,
+ namespaceId: NAMESPACE_ID,
...propsData,
},
+ provide: {
+ ...provide,
+ },
+ stubs: {
+ ToggleAgentMappingStatusMutation: ToggleAgentMappingStatusMutationStub,
+ },
});
};
const findAgentsTable = () => wrapper.findComponent(GlTable);
@@ -114,7 +129,7 @@ describe('workspaces/agent_mapping/components/agents_table.vue', () => {
});
it('displays agents list', () => {
- expect(findAgentsTable().text()).toContain('agent-1');
+ expect(findAgentsTable().text()).toContain(MAPPED_CLUSTER_AGENT.name);
});
describe('when displayMappingStatus is true', () => {
@@ -131,9 +146,15 @@ describe('workspaces/agent_mapping/components/agents_table.vue', () => {
);
const labels = wrapper
.findAllByTestId('agent-mapping-status-label')
- .wrappers.map((labelWrapper) => labelWrapper.text());
-
- expect(labels).toEqual(['Allowed', 'Blocked']);
+ .wrappers.map((labelWrapper) => ({
+ text: labelWrapper.text(),
+ variant: labelWrapper.props().variant,
+ }));
+
+ expect(labels).toEqual([
+ { text: 'Allowed', variant: 'success' },
+ { text: 'Blocked', variant: 'danger' },
+ ]);
});
});
@@ -153,5 +174,64 @@ describe('workspaces/agent_mapping/components/agents_table.vue', () => {
expect(wrapper.findAllByTestId('agent-mapping-status-label').length).toBe(0);
});
});
+
+ describe('when canAdminClusterAgentMapping is true', () => {
+ beforeEach(() => {
+ buildWrapper(
+ {
+ propsData: {
+ isLoading: false,
+ agents,
+ },
+ provide: {
+ canAdminClusterAgentMapping: true,
+ },
+ },
+ mountExtended,
+ );
+ });
+
+ it('displays actions column', () => {
+ expect(findAgentsTable().text()).toContain('Actions');
+
+ const toggles = findAgentsTable().findAllComponents(AgentMappingStatusToggle);
+ const mutations = findAgentsTable().findAllComponents(ToggleAgentMappingStatusMutationStub);
+
+ agents.forEach((agent, index) => {
+ expect(toggles.at(index).props().agent).toMatchObject(agent);
+ expect(mutations.at(index).props()).toMatchObject({
+ agent,
+ namespaceId: NAMESPACE_ID,
+ });
+ });
+ });
+
+ describe('when action toggle emits toggle event', () => {
+ it('executes the toggle mapping agent status mutation', () => {
+ findAgentsTable().findAllComponents(AgentMappingStatusToggle).at(0).vm.$emit('toggle');
+
+ expect(executeToggleAgentMappingStatusMutationFn).toHaveBeenCalled();
+ });
+ });
+ });
+ });
+
+ describe('when canAdminClusterAgentMapping is false', () => {
+ it('does not display actions column', () => {
+ buildWrapper(
+ {
+ propsData: {
+ isLoading: false,
+ agents,
+ },
+ provide: {
+ canAdminClusterAgentMapping: false,
+ },
+ },
+ mountExtended,
+ );
+ expect(findAgentsTable().text()).not.toContain('Actions');
+ expect(findAgentsTable().findAllComponents(AgentMappingStatusToggle).length).toBe(0);
+ });
});
});
diff --git a/ee/spec/frontend/workspaces/agent_mapping/components/toggle_agent_mapping_status_mutation_spec.js b/ee/spec/frontend/workspaces/agent_mapping/components/toggle_agent_mapping_status_mutation_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..8c30a312df0607089c0c6a154d1994e2b52cab9c
--- /dev/null
+++ b/ee/spec/frontend/workspaces/agent_mapping/components/toggle_agent_mapping_status_mutation_spec.js
@@ -0,0 +1,183 @@
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import { logError } from '~/lib/logger';
+import * as Sentry from '~/sentry/sentry_browser_wrapper';
+import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ToggleAgentMappingStatusMutation from 'ee_component/workspaces/agent_mapping/components/toggle_agent_mapping_status_mutation.vue';
+import createClusterAgentMappingMutation from 'ee/workspaces/agent_mapping/graphql/mutations/create_cluster_agent_mapping.mutation.graphql';
+import deleteClusterAgentMappingMutation from 'ee/workspaces/agent_mapping/graphql/mutations/delete_cluster_agent_mapping.mutation.graphql';
+import getAgentsWithAuthorizationStatusQuery from 'ee/workspaces/agent_mapping/graphql/queries/get_agents_with_mapping_status.query.graphql';
+import {
+ CREATE_CLUSTER_AGENT_MAPPING_MUTATION_RESULT,
+ DELETE_CLUSTER_AGENT_MAPPING_MUTATION_RESULT,
+ GET_AGENTS_WITH_MAPPING_STATUS_QUERY_RESULT,
+ MAPPED_CLUSTER_AGENT,
+ NAMESPACE_ID,
+ UNMAPPED_CLUSTER_AGENT,
+} from '../../mock_data';
+
+jest.mock('~/lib/logger');
+jest.mock('~/sentry/sentry_browser_wrapper');
+
+Vue.use(VueApollo);
+
+describe('workspaces/agent_mapping/components/toggle_agent_mapping_status_mutation', () => {
+ let executeMutationFn;
+ let loadingStatus;
+ let apolloProvider;
+ let createClusterAgentMappingMutationHandler;
+ let deleteClusterAgentMappingMutationHandler;
+ let wrapper;
+ const namespace = 'foo/bar';
+
+ const setupApolloProvider = () => {
+ createClusterAgentMappingMutationHandler = jest
+ .fn()
+ .mockResolvedValueOnce(CREATE_CLUSTER_AGENT_MAPPING_MUTATION_RESULT);
+ deleteClusterAgentMappingMutationHandler = jest
+ .fn()
+ .mockResolvedValueOnce(DELETE_CLUSTER_AGENT_MAPPING_MUTATION_RESULT);
+
+ apolloProvider = createMockApollo([
+ [createClusterAgentMappingMutation, createClusterAgentMappingMutationHandler],
+ [deleteClusterAgentMappingMutation, deleteClusterAgentMappingMutationHandler],
+ ]);
+
+ apolloProvider.clients.defaultClient.writeQuery({
+ query: getAgentsWithAuthorizationStatusQuery,
+ variables: {
+ namespace,
+ },
+ data: GET_AGENTS_WITH_MAPPING_STATUS_QUERY_RESULT.data,
+ });
+ };
+
+ const readRemoteDevelopmentClusterAgentsFromQueryCache = () => {
+ const apolloClient = apolloProvider.clients.defaultClient;
+ const result = apolloClient.readQuery({
+ query: getAgentsWithAuthorizationStatusQuery,
+ variables: {
+ namespace,
+ },
+ });
+
+ return result?.group.remoteDevelopmentClusterAgents.nodes;
+ };
+
+ const buildWrapper = ({ propsData = {} } = {}) => {
+ wrapper = shallowMountExtended(ToggleAgentMappingStatusMutation, {
+ apolloProvider,
+ propsData: {
+ namespaceId: NAMESPACE_ID,
+ ...propsData,
+ },
+ provide: {
+ namespace,
+ },
+ scopedSlots: {
+ default(props) {
+ executeMutationFn = props.execute;
+ loadingStatus = props.loading;
+ return this.$createElement('div');
+ },
+ },
+ });
+ };
+
+ beforeEach(() => {
+ setupApolloProvider();
+ });
+
+ describe('when executing mutation', () => {
+ it('sets loading status as loading', async () => {
+ buildWrapper({ propsData: { agent: MAPPED_CLUSTER_AGENT } });
+
+ expect(loadingStatus).toBe(false);
+
+ executeMutationFn();
+
+ await nextTick();
+
+ expect(loadingStatus).toBe(true);
+ });
+
+ describe(`given the agent status is ${MAPPED_CLUSTER_AGENT.mappingStatus}`, () => {
+ beforeEach(async () => {
+ buildWrapper({ propsData: { agent: MAPPED_CLUSTER_AGENT } });
+
+ executeMutationFn();
+
+ await nextTick();
+ });
+
+ it('executes deleteClusterAgentMappingMutation', () => {
+ expect(deleteClusterAgentMappingMutationHandler).toHaveBeenCalledWith({
+ input: {
+ clusterAgentId: MAPPED_CLUSTER_AGENT.id,
+ namespaceId: NAMESPACE_ID,
+ },
+ });
+ });
+
+ it('removes agent from the remoteDevelopmentClusterAgents collection', () => {
+ const agents = readRemoteDevelopmentClusterAgentsFromQueryCache();
+
+ expect(agents.some((agent) => agent.id === MAPPED_CLUSTER_AGENT.id)).toBe(false);
+ });
+ });
+
+ describe(`given the agent status is ${UNMAPPED_CLUSTER_AGENT.mappingStatus}`, () => {
+ beforeEach(async () => {
+ buildWrapper({ propsData: { agent: UNMAPPED_CLUSTER_AGENT } });
+
+ executeMutationFn();
+
+ await nextTick();
+ });
+
+ it('executes createClusterAgentMappingMutation', () => {
+ expect(createClusterAgentMappingMutationHandler).toHaveBeenCalledWith({
+ input: {
+ clusterAgentId: UNMAPPED_CLUSTER_AGENT.id,
+ namespaceId: NAMESPACE_ID,
+ },
+ });
+ });
+
+ it('adds unmapped agents to the remoteDevelopmentClusterAgents collection', () => {
+ const agents = readRemoteDevelopmentClusterAgentsFromQueryCache();
+
+ expect(agents.some((agent) => agent.id === UNMAPPED_CLUSTER_AGENT.id)).toBe(true);
+ });
+ });
+
+ describe('when the mutation fails', () => {
+ const error = new Error();
+
+ beforeEach(async () => {
+ createClusterAgentMappingMutationHandler.mockReset().mockRejectedValueOnce(error);
+
+ buildWrapper({ propsData: { agent: UNMAPPED_CLUSTER_AGENT } });
+
+ executeMutationFn();
+
+ await nextTick();
+ await waitForPromises();
+ });
+
+ it('emits error event', () => {
+ expect(wrapper.emitted('error')).toHaveLength(1);
+ });
+
+ it('logs error', () => {
+ expect(logError).toHaveBeenCalledWith(error);
+ });
+
+ it('captures the exception in Sentry', () => {
+ expect(Sentry.captureException).toHaveBeenCalledWith(error);
+ });
+ });
+ });
+});
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 0b50f1e4b8c38495e0ff2ae4bc98d32d8792aa1e..98700053747c9a8a512238ef528c370c353fb68f 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -5162,6 +5162,9 @@ msgstr ""
msgid "All users with matching phone numbers"
msgstr ""
+msgid "Allow"
+msgstr ""
+
msgid "Allow %{strongOpen}%{group_name}%{strongClose} to sign you in?"
msgstr ""
@@ -8609,6 +8612,9 @@ msgstr ""
msgid "BlobViewer|View on %{environmentName}"
msgstr ""
+msgid "Block"
+msgstr ""
+
msgid "Block secrets such as keys and API tokens from being pushed to your repositories. Secret push protection is triggered when commits are pushed to a repository. If any secrets are detected, the push is blocked."
msgstr ""
@@ -59666,9 +59672,24 @@ msgstr ""
msgid "Workspaces|All agents"
msgstr ""
+msgid "Workspaces|Allow agent"
+msgstr ""
+
+msgid "Workspaces|Allow this agent for all group members?"
+msgstr ""
+
msgid "Workspaces|Allowed Agents"
msgstr ""
+msgid "Workspaces|Block agent"
+msgstr ""
+
+msgid "Workspaces|Block this agent for all group members?"
+msgstr ""
+
+msgid "Workspaces|Blocking an agent doesn't delete it. Agents can only be deleted in the project where they were created."
+msgstr ""
+
msgid "Workspaces|Cancel"
msgstr ""
@@ -59720,6 +59741,12 @@ msgstr ""
msgid "Workspaces|Group agents"
msgstr ""
+msgid "Workspaces|Group members can use allowed agents to create workspaces."
+msgstr ""
+
+msgid "Workspaces|Group members can't create a workspace with a blocked agent. Existing workspaces using this agent will continue to run, but can't restart if they are terminated."
+msgstr ""
+
msgid "Workspaces|If your devfile is not in the root directory of your project, specify a relative path."
msgstr ""