Commit 596e56ce authored by Anna Vovchenko's avatar Anna Vovchenko Committed by Phil Hughes

Provide Terraform backend environment variables in Operations-->Terraform UI

parent 6e1e1bf5
<script>
import { GlModal, GlSprintf, GlLink } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
export default {
i18n: {
title: s__('Terraform|Terraform init command'),
explanatoryText: s__(
`Terraform|To get access to this terraform state from your local computer, run the following command at the command line. The first line requires a personal access token with API read and write access. %{linkStart}How do I create a personal access token?%{linkEnd}.`,
),
closeText: __('Close'),
copyToClipboardText: __('Copy'),
},
components: {
GlModal,
GlSprintf,
GlLink,
ModalCopyButton,
},
inject: ['accessTokensPath', 'terraformApiUrl', 'username'],
props: {
modalId: {
type: String,
required: true,
},
stateName: {
type: String,
required: true,
},
},
computed: {
closeModalProps() {
return {
text: this.$options.i18n.closeText,
attributes: [],
};
},
},
methods: {
getModalInfoCopyStr() {
return `export GITLAB_ACCESS_TOKEN=<YOUR-ACCESS-TOKEN>
terraform init \\
-backend-config="address=${this.terraformApiUrl}/${this.stateName}" \\
-backend-config="lock_address=${this.terraformApiUrl}/${this.stateName}/lock" \\
-backend-config="unlock_address=${this.terraformApiUrl}/${this.stateName}/lock" \\
-backend-config="username=${this.username}" \\
-backend-config="password=$GITLAB_ACCESS_TOKEN" \\
-backend-config="lock_method=POST" \\
-backend-config="unlock_method=DELETE" \\
-backend-config="retry_wait_min=5"
`;
},
},
};
</script>
<template>
<gl-modal
ref="initCommandModal"
:modal-id="modalId"
:title="$options.i18n.title"
:action-cancel="closeModalProps"
>
<p data-testid="init-command-explanatory-text">
<gl-sprintf :message="$options.i18n.explanatoryText">
<template #link="{ content }">
<gl-link :href="accessTokensPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
<div class="gl-display-flex">
<pre class="gl-bg-gray gl-white-space-pre-wrap" data-testid="terraform-init-command">{{
getModalInfoCopyStr()
}}</pre>
<modal-copy-button
:title="$options.i18n.copyToClipboardText"
:text="getModalInfoCopyStr()"
:modal-id="$options.modalId"
data-testid="init-command-copy-clipboard"
css-classes="gl-align-self-start gl-ml-2"
/>
</div>
</gl-modal>
</template>
...@@ -8,12 +8,14 @@ import { ...@@ -8,12 +8,14 @@ import {
GlIcon, GlIcon,
GlModal, GlModal,
GlSprintf, GlSprintf,
GlModalDirective,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import addDataToState from '../graphql/mutations/add_data_to_state.mutation.graphql'; import addDataToState from '../graphql/mutations/add_data_to_state.mutation.graphql';
import lockState from '../graphql/mutations/lock_state.mutation.graphql'; import lockState from '../graphql/mutations/lock_state.mutation.graphql';
import removeState from '../graphql/mutations/remove_state.mutation.graphql'; import removeState from '../graphql/mutations/remove_state.mutation.graphql';
import unlockState from '../graphql/mutations/unlock_state.mutation.graphql'; import unlockState from '../graphql/mutations/unlock_state.mutation.graphql';
import InitCommandModal from './init_command_modal.vue';
export default { export default {
components: { components: {
...@@ -25,6 +27,10 @@ export default { ...@@ -25,6 +27,10 @@ export default {
GlIcon, GlIcon,
GlModal, GlModal,
GlSprintf, GlSprintf,
InitCommandModal,
},
directives: {
GlModalDirective,
}, },
props: { props: {
state: { state: {
...@@ -36,6 +42,7 @@ export default { ...@@ -36,6 +42,7 @@ export default {
return { return {
showRemoveModal: false, showRemoveModal: false,
removeConfirmText: '', removeConfirmText: '',
showCommandModal: false,
}; };
}, },
i18n: { i18n: {
...@@ -54,6 +61,7 @@ export default { ...@@ -54,6 +61,7 @@ export default {
remove: s__('Terraform|Remove state file and versions'), remove: s__('Terraform|Remove state file and versions'),
removeSuccessful: s__('Terraform|%{name} successfully removed'), removeSuccessful: s__('Terraform|%{name} successfully removed'),
unlock: s__('Terraform|Unlock'), unlock: s__('Terraform|Unlock'),
copyCommand: s__('Terraform|Copy Terraform init command'),
}, },
computed: { computed: {
cancelModalProps() { cancelModalProps() {
...@@ -74,6 +82,9 @@ export default { ...@@ -74,6 +82,9 @@ export default {
attributes: [{ disabled: this.disableModalSubmit }, { variant: 'danger' }], attributes: [{ disabled: this.disableModalSubmit }, { variant: 'danger' }],
}; };
}, },
commandModalId() {
return `init-command-modal-${this.state.name}`;
},
}, },
methods: { methods: {
hideModal() { hideModal() {
...@@ -164,6 +175,9 @@ export default { ...@@ -164,6 +175,9 @@ export default {
}); });
}); });
}, },
copyInitCommand() {
this.showCommandModal = true;
},
}, },
}; };
</script> </script>
...@@ -181,6 +195,14 @@ export default { ...@@ -181,6 +195,14 @@ export default {
<gl-icon class="gl-mr-0" name="ellipsis_v" /> <gl-icon class="gl-mr-0" name="ellipsis_v" />
</template> </template>
<gl-dropdown-item
v-gl-modal-directive="commandModalId"
data-testid="terraform-state-copy-init-command"
@click="copyInitCommand"
>
{{ $options.i18n.copyCommand }}
</gl-dropdown-item>
<gl-dropdown-item <gl-dropdown-item
v-if="state.latestVersion" v-if="state.latestVersion"
data-testid="terraform-state-download" data-testid="terraform-state-download"
...@@ -248,5 +270,11 @@ export default { ...@@ -248,5 +270,11 @@ export default {
/> />
</gl-form-group> </gl-form-group>
</gl-modal> </gl-modal>
<init-command-modal
v-if="showCommandModal"
:modal-id="commandModalId"
:state-name="state.name"
/>
</div> </div>
</template> </template>
...@@ -24,11 +24,16 @@ export default () => { ...@@ -24,11 +24,16 @@ export default () => {
}, },
}); });
const { emptyStateImage, projectPath } = el.dataset; const { emptyStateImage, projectPath, accessTokensPath, terraformApiUrl, username } = el.dataset;
return new Vue({ return new Vue({
el, el,
apolloProvider: new VueApollo({ defaultClient }), apolloProvider: new VueApollo({ defaultClient }),
provide: {
accessTokensPath,
terraformApiUrl,
username,
},
render(createElement) { render(createElement) {
return createElement(TerraformList, { return createElement(TerraformList, {
props: { props: {
......
...@@ -5,7 +5,10 @@ module Projects::TerraformHelper ...@@ -5,7 +5,10 @@ module Projects::TerraformHelper
{ {
empty_state_image: image_path('illustrations/empty-state/empty-serverless-lg.svg'), empty_state_image: image_path('illustrations/empty-state/empty-serverless-lg.svg'),
project_path: project.full_path, project_path: project.full_path,
terraform_admin: current_user&.can?(:admin_terraform_state, project) terraform_admin: current_user&.can?(:admin_terraform_state, project),
access_tokens_path: profile_personal_access_tokens_path,
username: current_user&.username,
terraform_api_url: "#{Settings.gitlab.url}/api/v4/projects/#{project.id}/terraform/state"
} }
end end
end end
...@@ -83,6 +83,14 @@ local machine, this is a simple way to get started: ...@@ -83,6 +83,14 @@ local machine, this is a simple way to get started:
-backend-config="retry_wait_min=5" -backend-config="retry_wait_min=5"
``` ```
If you already have a GitLab-managed Terraform state, you can use the `terraform init` command
with the prepopulated parameters values:
1. On the top bar, select **Menu > Projects** and find your project.
1. On the left sidebar, select **Infrastructure > Terraform**.
1. Next to the environment you want to use, select the [Actions menu](#managing-state-files)
**{ellipsis_v}** and select **Copy Terraform init command**.
You can now run `terraform plan` and `terraform apply` as you normally would. You can now run `terraform plan` and `terraform apply` as you normally would.
### Get started using GitLab CI ### Get started using GitLab CI
...@@ -222,7 +230,7 @@ An example setup is shown below: ...@@ -222,7 +230,7 @@ An example setup is shown below:
```plaintext ```plaintext
example_remote_state_address=https://gitlab.com/api/v4/projects/<TARGET-PROJECT-ID>/terraform/state/<TARGET-STATE-NAME> example_remote_state_address=https://gitlab.com/api/v4/projects/<TARGET-PROJECT-ID>/terraform/state/<TARGET-STATE-NAME>
example_username=<GitLab username> example_username=<GitLab username>
example_access_token=<GitLab Personal Acceess Token> example_access_token=<GitLab Personal Access Token>
``` ```
1. Define the data source by adding the following code block in a `.tf` file (such as `data.tf`): 1. Define the data source by adding the following code block in a `.tf` file (such as `data.tf`):
...@@ -362,10 +370,8 @@ contains these fields: ...@@ -362,10 +370,8 @@ contains these fields:
state file is locked. state file is locked.
- **Pipeline**: A link to the most recent pipeline and its status. - **Pipeline**: A link to the most recent pipeline and its status.
- **Details**: Information about when the state file was created or changed. - **Details**: Information about when the state file was created or changed.
- **Actions**: Actions you can take on the state file, including downloading, - **Actions**: Actions you can take on the state file, including copying the `terraform init` command,
locking, unlocking, or [removing](#remove-a-state-file) the state file and versions: downloading, locking, unlocking, or [removing](#remove-a-state-file) the state file and versions.
![Terraform state list](img/terraform_list_view_actions_v13_8.png)
NOTE: NOTE:
Additional improvements to the Additional improvements to the
......
...@@ -32641,6 +32641,9 @@ msgstr "" ...@@ -32641,6 +32641,9 @@ msgstr ""
msgid "Terraform|Cancel" msgid "Terraform|Cancel"
msgstr "" msgstr ""
msgid "Terraform|Copy Terraform init command"
msgstr ""
msgid "Terraform|Details" msgid "Terraform|Details"
msgstr "" msgstr ""
...@@ -32692,12 +32695,18 @@ msgstr "" ...@@ -32692,12 +32695,18 @@ msgstr ""
msgid "Terraform|States" msgid "Terraform|States"
msgstr "" msgstr ""
msgid "Terraform|Terraform init command"
msgstr ""
msgid "Terraform|The report %{name} failed to generate." msgid "Terraform|The report %{name} failed to generate."
msgstr "" msgstr ""
msgid "Terraform|The report %{name} was generated in your pipelines." msgid "Terraform|The report %{name} was generated in your pipelines."
msgstr "" msgstr ""
msgid "Terraform|To get access to this terraform state from your local computer, run the following command at the command line. The first line requires a personal access token with API read and write access. %{linkStart}How do I create a personal access token?%{linkEnd}."
msgstr ""
msgid "Terraform|To remove the State file and its versions, type %{name} to confirm:" msgid "Terraform|To remove the State file and its versions, type %{name} to confirm:"
msgstr "" msgstr ""
......
...@@ -38,7 +38,7 @@ RSpec.describe 'Terraform', :js do ...@@ -38,7 +38,7 @@ RSpec.describe 'Terraform', :js do
it 'displays a table with terraform states' do it 'displays a table with terraform states' do
expect(page).to have_selector( expect(page).to have_selector(
'[data-testid="terraform-states-table-name"]', "[data-testid='terraform-states-table-name']",
count: project.terraform_states.size count: project.terraform_states.size
) )
end end
...@@ -64,7 +64,7 @@ RSpec.describe 'Terraform', :js do ...@@ -64,7 +64,7 @@ RSpec.describe 'Terraform', :js do
expect(page).to have_content(additional_state.name) expect(page).to have_content(additional_state.name)
find("[data-testid='terraform-state-actions-#{additional_state.name}']").click find("[data-testid='terraform-state-actions-#{additional_state.name}']").click
find('[data-testid="terraform-state-remove"]').click find("[data-testid='terraform-state-remove']").click
fill_in "terraform-state-remove-input-#{additional_state.name}", with: additional_state.name fill_in "terraform-state-remove-input-#{additional_state.name}", with: additional_state.name
click_button 'Remove' click_button 'Remove'
...@@ -72,6 +72,21 @@ RSpec.describe 'Terraform', :js do ...@@ -72,6 +72,21 @@ RSpec.describe 'Terraform', :js do
expect { additional_state.reload }.to raise_error ActiveRecord::RecordNotFound expect { additional_state.reload }.to raise_error ActiveRecord::RecordNotFound
end end
end end
context 'when clicking on copy Terraform init command' do
it 'shows the modal with the init command' do
visit project_terraform_index_path(project)
expect(page).to have_content(terraform_state.name)
page.within("[data-testid='terraform-state-actions-#{terraform_state.name}']") do
click_button class: 'gl-dropdown-toggle'
click_button 'Copy Terraform init command'
end
expect(page).to have_content("To get access to this terraform state from your local computer, run the following command at the command line.")
end
end
end end
end end
...@@ -87,11 +102,11 @@ RSpec.describe 'Terraform', :js do ...@@ -87,11 +102,11 @@ RSpec.describe 'Terraform', :js do
context 'when user visits the index page' do context 'when user visits the index page' do
it 'displays a table without an action dropdown', :aggregate_failures do it 'displays a table without an action dropdown', :aggregate_failures do
expect(page).to have_selector( expect(page).to have_selector(
'[data-testid="terraform-states-table-name"]', "[data-testid='terraform-states-table-name']",
count: project.terraform_states.size count: project.terraform_states.size
) )
expect(page).not_to have_selector('[data-testid*="terraform-state-actions"]') expect(page).not_to have_selector("[data-testid*='terraform-state-actions']")
end end
end end
end end
......
import { GlLink, GlSprintf } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import InitCommandModal from '~/terraform/components/init_command_modal.vue';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
const accessTokensPath = '/path/to/access-tokens-page';
const terraformApiUrl = 'https://gitlab.com/api/v4/projects/1';
const username = 'username';
const modalId = 'fake-modal-id';
const stateName = 'production';
const modalInfoCopyStr = `export GITLAB_ACCESS_TOKEN=<YOUR-ACCESS-TOKEN>
terraform init \\
-backend-config="address=${terraformApiUrl}/${stateName}" \\
-backend-config="lock_address=${terraformApiUrl}/${stateName}/lock" \\
-backend-config="unlock_address=${terraformApiUrl}/${stateName}/lock" \\
-backend-config="username=${username}" \\
-backend-config="password=$GITLAB_ACCESS_TOKEN" \\
-backend-config="lock_method=POST" \\
-backend-config="unlock_method=DELETE" \\
-backend-config="retry_wait_min=5"
`;
describe('InitCommandModal', () => {
let wrapper;
const propsData = {
modalId,
stateName,
};
const provideData = {
accessTokensPath,
terraformApiUrl,
username,
};
const findExplanatoryText = () => wrapper.findByTestId('init-command-explanatory-text');
const findLink = () => wrapper.findComponent(GlLink);
const findInitCommand = () => wrapper.findByTestId('terraform-init-command');
const findCopyButton = () => wrapper.findComponent(ModalCopyButton);
beforeEach(() => {
wrapper = shallowMountExtended(InitCommandModal, {
propsData,
provide: provideData,
stubs: {
GlSprintf,
},
});
});
afterEach(() => {
wrapper.destroy();
});
describe('on rendering', () => {
it('renders the explanatory text', () => {
expect(findExplanatoryText().text()).toContain('personal access token');
});
it('renders the personal access token link', () => {
expect(findLink().attributes('href')).toBe(accessTokensPath);
});
it('renders the init command with the username and state name prepopulated', () => {
expect(findInitCommand().text()).toContain(username);
expect(findInitCommand().text()).toContain(stateName);
});
it('renders the copyToClipboard button', () => {
expect(findCopyButton().exists()).toBe(true);
});
});
describe('when copy button is clicked', () => {
it('copies init command to clipboard', () => {
expect(findCopyButton().props('text')).toBe(modalInfoCopyStr);
});
});
});
...@@ -3,6 +3,7 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; ...@@ -3,6 +3,7 @@ import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import InitCommandModal from '~/terraform/components/init_command_modal.vue';
import StateActions from '~/terraform/components/states_table_actions.vue'; import StateActions from '~/terraform/components/states_table_actions.vue';
import lockStateMutation from '~/terraform/graphql/mutations/lock_state.mutation.graphql'; import lockStateMutation from '~/terraform/graphql/mutations/lock_state.mutation.graphql';
import removeStateMutation from '~/terraform/graphql/mutations/remove_state.mutation.graphql'; import removeStateMutation from '~/terraform/graphql/mutations/remove_state.mutation.graphql';
...@@ -73,12 +74,14 @@ describe('StatesTableActions', () => { ...@@ -73,12 +74,14 @@ describe('StatesTableActions', () => {
return wrapper.vm.$nextTick(); return wrapper.vm.$nextTick();
}; };
const findActionsDropdown = () => wrapper.find(GlDropdown); const findActionsDropdown = () => wrapper.findComponent(GlDropdown);
const findCopyBtn = () => wrapper.find('[data-testid="terraform-state-copy-init-command"]');
const findCopyModal = () => wrapper.findComponent(InitCommandModal);
const findLockBtn = () => wrapper.find('[data-testid="terraform-state-lock"]'); const findLockBtn = () => wrapper.find('[data-testid="terraform-state-lock"]');
const findUnlockBtn = () => wrapper.find('[data-testid="terraform-state-unlock"]'); const findUnlockBtn = () => wrapper.find('[data-testid="terraform-state-unlock"]');
const findDownloadBtn = () => wrapper.find('[data-testid="terraform-state-download"]'); const findDownloadBtn = () => wrapper.find('[data-testid="terraform-state-download"]');
const findRemoveBtn = () => wrapper.find('[data-testid="terraform-state-remove"]'); const findRemoveBtn = () => wrapper.find('[data-testid="terraform-state-remove"]');
const findRemoveModal = () => wrapper.find(GlModal); const findRemoveModal = () => wrapper.findComponent(GlModal);
beforeEach(() => { beforeEach(() => {
return createComponent(); return createComponent();
...@@ -125,6 +128,25 @@ describe('StatesTableActions', () => { ...@@ -125,6 +128,25 @@ describe('StatesTableActions', () => {
}); });
}); });
describe('copy command button', () => {
it('displays a copy init command button', () => {
expect(findCopyBtn().text()).toBe('Copy Terraform init command');
});
describe('when clicking the copy init command button', () => {
beforeEach(() => {
findCopyBtn().vm.$emit('click');
return waitForPromises();
});
it('opens the modal', async () => {
expect(findCopyModal().exists()).toBe(true);
expect(findCopyModal().isVisible()).toBe(true);
});
});
});
describe('download button', () => { describe('download button', () => {
it('displays a download button', () => { it('displays a download button', () => {
expect(findDownloadBtn().text()).toBe('Download JSON'); expect(findDownloadBtn().text()).toBe('Download JSON');
......
...@@ -22,6 +22,18 @@ RSpec.describe Projects::TerraformHelper do ...@@ -22,6 +22,18 @@ RSpec.describe Projects::TerraformHelper do
expect(subject[:project_path]).to eq(project.full_path) expect(subject[:project_path]).to eq(project.full_path)
end end
it 'includes access token path' do
expect(subject[:access_tokens_path]).to eq(profile_personal_access_tokens_path)
end
it 'includes username' do
expect(subject[:username]).to eq(current_user.username)
end
it 'includes terraform state api url' do
expect(subject[:terraform_api_url]).to eq("#{Settings.gitlab.url}/api/v4/projects/#{project.id}/terraform/state")
end
it 'indicates the user is a terraform admin' do it 'indicates the user is a terraform admin' do
expect(subject[:terraform_admin]).to eq(true) expect(subject[:terraform_admin]).to eq(true)
end end
......
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