Commit 7a21243c authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch '327855-install-agent-modal' into 'master'

Add modal component for registering a cluster agent

See merge request gitlab-org/gitlab!65940
parents 9bedc00e e30e7d97
export function generateAgentRegistrationCommand(agentToken, kasAddress) {
return `docker run --pull=always --rm \\
registry.gitlab.com/gitlab-org/cluster-integration/gitlab-agent/cli:stable generate \\
--agent-token=${agentToken} \\
--kas-address=${kasAddress} \\
--agent-version stable \\
--namespace gitlab-kubernetes-agent | kubectl apply -f -`;
}
......@@ -10,7 +10,13 @@ export default {
GlDropdown,
GlDropdownItem,
},
inject: ['projectPath', 'isRegistering'],
inject: ['projectPath'],
props: {
isRegistering: {
required: true,
type: Boolean,
},
},
apollo: {
agents: {
query: agentConfigurations,
......
<script>
import {
GlAlert,
GlButton,
GlFormGroup,
GlFormInputGroup,
GlLink,
GlModal,
GlSprintf,
} from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import CodeBlock from '~/vue_shared/components/code_block.vue';
import { generateAgentRegistrationCommand } from '../clusters_util';
import { INSTALL_AGENT_MODAL_ID, I18N_INSTALL_AGENT_MODAL } from '../constants';
import createAgent from '../graphql/mutations/create_agent.mutation.graphql';
import createAgentToken from '../graphql/mutations/create_agent_token.mutation.graphql';
import AvailableAgentsDropdown from './available_agents_dropdown.vue';
export default {
modalId: INSTALL_AGENT_MODAL_ID,
i18n: I18N_INSTALL_AGENT_MODAL,
components: {
AvailableAgentsDropdown,
ClipboardButton,
CodeBlock,
GlAlert,
GlButton,
GlFormGroup,
GlFormInputGroup,
GlLink,
GlModal,
GlSprintf,
},
inject: ['projectPath', 'kasAddress'],
data() {
return {
registering: false,
agentName: null,
agentToken: null,
error: null,
};
},
computed: {
registered() {
return Boolean(this.agentToken);
},
nextButtonDisabled() {
return !this.registering && this.agentName !== null;
},
canCancel() {
return !this.registered && !this.registering;
},
agentRegistrationCommand() {
return generateAgentRegistrationCommand(this.agentToken, this.kasAddress);
},
basicInstallPath() {
return helpPagePath('user/clusters/agent/index', {
anchor: 'install-the-agent-into-the-cluster',
});
},
advancedInstallPath() {
return helpPagePath('user/clusters/agent/index', { anchor: 'advanced-installation' });
},
},
methods: {
setAgentName(name) {
this.agentName = name;
},
cancelClicked() {
this.$refs.modal.hide();
},
doneClicked() {
this.$emit('agentRegistered');
this.$refs.modal.hide();
},
resetModal() {
this.registering = null;
this.agentName = null;
this.agentToken = null;
this.error = null;
},
createAgentMutation() {
return this.$apollo
.mutate({
mutation: createAgent,
variables: {
input: {
name: this.agentName,
projectPath: this.projectPath,
},
},
})
.then(({ data: { createClusterAgent } }) => createClusterAgent);
},
createAgentTokenMutation(agendId) {
return this.$apollo
.mutate({
mutation: createAgentToken,
variables: {
input: {
clusterAgentId: agendId,
name: this.agentName,
},
},
})
.then(({ data: { clusterAgentTokenCreate } }) => clusterAgentTokenCreate);
},
async registerAgent() {
this.registering = true;
this.error = null;
try {
const { errors: agentErrors, clusterAgent } = await this.createAgentMutation();
if (agentErrors?.length > 0) {
throw new Error(agentErrors[0]);
}
const { errors: tokenErrors, secret } = await this.createAgentTokenMutation(
clusterAgent.id,
);
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.registering = false;
}
},
},
};
</script>
<template>
<gl-modal
ref="modal"
:modal-id="$options.modalId"
:title="$options.i18n.modalTitle"
static
lazy
@hidden="resetModal"
>
<template v-if="!registered">
<p>
<strong>{{ $options.i18n.selectAgentTitle }}</strong>
</p>
<p>
<gl-sprintf :message="$options.i18n.selectAgentBody">
<template #link="{ content }">
<gl-link :href="basicInstallPath" target="_blank"> {{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
<form>
<gl-form-group label-for="agent-name">
<available-agents-dropdown
class="gl-w-70p"
:is-registering="registering"
@agentSelected="setAgentName"
/>
</gl-form-group>
</form>
<p v-if="error">
<gl-alert
:title="$options.i18n.registrationErrorTitle"
variant="danger"
:dismissible="false"
>
{{ error }}
</gl-alert>
</p>
</template>
<template v-else>
<p>
<strong>{{ $options.i18n.tokenTitle }}</strong>
</p>
<p>
<gl-sprintf :message="$options.i18n.tokenBody">
<template #link="{ content }">
<gl-link :href="basicInstallPath" target="_blank"> {{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
<p>
<gl-alert
:title="$options.i18n.tokenSingleUseWarningTitle"
variant="warning"
:dismissible="false"
>
{{ $options.i18n.tokenSingleUseWarningBody }}
</gl-alert>
</p>
<p>
<gl-form-input-group readonly :value="agentToken" :select-on-click="true">
<template #append>
<clipboard-button :text="agentToken" :title="$options.i18n.copyToken" />
</template>
</gl-form-input-group>
</p>
<p>
<strong>{{ $options.i18n.basicInstallTitle }}</strong>
</p>
<p>
{{ $options.i18n.basicInstallBody }}
</p>
<p>
<code-block :code="agentRegistrationCommand" />
</p>
<p>
<strong>{{ $options.i18n.advancedInstallTitle }}</strong>
</p>
<p>
<gl-sprintf :message="$options.i18n.advancedInstallBody">
<template #link="{ content }">
<gl-link :href="advancedInstallPath" target="_blank"> {{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
</template>
<template #modal-footer>
<gl-button v-if="canCancel" @click="cancelClicked">{{ $options.i18n.cancel }} </gl-button>
<gl-button v-if="registered" variant="confirm" category="primary" @click="doneClicked"
>{{ $options.i18n.done }}
</gl-button>
<gl-button
v-else
:disabled="!nextButtonDisabled"
variant="confirm"
category="primary"
@click="registerAgent"
>{{ $options.i18n.next }}
</gl-button>
</template>
</gl-modal>
</template>
import { s__ } from '~/locale';
import { __, s__ } from '~/locale';
export const MAX_LIST_COUNT = 25;
export const INSTALL_AGENT_MODAL_ID = 'install-agent';
export const I18N_INSTALL_AGENT_MODAL = {
next: __('Next'),
done: __('Done'),
cancel: __('Cancel'),
modalTitle: s__('ClusterAgents|Install new Agent'),
selectAgentTitle: s__('ClusterAgents|Select which Agent you want to install'),
selectAgentBody: s__(
`ClusterAgents|Select the Agent you want to register with GitLab and install on your cluster. To learn more about the Kubernetes Agent registration process %{linkStart}go to the documentation%{linkEnd}.`,
),
copyToken: s__('ClusterAgents|Copy token'),
tokenTitle: s__('ClusterAgents|Registration token'),
tokenBody: s__(
`ClusterAgents|The registration token will be used to connect the Agent on your cluster to GitLab. To learn more about the registration tokens and how they are used %{linkStart}go to the documentation%{linkEnd}.`,
),
tokenSingleUseWarningTitle: s__(
'ClusterAgents|The token value will not be shown again after you close this window.',
),
tokenSingleUseWarningBody: s__(
`ClusterAgents|The recommended installation method provided below includes the token. If you want to follow the alternative installation method provided in the docs make sure you save the token value before you close the window.`,
),
basicInstallTitle: s__('ClusterAgents|Recommended installation method'),
basicInstallBody: s__(
`Open a CLI and connect to the cluster you want to install the Agent in. Use this installation method to minimise any manual steps.The token is already included in the command.`,
),
advancedInstallTitle: s__('ClusterAgents|Alternative installation methods'),
advancedInstallBody: s__(
'ClusterAgents|For alternative installation methods %{linkStart}go to the documentation%{linkEnd}.',
),
registrationErrorTitle: s__('Failed to register Agent'),
unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'),
};
export const I18N_AVAILABLE_AGENTS_DROPDOWN = {
selectAgent: s__('ClusterAgents|Select an Agent'),
......
mutation createClusterAgent($input: CreateClusterAgentInput!) {
createClusterAgent(input: $input) {
clusterAgent {
id
}
errors
}
}
mutation createClusterAgentToken($input: ClusterAgentTokenCreateInput!) {
clusterAgentTokenCreate(input: $input) {
secret
token {
id
}
errors
}
}
......@@ -18,10 +18,9 @@ describe('AvailableAgentsDropdown', () => {
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findConfiguredAgentItem = () => findDropdownItems().at(0);
const createWrapper = ({ extraProvides = {}, isLoading = false }) => {
const createWrapper = ({ propsData = {}, isLoading = false }) => {
const provide = {
projectPath: 'path/to/project',
...extraProvides,
};
wrapper = (() => {
......@@ -36,7 +35,7 @@ describe('AvailableAgentsDropdown', () => {
},
};
return mount(AvailableAgentsDropdown, { mocks, provide });
return mount(AvailableAgentsDropdown, { mocks, provide, propsData });
}
const apolloProvider = createMockApollo([
......@@ -47,6 +46,7 @@ describe('AvailableAgentsDropdown', () => {
localVue,
apolloProvider,
provide,
propsData,
});
})();
};
......@@ -57,12 +57,12 @@ describe('AvailableAgentsDropdown', () => {
});
describe('there are agents available', () => {
const extraProvides = {
const propsData = {
isRegistering: false,
};
beforeEach(() => {
createWrapper({ extraProvides });
createWrapper({ propsData });
});
it('prompts to select an agent', () => {
......@@ -92,12 +92,12 @@ describe('AvailableAgentsDropdown', () => {
});
describe('registration in progress', () => {
const extraProvides = {
const propsData = {
isRegistering: true,
};
beforeEach(() => {
createWrapper({ extraProvides });
createWrapper({ propsData });
});
it('updates the text in the dropdown', () => {
......@@ -110,12 +110,12 @@ describe('AvailableAgentsDropdown', () => {
});
describe('agents query is loading', () => {
const extraProvides = {
const propsData = {
isRegistering: false,
};
beforeEach(() => {
createWrapper({ extraProvides, isLoading: true });
createWrapper({ propsData, isLoading: true });
});
it('updates the text in the dropdown', () => {
......
import { GlAlert, GlButton, GlFormInputGroup } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import AvailableAgentsDropdown from 'ee/clusters_list/components/available_agents_dropdown.vue';
import InstallAgentModal from 'ee/clusters_list/components/install_agent_modal.vue';
import { I18N_INSTALL_AGENT_MODAL } from 'ee/clusters_list/constants';
import createAgentMutation from 'ee/clusters_list/graphql/mutations/create_agent.mutation.graphql';
import createAgentTokenMutation from 'ee/clusters_list/graphql/mutations/create_agent_token.mutation.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import CodeBlock from '~/vue_shared/components/code_block.vue';
import {
createAgentResponse,
createAgentErrorResponse,
createAgentTokenResponse,
createAgentTokenErrorResponse,
} from '../mocks/apollo';
import ModalStub from '../stubs';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('InstallAgentModal', () => {
let wrapper;
let apolloProvider;
const i18n = I18N_INSTALL_AGENT_MODAL;
const findModal = () => wrapper.findComponent(ModalStub);
const findAgentDropdown = () => findModal().findComponent(AvailableAgentsDropdown);
const findAlert = () => findModal().findComponent(GlAlert);
const findButtonByVariant = (variant) =>
findModal()
.findAll(GlButton)
.wrappers.find((button) => button.props('variant') === variant);
const findActionButton = () => findButtonByVariant('confirm');
const findCancelButton = () => findButtonByVariant('default');
const expectDisabledAttribute = (element, disabled) => {
if (disabled) {
expect(element.attributes('disabled')).toBe('true');
} else {
expect(element.attributes('disabled')).toBeUndefined();
}
};
const createWrapper = () => {
const provide = {
projectPath: 'path/to/project',
kasAddress: 'kas.example.com',
};
wrapper = shallowMount(InstallAgentModal, {
attachTo: document.body,
stubs: {
GlModal: ModalStub,
},
localVue,
apolloProvider,
provide,
});
};
const mockSelectedAgentResponse = () => {
createWrapper();
wrapper.vm.setAgentName('agent-name');
findActionButton().vm.$emit('click');
return waitForPromises();
};
beforeEach(() => {
createWrapper();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
apolloProvider = null;
});
describe('initial state', () => {
it('renders the dropdown for available agents', () => {
expect(findAgentDropdown().isVisible()).toBe(true);
expect(findModal().text()).not.toContain(i18n.basicInstallTitle);
expect(findModal().findComponent(GlFormInputGroup).exists()).toBe(false);
expect(findModal().findComponent(GlAlert).exists()).toBe(false);
expect(findModal().findComponent(CodeBlock).exists()).toBe(false);
});
it('renders a cancel button', () => {
expect(findCancelButton().isVisible()).toBe(true);
expectDisabledAttribute(findCancelButton(), false);
});
it('renders a disabled next button', () => {
expect(findActionButton().isVisible()).toBe(true);
expect(findActionButton().text()).toBe(i18n.next);
expectDisabledAttribute(findActionButton(), true);
});
});
describe('an agent is selected', () => {
beforeEach(() => {
findAgentDropdown().vm.$emit('agentSelected');
});
it('enables the next button', () => {
expect(findActionButton().isVisible()).toBe(true);
expectDisabledAttribute(findActionButton(), false);
});
});
describe('registering an agent', () => {
const createAgentHandler = jest.fn().mockResolvedValue(createAgentResponse);
const createAgentTokenHandler = jest.fn().mockResolvedValue(createAgentTokenResponse);
beforeEach(() => {
apolloProvider = createMockApollo([
[createAgentMutation, createAgentHandler],
[createAgentTokenMutation, createAgentTokenHandler],
]);
return mockSelectedAgentResponse(apolloProvider);
});
it('creates an agent and token', () => {
expect(createAgentHandler).toHaveBeenCalledWith({
input: { name: 'agent-name', projectPath: 'path/to/project' },
});
expect(createAgentTokenHandler).toHaveBeenCalledWith({
input: { clusterAgentId: 'agent-id', name: 'agent-name' },
});
});
it('renders a done button', () => {
expect(findActionButton().isVisible()).toBe(true);
expect(findActionButton().text()).toBe(i18n.done);
expectDisabledAttribute(findActionButton(), false);
});
it('shows agent instructions', () => {
const modalText = findModal().text();
expect(modalText).toContain(i18n.basicInstallTitle);
expect(modalText).toContain(i18n.basicInstallBody);
const token = findModal().findComponent(GlFormInputGroup);
expect(token.props('value')).toBe('mock-agent-token');
const alert = findModal().findComponent(GlAlert);
expect(alert.props('title')).toBe(i18n.tokenSingleUseWarningTitle);
const code = findModal().findComponent(CodeBlock).props('code');
expect(code).toContain('--agent-token=mock-agent-token');
expect(code).toContain('--kas-address=kas.example.com');
});
describe('error creating agent', () => {
beforeEach(() => {
apolloProvider = createMockApollo([
[createAgentMutation, jest.fn().mockResolvedValue(createAgentErrorResponse)],
]);
return mockSelectedAgentResponse();
});
it('displays the error message', () => {
expect(findAlert().text()).toBe(createAgentErrorResponse.data.createClusterAgent.errors[0]);
});
});
describe('error creating token', () => {
beforeEach(() => {
apolloProvider = createMockApollo([
[createAgentMutation, jest.fn().mockResolvedValue(createAgentResponse)],
[createAgentTokenMutation, jest.fn().mockResolvedValue(createAgentTokenErrorResponse)],
]);
return mockSelectedAgentResponse();
});
it('displays the error message', () => {
expect(findAlert().text()).toBe(
createAgentTokenErrorResponse.data.clusterAgentTokenCreate.errors[0],
);
});
});
});
});
export const createAgentResponse = {
data: {
createClusterAgent: {
clusterAgent: {
id: 'agent-id',
},
errors: [],
},
},
};
export const createAgentErrorResponse = {
data: {
createClusterAgent: {
clusterAgent: {
id: 'agent-id',
},
errors: ['could not create agent'],
},
},
};
export const createAgentTokenResponse = {
data: {
clusterAgentTokenCreate: {
token: {
id: 'token-id',
},
secret: 'mock-agent-token',
errors: [],
},
},
};
export const createAgentTokenErrorResponse = {
data: {
clusterAgentTokenCreate: {
token: {
id: 'token-id',
},
secret: 'mock-agent-token',
errors: ['could not create agent token'],
},
},
};
const ModalStub = {
name: 'glmodal-stub',
template: `
<div>
<slot></slot>
<slot name="modal-footer"></slot>
</div>
`,
methods: {
hide: jest.fn(),
},
};
export default ModalStub;
......@@ -7027,15 +7027,24 @@ msgstr ""
msgid "ClusterAgents|Access tokens"
msgstr ""
msgid "ClusterAgents|Alternative installation methods"
msgstr ""
msgid "ClusterAgents|An error occurred while loading your GitLab Agents"
msgstr ""
msgid "ClusterAgents|An error occurred while loading your agent"
msgstr ""
msgid "ClusterAgents|An unknown error occurred. Please try again."
msgstr ""
msgid "ClusterAgents|Configuration"
msgstr ""
msgid "ClusterAgents|Copy token"
msgstr ""
msgid "ClusterAgents|Created by"
msgstr ""
......@@ -7048,9 +7057,15 @@ msgstr ""
msgid "ClusterAgents|Description"
msgstr ""
msgid "ClusterAgents|For alternative installation methods %{linkStart}go to the documentation%{linkEnd}."
msgstr ""
msgid "ClusterAgents|Go to the repository"
msgstr ""
msgid "ClusterAgents|Install new Agent"
msgstr ""
msgid "ClusterAgents|Integrate Kubernetes with a GitLab Agent"
msgstr ""
......@@ -7075,18 +7090,39 @@ msgstr ""
msgid "ClusterAgents|Read more about getting started"
msgstr ""
msgid "ClusterAgents|Recommended installation method"
msgstr ""
msgid "ClusterAgents|Registering Agent"
msgstr ""
msgid "ClusterAgents|Registration token"
msgstr ""
msgid "ClusterAgents|Select an Agent"
msgstr ""
msgid "ClusterAgents|Select the Agent you want to register with GitLab and install on your cluster. To learn more about the Kubernetes Agent registration process %{linkStart}go to the documentation%{linkEnd}."
msgstr ""
msgid "ClusterAgents|Select which Agent you want to install"
msgstr ""
msgid "ClusterAgents|The GitLab Agent also requires %{linkStart}enabling the Agent Server%{linkEnd}"
msgstr ""
msgid "ClusterAgents|The GitLab Kubernetes Agent allows an Infrastructure as Code, GitOps approach to integrating Kubernetes clusters with GitLab. %{linkStart}Learn more.%{linkEnd}"
msgstr ""
msgid "ClusterAgents|The recommended installation method provided below includes the token. If you want to follow the alternative installation method provided in the docs make sure you save the token value before you close the window."
msgstr ""
msgid "ClusterAgents|The registration token will be used to connect the Agent on your cluster to GitLab. To learn more about the registration tokens and how they are used %{linkStart}go to the documentation%{linkEnd}."
msgstr ""
msgid "ClusterAgents|The token value will not be shown again after you close this window."
msgstr ""
msgid "ClusterAgents|This agent has no tokens"
msgstr ""
......@@ -13626,6 +13662,9 @@ msgstr ""
msgid "Failed to publish issue on status page."
msgstr ""
msgid "Failed to register Agent"
msgstr ""
msgid "Failed to remove a Zoom meeting"
msgstr ""
......@@ -23123,6 +23162,9 @@ msgstr ""
msgid "Open Selection"
msgstr ""
msgid "Open a CLI and connect to the cluster you want to install the Agent in. Use this installation method to minimise any manual steps.The token is already included in the command."
msgstr ""
msgid "Open comment type dropdown"
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment