From 62813dd82bbd1f0e3b89c15ad973d3e4adc7fcff Mon Sep 17 00:00:00 2001 From: Thorben Denzer Date: Tue, 10 Oct 2023 15:53:54 +0200 Subject: [PATCH] Fixes #36971 - GUI to allow cloning of Ansible roles from VCS --- .../api/v2/vcs_clone_controller.rb | 150 +++++ .../foreman_ansible/ansible_roles_helper.rb | 6 + app/jobs/clone_ansible_role.rb | 14 + app/jobs/delete_ansible_role.rb | 14 + app/jobs/update_ansible_role.rb | 14 + app/lib/proxy_api/ansible.rb | 59 +- app/services/foreman_ansible/vcs_cloner.rb | 17 + app/views/ansible_roles/index.html.erb | 13 +- app/views/ansible_roles/welcome.html.erb | 6 + config/routes.rb | 11 + lib/foreman_ansible/register.rb | 4 +- .../api/v2/vcs_clone_controller_test.rb | 107 ++++ .../VcsCloneModalContent.js | 540 ++++++++++++++++++ .../VcsCloneModalContentHelpers.js | 40 ++ .../components/BranchTagSelectionMenu.js | 52 ++ .../components/GitLinkInputComponent.js | 97 ++++ .../components/MultiSelectorMenu.js | 64 +++ .../__test__/BranchTagSelectionMenu.test.js | 95 +++ .../__test__/GitLinkInputComponent.test.js | 139 +++++ .../__test__/MultiSelectorMenu.test.js | 99 ++++ webpack/index.js | 6 + 21 files changed, 1543 insertions(+), 4 deletions(-) create mode 100644 app/controllers/api/v2/vcs_clone_controller.rb create mode 100644 app/jobs/clone_ansible_role.rb create mode 100644 app/jobs/delete_ansible_role.rb create mode 100644 app/jobs/update_ansible_role.rb create mode 100644 app/services/foreman_ansible/vcs_cloner.rb create mode 100644 test/functional/api/v2/vcs_clone_controller_test.rb create mode 100644 webpack/components/VcsCloneModalContent/VcsCloneModalContent.js create mode 100644 webpack/components/VcsCloneModalContent/VcsCloneModalContentHelpers.js create mode 100644 webpack/components/VcsCloneModalContent/components/BranchTagSelectionMenu.js create mode 100644 webpack/components/VcsCloneModalContent/components/GitLinkInputComponent.js create mode 100644 webpack/components/VcsCloneModalContent/components/MultiSelectorMenu.js create mode 100644 webpack/components/VcsCloneModalContent/components/__test__/BranchTagSelectionMenu.test.js create mode 100644 webpack/components/VcsCloneModalContent/components/__test__/GitLinkInputComponent.test.js create mode 100644 webpack/components/VcsCloneModalContent/components/__test__/MultiSelectorMenu.test.js diff --git a/app/controllers/api/v2/vcs_clone_controller.rb b/app/controllers/api/v2/vcs_clone_controller.rb new file mode 100644 index 000000000..9b372b27c --- /dev/null +++ b/app/controllers/api/v2/vcs_clone_controller.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +module Api + module V2 + class VcsCloneController < ::Api::V2::BaseController + include ::ForemanAnsible::ProxyAPI + + rescue_from ActionController::ParameterMissing do |e| + render json: { 'error' => e.message }, status: :bad_request + end + + rescue_from Foreman::Exception do |_e| + head :internal_server_error + end + + skip_before_action :verify_authenticity_token + + before_action :set_proxy_api + + api :GET, '/smart_proxies/:proxy_name/ansible/vcs_clone/repo_information', + N_('Queries metadata about the repo') + param :proxy_name, Array, N_('Name of the SmartProxy'), :required => true + param :vcs_url, String, N_('Url of the repo'), :required => true + error 400, :desc => N_('Parameter unfulfilled / invalid repo-info') + def repo_information + vcs_url = params.require(:vcs_url) + render json: @proxy_api.repo_information(vcs_url) + end + + api :GET, '/smart_proxies/:proxy_name/ansible/vcs_clone/roles', + N_('Returns an array of roles installed on the provided proxy') + formats ['json'] + param :proxy_name, Array, N_('Name of the SmartProxy'), :required => true + error 400, :desc => N_('Parameter unfulfilled') + def installed_roles + render json: @proxy_api.list_installed + end + + api :POST, '/smart_proxies/:proxy_name/ansible/vcs_clone/roles', + N_('Launches a task to install the provided role') + formats ['json'] + param :repo_info, Hash, :desc => N_('Dictionary containing info about the role to be installed') do + param :vcs_url, String, :desc => N_('Url of the repo'), :required => true + param :name, String, :desc => N_('Name of the repo'), :required => true + param :ref, String, :desc => N_('Branch / Tag / Commit reference'), :required => true + end + param :smart_proxy, Array, N_('SmartProxy the role should get installed to') + error 400, :desc => N_('Parameter unfulfilled') + def install_role + payload = verify_install_role_parameters(params) + start_vcs_task(payload, :install) + end + + api :PUT, '/smart_proxies/:proxy_name/ansible/vcs_clone/roles', + N_('Launches a task to update the provided role') + formats ['json'] + param :repo_info, Hash, :desc => N_('Dictionary containing info about the role to be installed') do + param :vcs_url, String, :desc => N_('Url of the repo'), :required => true + param :name, String, :desc => N_('Name of the repo'), :required => true + param :ref, String, :desc => N_('Branch / Tag / Commit reference'), :required => true + end + param :smart_proxy, Array, N_('SmartProxy the role should get installed to') + error 400, :desc => N_('Parameter unfulfilled') + def update_role + payload = verify_update_role_parameters(params) + payload['name'] = params.require(:role_name) + start_vcs_task(payload, :update) + end + + api :DELETE, '/smart_proxies/:proxy_name/ansible/vcs_clone/roles/:role_name', + N_('Launches a task to delete the provided role') + formats ['json'] + param :role_name, String, :desc => N_('Name of the role that should be deleted') + param :smart_proxy, Array, N_('SmartProxy the role should get deleted from') + error 400, :desc => N_('Parameter unfulfilled') + def delete_role + payload = params.require(:role_name) + start_vcs_task(payload, :delete) + end + + private + + def set_proxy_api + unless params[:id] + msg = _('Smart proxy id is required') + return render_error('custom_error', :status => :unprocessable_entity, :locals => { :message => msg }) + end + ansible_proxy = SmartProxy.find_by(id: params[:id]) + if ansible_proxy.nil? + msg = _('Smart proxy does not exist') + return render_error('custom_error', :status => :bad_request, :locals => { :message => msg }) + else unless ansible_proxy.has_capability?('Ansible', 'vcs_clone') + msg = _('Smart proxy does not have foreman_ansible installed / is not capable of cloning from VCS') + return render_error('custom_error', :status => :bad_request, :locals => { :message => msg }) + end + end + @proxy = ansible_proxy + @proxy_api = find_proxy_api(ansible_proxy) + end + + def permit_parameters(params) + params.require(:vcs_clone). + permit( + repo_info: [ + :vcs_url, + :name, + :ref + ] + ).to_h + end + + def verify_install_role_parameters(params) + payload = permit_parameters params + %w[vcs_url name ref].each do |param| + raise ActionController::ParameterMissing.new(param) unless payload['repo_info'].key?(param) + end + payload + end + + def verify_update_role_parameters(params) + payload = permit_parameters params + %w[vcs_url ref].each do |param| + raise ActionController::ParameterMissing.new(param) unless payload['repo_info'].key?(param) + end + payload + end + + def start_vcs_task(op_info, operation) + case operation + when :update + job = UpdateAnsibleRole.perform_later(op_info, @proxy) + when :install + job = CloneAnsibleRole.perform_later(op_info, @proxy) + when :delete + job = DeleteAnsibleRole.perform_later(op_info, @proxy) + else + raise Foreman::Exception.new(N_('Unsupported operation')) + end + + task = ForemanTasks::Task.find_by(external_id: job.provider_job_id) + + render json: { + task: task + }, status: :ok + rescue Foreman::Exception + head :internal_server_error + end + end + end +end diff --git a/app/helpers/foreman_ansible/ansible_roles_helper.rb b/app/helpers/foreman_ansible/ansible_roles_helper.rb index b211a568b..e80f0a13c 100644 --- a/app/helpers/foreman_ansible/ansible_roles_helper.rb +++ b/app/helpers/foreman_ansible/ansible_roles_helper.rb @@ -31,6 +31,12 @@ def ansible_proxy_import(hash) ansible_proxy_links(hash)) end + def vcs_import + select_action_button("", + { :primary => true, :class => 'roles-import' }, + link_to(_("Import from VCS..."), "#vcs_import")) + end + def import_time(role) _('%s ago') % time_ago_in_words(role.updated_at) end diff --git a/app/jobs/clone_ansible_role.rb b/app/jobs/clone_ansible_role.rb new file mode 100644 index 000000000..ad014bb32 --- /dev/null +++ b/app/jobs/clone_ansible_role.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CloneAnsibleRole < ::ApplicationJob + queue_as :default + + def humanized_name + _('Clone Ansible Role from VCS') + end + + def perform(repo_info, proxy) + vcs_cloner = ForemanAnsible::VcsCloner.new(proxy) + vcs_cloner.install_role repo_info + end +end diff --git a/app/jobs/delete_ansible_role.rb b/app/jobs/delete_ansible_role.rb new file mode 100644 index 000000000..34f0d2281 --- /dev/null +++ b/app/jobs/delete_ansible_role.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class DeleteAnsibleRole < ::ApplicationJob + queue_as :default + + def humanized_name + _('Delete Ansible Role') + end + + def perform(role_name, proxy) + vcs_cloner = ForemanAnsible::VcsCloner.new(proxy) + vcs_cloner.delete_role role_name + end +end diff --git a/app/jobs/update_ansible_role.rb b/app/jobs/update_ansible_role.rb new file mode 100644 index 000000000..9452319a6 --- /dev/null +++ b/app/jobs/update_ansible_role.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class UpdateAnsibleRole < ::ApplicationJob + queue_as :default + + def humanized_name + _('Update Ansible Role from VCS') + end + + def perform(repo_info, proxy) + vcs_cloner = ForemanAnsible::VcsCloner.new(proxy) + vcs_cloner.update_role repo_info + end +end diff --git a/app/lib/proxy_api/ansible.rb b/app/lib/proxy_api/ansible.rb index f54eaec98..07ca77660 100644 --- a/app/lib/proxy_api/ansible.rb +++ b/app/lib/proxy_api/ansible.rb @@ -4,7 +4,7 @@ module ProxyAPI # ProxyAPI for Ansible class Ansible < ::ProxyAPI::Resource def initialize(args) - @url = args[:url] + '/ansible/' + @url = "#{args[:url]}/ansible/" super args end @@ -53,5 +53,62 @@ def playbooks(playbooks_names = []) rescue *PROXY_ERRORS => e raise ProxyException.new(url, e, N_('Unable to get playbooks from Ansible')) end + + def repo_information(vcs_url) + parse(get("vcs_clone/repo_information?vcs_url=#{vcs_url}")) + rescue *PROXY_ERRORS, RestClient::Exception => e + raise e unless e.is_a? RestClient::RequestFailed + case e.http_code + when 400 + raise Foreman::Exception.new N_('Error requesting repo-info. Check Smartproxy log.') + else + raise + end + end + + def list_installed + parse(get('vcs_clone/roles')) + rescue *PROXY_ERRORS + raise Foreman::Exception.new N_('Error requesting installed roles. Check log.') + end + + def install_role(repo_info) + parse(post(repo_info, 'vcs_clone/roles')) + rescue *PROXY_ERRORS, RestClient::Exception => e + raise e unless e.is_a? RestClient::RequestFailed + case e.http_code + when 409 + raise Foreman::Exception.new N_('A repo with the name %rName already exists.') % repo_info['name'] + when 400 + raise Foreman::Exception.new N_('Git Error. Check log.') + else + raise + end + end + + def update_role(repo_info) + name = repo_info.delete('name') + parse(put(repo_info, "vcs_clone/roles/#{name}")) + rescue *PROXY_ERRORS, RestClient::Exception => e + raise e unless e.is_a? RestClient::RequestFailed + case e.http_code + when 400 + raise Foreman::Exception.new N_('Error updating %rName. Check Smartproxy log.') % name + else + raise + end + end + + def delete_role(role_name) + parse(delete("vcs_clone/roles/#{role_name}")) + rescue *PROXY_ERRORS, RestClient::Exception => e + raise e unless e.is_a? RestClient::RequestFailed + case e.http_code + when 400 + raise Foreman::Exception.new N_('Error deleting %rName. Check Smartproxy log.') % role_name + else + raise + end + end end end diff --git a/app/services/foreman_ansible/vcs_cloner.rb b/app/services/foreman_ansible/vcs_cloner.rb new file mode 100644 index 000000000..15f29dd7d --- /dev/null +++ b/app/services/foreman_ansible/vcs_cloner.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module ForemanAnsible + class VcsCloner + include ::ForemanAnsible::ProxyAPI + + def initialize(proxy = nil) + @ansible_proxy = proxy + end + + delegate :install_role, to: :proxy_api + + delegate :update_role, to: :proxy_api + + delegate :delete_role, to: :proxy_api + end +end diff --git a/app/views/ansible_roles/index.html.erb b/app/views/ansible_roles/index.html.erb index def0fd832..7809d8f2e 100644 --- a/app/views/ansible_roles/index.html.erb +++ b/app/views/ansible_roles/index.html.erb @@ -1,7 +1,14 @@ + +<%= webpacked_plugins_js_for :foreman_ansible %> +<%= webpacked_plugins_css_for :foreman_ansible %> + +<%= csrf_meta_tag %> + <% title _("Ansible Roles") %> -<% title_actions ansible_proxy_import(hash_for_import_ansible_roles_path), - documentation_button('#4.1ImportingRoles', :root_url => ansible_doc_url) %> +<% title_actions ansible_proxy_import(hash_for_import_ansible_roles_path), vcs_import, + documentation_button('#4.1ImportingRoles', :root_url => ansible_doc_url) +%> @@ -44,4 +51,6 @@
+<%= react_component('VcsCloneModalContent')%> + <%= will_paginate_with_info @ansible_roles %> diff --git a/app/views/ansible_roles/welcome.html.erb b/app/views/ansible_roles/welcome.html.erb index fd2011862..6b1b8b5ed 100644 --- a/app/views/ansible_roles/welcome.html.erb +++ b/app/views/ansible_roles/welcome.html.erb @@ -1,3 +1,6 @@ +<%= webpacked_plugins_js_for :foreman_ansible %> +<%= webpacked_plugins_css_for :foreman_ansible %> + <% content_for(:title, _("Ansible Roles")) %>
@@ -10,5 +13,8 @@

<%= link_to(_('Learn more about this in the documentation.'), documentation_url('#4.1ImportingRoles', :root_url => ansible_doc_url), target: '_blank') %>

<%= ansible_proxy_import(hash_for_import_ansible_roles_path) %> + <%= vcs_import %>
+ +<%= react_component('VcsCloneModalContent', {:title => "Get Ansible-Roles from VCS"})%> diff --git a/config/routes.rb b/config/routes.rb index 1a0431e4f..8349ad5f1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -32,6 +32,17 @@ post :multiple_play_roles end end + resources :smart_proxies, :only => [] do + member do + scope '/ansible' do + get 'repo_information', to: 'vcs_clone#repo_information' + get 'roles', to: 'vcs_clone#installed_roles' + post 'roles', to: 'vcs_clone#install_role' + put 'roles/:role_name', to: 'vcs_clone#update_role', constraints: { role_name: %r{[^\/]+} } + delete 'roles/:role_name', to: 'vcs_clone#delete_role', constraints: { role_name: %r{[^\/]+} } + end + end + end end end end diff --git a/lib/foreman_ansible/register.rb b/lib/foreman_ansible/register.rb index a09a49d57..cbb81a594 100644 --- a/lib/foreman_ansible/register.rb +++ b/lib/foreman_ansible/register.rb @@ -160,6 +160,8 @@ { :'api/v2/ansible_inventories' => [:schedule] } permission :import_ansible_playbooks, { :'api/v2/ansible_playbooks' => [:sync, :fetch] } + permission :clone_from_vcs, + { :'api/v2/vcs_clone' => [:repo_information, :installed_roles, :install_role, :update_role, :delete_role] } end role 'Ansible Roles Manager', @@ -170,7 +172,7 @@ :import_ansible_roles, :view_ansible_variables, :view_lookup_values, :create_lookup_values, :edit_lookup_values, :destroy_lookup_values, :create_ansible_variables, :import_ansible_variables, - :edit_ansible_variables, :destroy_ansible_variables, :import_ansible_playbooks] + :edit_ansible_variables, :destroy_ansible_variables, :import_ansible_playbooks, :clone_from_vcs] role 'Ansible Tower Inventory Reader', [:view_hosts, :view_hostgroups, :view_facts, :generate_report_templates, :generate_ansible_inventory, diff --git a/test/functional/api/v2/vcs_clone_controller_test.rb b/test/functional/api/v2/vcs_clone_controller_test.rb new file mode 100644 index 000000000..f21db651d --- /dev/null +++ b/test/functional/api/v2/vcs_clone_controller_test.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'test_plugin_helper' + +module Api + module V2 + class VcsCloneControllerTest < ActionController::TestCase + describe 'input' do + test 'handles missing proxy capability' do + proxy = FactoryBot.create(:smart_proxy, :with_ansible) + + get :repo_information, + params: { id: proxy.id, vcs_url: 'https://github.com/theforeman/foreman_ansible.git' }, + session: set_session_user + + response = JSON.parse(@response.body) + assert_response :bad_request + assert_equal({ 'error' => + { 'message' => 'Smart proxy does not have foreman_ansible installed / is not capable of cloning from VCS' } }, response) + end + end + describe '#repo_information' do + test 'requests repo information' do + proxy = FactoryBot.create(:smart_proxy, :with_ansible) + SmartProxy.any_instance.stubs(:has_capability?).returns(true) + ProxyAPI::Ansible.any_instance.expects(:repo_information).returns({ + 'head' => {}, + 'branches' => {}, + 'tags' => {}, + 'vcs_url' => 'https://github.com/theforeman/foreman_ansible.git' + }) + + get :repo_information, + params: { id: proxy.id, vcs_url: 'https://github.com/theforeman/foreman_ansible.git' }, + session: set_session_user + + response = JSON.parse(@response.body) + assert_response :success + assert_equal({ 'head' => {}, + 'branches' => {}, + 'tags' => {}, + 'vcs_url' => 'https://github.com/theforeman/foreman_ansible.git' }, response) + end + end + describe '#installed_roles' do + test 'requests installed roles' do + proxy = FactoryBot.create(:smart_proxy, :with_ansible) + SmartProxy.any_instance.stubs(:has_capability?).returns(true) + ProxyAPI::Ansible.any_instance.expects(:list_installed).returns(%w[role1 role2]) + + get :installed_roles, + params: { id: proxy.id }, + session: set_session_user + + response = JSON.parse(@response.body) + assert_response :success + assert_equal(%w[role1 role2], response) + end + end + describe '#install_role' do + test 'installes a role' do + proxy = FactoryBot.create(:smart_proxy, :with_ansible) + SmartProxy.any_instance.stubs(:has_capability?).returns(true) + + post :install_role, + params: { id: proxy.id, repo_info: { + 'vcs_url' => 'https://github.com/theforeman/foreman_ansible.git', + 'name' => 'best.role.ever', + 'ref' => 'master' + } }, + session: set_session_user + assert_response :success + end + test 'handles faulty parameters' do + proxy = FactoryBot.create(:smart_proxy, :with_ansible) + SmartProxy.any_instance.stubs(:has_capability?).returns(true) + + post :install_role, + params: { id: proxy.id, 'repo_info': { + 'vcs_urll' => 'https://github.com/theforeman/foreman_ansible.git', + 'name' => 'best.role.ever', + 'ref' => 'master' + } }, + session: set_session_user + response = JSON.parse(@response.body) + assert_response :bad_request + assert_equal({ 'error' => 'param is missing or the value is empty: vcs_url' }, response) + end + end + describe '#update_role' do + # With the difference of the http-method being PUT, this is + # identical to #install_role + end + describe '#delete_role' do + test 'deletes a role' do + proxy = FactoryBot.create(:smart_proxy, :with_ansible) + SmartProxy.any_instance.stubs(:has_capability?).returns(true) + + delete :delete_role, + params: { id: proxy.id, role_name: 'best.role.ever' }, + session: set_session_user + assert_response :success + end + end + end + end +end diff --git a/webpack/components/VcsCloneModalContent/VcsCloneModalContent.js b/webpack/components/VcsCloneModalContent/VcsCloneModalContent.js new file mode 100644 index 000000000..dec16dd03 --- /dev/null +++ b/webpack/components/VcsCloneModalContent/VcsCloneModalContent.js @@ -0,0 +1,540 @@ +/* eslint-disable max-lines */ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Modal, + ModalVariant, + Button, + Grid, + GridItem, + Form, + FormGroup, + TextInput, + Text, + TextVariants, + Select, + SelectOption, + SelectVariant, + Switch, +} from '@patternfly/react-core'; +import { translate as __, sprintf } from 'foremanReact/common/I18n'; +import { MultiSelectorMenu } from './components/MultiSelectorMenu'; +import { GitLinkInputComponent } from './components/GitLinkInputComponent'; +import { BranchTagSelectionMenu } from './components/BranchTagSelectionMenu'; +import { + showErrorToast, + showSuccessToast, +} from './VcsCloneModalContentHelpers'; + +export const VcsCloneModalContent = () => { + // STATE DEFINITION + + const [selectedItem, setSelectedItem] = useState(''); + + const [installedRoles, setInstalledRoles] = useState({}); + + const [repoName, setRepoName] = useState(undefined); + + const [isLoading, setIsLoading] = useState(false); + const [isModalButtonLoading, setIsModalButtonLoading] = useState(false); + const [isModalButtonActive, setIsModalButtonActive] = useState(false); // false + + const [isModalOpen, setIsModalOpen] = useState(false); // change to false + + const [repoInfo, setRepoInfo] = useState(); + + const [isAlertVisible, setIsAlertVisible] = useState(false); + const [alertText, setAlertText] = useState(''); + + const [finalProcedure, setFinalProcedure] = useState(''); + + const [originalRepoName, setOriginalRepoName] = useState(''); + + const [smartProxies, setSmartProxies] = useState([]); + const [smartProxySelection, setSmartProxySelection] = useState([]); + + const [isSmartProxyDropdownOpen, setIsSmartProxyDropdownOpen] = useState( + false + ); + + const [updateExisting, setUpdateExisting] = useState(false); + + // EFFECT-HANDLERS + + /** + * Watch URL-anchor and open/close modal. + */ + useEffect(() => { + // Handle direct link ...#vcs_import + if (window.location.hash === '#vcs_import') { + handleModalToggle(); + } + // Set event listener for anchor change + onhashchange = () => { + if (window.location.hash === '#vcs_import') { + handleModalToggle(); + } else { + window.location.hash = ''; + } + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + /** + * Fetch SmartProxies when the modal is opened. + */ + useEffect(() => { + async function fetchSmartProxies() { + if (isModalOpen) { + const response = await fetch('/api/smart_proxies'); + const responseJson = await response.json(); + const tempSmartProxies = {}; + responseJson.results.forEach(proxy => + proxy.features.forEach(feature => { + if (feature.name === 'Ansible') { + if (feature.capabilities.includes('vcs_clone')) { + tempSmartProxies[proxy.name] = proxy.id; + } + } + }) + ); + setSmartProxies(tempSmartProxies); + if (Object.keys(tempSmartProxies).length > 0) { + setSmartProxySelection([Object.keys(tempSmartProxies)[0]]); + } else { + setAlertText( + __('No smartproxies with support for cloning from VCS found') + ); + setIsAlertVisible(true); + } + } + } + + fetchSmartProxies(); + }, [isModalOpen]); + + /** + * Check if a role is installed when a SP is selected. + */ + useEffect(() => { + async function initInstalledCheckForSmartProxySelection() { + if ( + smartProxySelection.length !== 0 && + !(smartProxySelection in installedRoles) + ) { + await getInstalledRolesAtProxy(smartProxySelection[0]); + checkIfRoleIsInstalled(); + } + } + + /** + * Method to query which roles are installed on a given SmartProxy. + * Called by: smartProxySelection-Effect + * Sends a request to the server, which responds with an array of roles that are installed on the provided proxy. + * @param proxy SmartProxy from which the roles should be queried. + */ + async function getInstalledRolesAtProxy(proxy) { + const response = await fetch( + `/api/v2/smart_proxies/${smartProxies[smartProxySelection]}/ansible/roles`, + { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + } + ); + if (response.ok) { + const responseJson = await response.json(); + const installedRolesMap = new Map(); + responseJson.forEach(key => installedRolesMap.set(key, null)); + const temp = installedRoles; + temp[proxy] = installedRolesMap; + setInstalledRoles(temp); + } + } + + initInstalledCheckForSmartProxySelection(); + }, [ + checkIfRoleIsInstalled, + installedRoles, + smartProxies, + smartProxySelection, + ]); + + /** + * Check if a role is installed when the name is changed. + */ + useEffect(() => { + setIsAlertVisible(false); + + if (repoName === __('COULD NOT EXTRACT NAME')) { + setAlertText(__('Could not extract repo name. Use manual input.')); + setIsAlertVisible(true); + } + async function initInstalledCheckForRepoName() { + if (repoName) { + checkIfRoleIsInstalled(); + } + } + initInstalledCheckForRepoName(); + }, [checkIfRoleIsInstalled, repoName]); + + /** + * Check if a role is installed when 'Skipping Existing' is toggled. + */ + useEffect(() => { + async function initInstalledCheckForUpdateExisting() { + setIsAlertVisible(false); + if (!updateExisting) { + checkIfRoleIsInstalled(); + } + } + initInstalledCheckForUpdateExisting(); + }, [checkIfRoleIsInstalled, updateExisting]); + + /** + * Disable the 'Confirm'-Button when the input is erroneous, i.e. the alert is visible. + */ + useEffect(() => { + setIsModalButtonActive(!isAlertVisible); + }, [isAlertVisible]); + + /** + * Update the 'Final Task'-message when an input value is changed. + */ + useEffect(() => { + setFinalProcedure( + sprintf( + __( + 'Clone [ %(item)s ] from %(oName)s to SP(s) [ %(sps)s ] as %(rName)s - %(update)s' + ), + { + item: selectedItem, + oName: originalRepoName, + sps: smartProxySelection, + rName: repoName, + update: updateExisting + ? __('updating existing') + : __('skipping existing'), + } + ) + ); + }, [ + selectedItem, + originalRepoName, + smartProxySelection, + repoName, + updateExisting, + ]); + + // CALLBACKS + + /** + * To be called when the modal is to be opened or closed. + * Called by: 'Cancel'- and 'x'-button + * Resets all the states and toggles modal visibility. + */ + const handleModalToggle = useCallback(() => { + if (isModalOpen) { + setSelectedItem(undefined); + setInstalledRoles(undefined); + setRepoName(''); + setIsLoading(false); + setIsModalButtonLoading(false); + setIsModalButtonActive(false); + setIsModalOpen(false); + setRepoInfo(undefined); + setIsAlertVisible(false); + setAlertText(''); + setFinalProcedure(''); + setOriginalRepoName(''); + setSmartProxies([]); + setSmartProxySelection([]); + setUpdateExisting(false); + } + setIsModalOpen(!isModalOpen); + window.location.hash = ''; + }, [isModalOpen]); + + /** + * To be called when all the inputs are verified and the repo should be cloned. + * Called by: 'Confirm'-Button + * Sends a request to the server, which starts a new VcsClone-Task . + */ + const handleConfirmButton = useCallback(async () => { + setIsModalButtonLoading(true); + + const response = await fetch( + updateExisting + ? `/api/v2/smart_proxies/${smartProxies[smartProxySelection]}/ansible/roles/${repoName}` + : `/api/v2/smart_proxies/${smartProxies[smartProxySelection]}/ansible/roles`, + { + method: updateExisting ? 'PUT' : 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + repo_info: { + vcs_url: repoInfo.vcs_url, + name: repoName, + ref: selectedItem, + }, + }), + } + ); + if (!response.ok) { + showErrorToast(response.status); + } else { + const responseJson = await response.json(); + showSuccessToast(responseJson.task.id, originalRepoName); + } + setIsModalButtonLoading(false); + }, [ + repoInfo, + repoName, + selectedItem, + updateExisting, + smartProxySelection, + originalRepoName, + smartProxies, + ]); + + /** + * To be called when Repo-information is to be requested. + * Called by: 'Examine'-Button + * Sends a request to the server, which responds with information about the provided repository. + */ + const handleExamineButton = useCallback( + async gitUrl => { + const roleNameExpr = new RegExp(`^.*/(.*/.*).git$`); + const matched = roleNameExpr.exec(gitUrl); + try { + setRepoName(matched[1].replace('/', '.').toLowerCase()); + setOriginalRepoName(matched[1]); + } catch (e) { + setRepoName(__('COULD NOT EXTRACT NAME')); + } + + // TODO: Handle timeouts + setIsLoading(true); + + const response = await fetch( + `/api/v2/smart_proxies/${ + smartProxies[smartProxySelection] + }/ansible/repo_information?${new URLSearchParams({ + vcs_url: gitUrl, + })}`, + { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + } + ); + + if (!response.ok) { + setIsLoading(false); + setAlertText(__('Could not request metadata. Use manual input.')); + setIsAlertVisible(true); + } else { + const responseJson = await response.json(); + setRepoInfo(responseJson); + setIsLoading(false); + } + }, + [smartProxies, smartProxySelection] + ); + + /** + * To be called when a SmartProxy should be selected. + * Called by: 'SmartProxies'-field + * Updates the smartProxySelection-state with the new selection. + * Note: Currently only one SmartProxy may be selected. Still, an array + * is used to allow the selection of multiple SmartProxies in the future. + */ + const handleSmartProxySelect = useCallback( + async (_event, value) => { + let updatedSmartProxySelection; + if (smartProxySelection.includes(value)) { + updatedSmartProxySelection = []; + } else { + updatedSmartProxySelection = [value]; + } + + setSmartProxySelection(updatedSmartProxySelection); + }, + [smartProxySelection] + ); + + // HELPER FUNCTIONS + + /** + * Dynamically creates the child-elements of the 'SmartProxies'-Field. + * Called by: Render of 'SmartProxies' FormGroup. + * @returns {*[]} Array of values. + */ + function createSmartProxySelectItems() { + const smartProxyArray = []; + // eslint-disable-next-line no-unused-vars + for (const proxy of Object.keys(smartProxies)) { + smartProxyArray.push(); + } + return smartProxyArray; + } + + /** + * Method to do the actual checking of whether a role is already present on the selected SmartProxy. + * Called by: repoName-, updateExisting- and smartProxySelection-effects + * Checks whether 'Repo name' is a role that is already present on the selected SmartProxy. + * -> Shows the alert if a collision is present. + */ + + const checkIfRoleIsInstalled = useCallback(() => { + if (smartProxySelection.length !== 0) { + // eslint-disable-next-line no-unused-vars + for (const proxy of Object.keys(smartProxies)) { + const roles = installedRoles[proxy]; + + if (roles !== undefined) { + if (roles.has(repoName) && !updateExisting) { + setAlertText( + sprintf( + __( + 'A repository with the name %(rName)s is already present on %(pName)s' + ), + { rName: repoName, pName: proxy } + ) + ); + setIsAlertVisible(true); + } + } + } + } + }, [ + installedRoles, + repoName, + smartProxies, + smartProxySelection, + updateExisting, + ]); + + return ( + + + {__('Confirm')} + , + , + ]} + > + + + + + + setSelectedItem(input)} + /> + } + tagMenu={ + setSelectedItem(input)} + /> + } + manualInput={ +
+ + setSelectedItem(input)} + /> + +
+ } + branchTagsEnabled={!(repoInfo === undefined)} + /> +
+ +
+ + + +
+
+ +
+ + setRepoName(input)} + aria-label={__('Repo name input')} + /> + +
+
+ + setUpdateExisting(value)} + /> + + +
+ + + {finalProcedure} + + +
+
+
+
+
+ ); +}; diff --git a/webpack/components/VcsCloneModalContent/VcsCloneModalContentHelpers.js b/webpack/components/VcsCloneModalContent/VcsCloneModalContentHelpers.js new file mode 100644 index 000000000..3df11d859 --- /dev/null +++ b/webpack/components/VcsCloneModalContent/VcsCloneModalContentHelpers.js @@ -0,0 +1,40 @@ +import React from 'react'; +import { translate as __, sprintf } from 'foremanReact/common/I18n'; +import { showToast } from '../../toastHelper'; +import { foremanUrl } from '../AnsibleRolesAndVariables/AnsibleRolesAndVariablesActions'; + +export const showSuccessToast = (taskId, repoName) => { + showToast({ + type: 'success', + message: ( + + {sprintf(__('Cloning of %(rName)s from VCS started:'), { + rName: repoName, + })} +
+ + {sprintf(__('View task %(tId)s'), { tId: taskId })} + +
+ ), + }); +}; + +export const showErrorToast = (statusCode, repoName) => { + showToast({ + type: 'danger', + message: ( + + {sprintf(__('Could not start cloning %(rName)s from VCS'), { + rName: repoName, + })} +
+ {sprintf(__('Status-Code: %(status)s'), { status: statusCode })} +
+ ), + }); +}; diff --git a/webpack/components/VcsCloneModalContent/components/BranchTagSelectionMenu.js b/webpack/components/VcsCloneModalContent/components/BranchTagSelectionMenu.js new file mode 100644 index 000000000..4a456a6c3 --- /dev/null +++ b/webpack/components/VcsCloneModalContent/components/BranchTagSelectionMenu.js @@ -0,0 +1,52 @@ +import React, { useState } from 'react'; +import { Tabs, Tab, TabTitleText } from '@patternfly/react-core'; +import PropTypes from 'prop-types'; +import { translate as __ } from 'foremanReact/common/I18n'; + +export const BranchTagSelectionMenu = props => { + const [activeTabKey, setActiveTabKey] = useState(0); + const handleTabClick = (event, tabIndex) => { + setActiveTabKey(tabIndex); + }; + return ( + + {__('Manual input')}} + > + {props.manualInput} + + {__('Branches')}} + isDisabled={!props.branchTagsEnabled} + > + {props.branchMenu} + + {__('Tags')}} + isDisabled={!props.branchTagsEnabled} + > + {props.tagMenu} + + + ); +}; + +BranchTagSelectionMenu.propTypes = { + manualInput: PropTypes.node, + branchMenu: PropTypes.object, + tagMenu: PropTypes.object, + branchTagsEnabled: PropTypes.bool, +}; + +BranchTagSelectionMenu.defaultProps = { + manualInput: undefined, + branchMenu: [], + tagMenu: [], + branchTagsEnabled: false, +}; diff --git a/webpack/components/VcsCloneModalContent/components/GitLinkInputComponent.js b/webpack/components/VcsCloneModalContent/components/GitLinkInputComponent.js new file mode 100644 index 000000000..90a4dacee --- /dev/null +++ b/webpack/components/VcsCloneModalContent/components/GitLinkInputComponent.js @@ -0,0 +1,97 @@ +import React, { useState } from 'react'; +import { + Button, + InputGroup, + TextInput, + Form, + Alert, + FormGroup, + FormAlert, +} from '@patternfly/react-core'; +import PropTypes from 'prop-types'; +import { translate as __ } from 'foremanReact/common/I18n'; + +export const GitLinkInputComponent = props => { + const [isButtonActive, setButtonActive] = useState(false); + const [textInput, setTextInput] = useState(''); + const [validated, setValidated] = useState('default'); + + const acceptedRegex = /^.*\.git$/; + + const primaryLoadingProps = {}; + primaryLoadingProps.spinnerAriaValueText = 'Loading'; + primaryLoadingProps.spinnerAriaLabelledBy = 'primary-loading-button'; + primaryLoadingProps.isLoading = props.isLoading; + + const handleTextInput = (gitLink, event) => { + setTextInput(gitLink); + if (acceptedRegex.test(gitLink)) { + setValidated('success'); + setButtonActive(true); + } else { + setValidated('error'); + if (isButtonActive) { + setButtonActive(false); + } + } + }; + + return ( + +
+ + + + + + {' '} + + + +
+
+ ); +}; + +GitLinkInputComponent.propTypes = { + isLoading: PropTypes.bool, + isAlertVisible: PropTypes.bool, + alertText: PropTypes.string, + buttonAction: PropTypes.func, +}; + +GitLinkInputComponent.defaultProps = { + isLoading: false, + isAlertVisible: false, + alertText: '', + buttonAction: undefined, +}; diff --git a/webpack/components/VcsCloneModalContent/components/MultiSelectorMenu.js b/webpack/components/VcsCloneModalContent/components/MultiSelectorMenu.js new file mode 100644 index 000000000..65a6da3c5 --- /dev/null +++ b/webpack/components/VcsCloneModalContent/components/MultiSelectorMenu.js @@ -0,0 +1,64 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { Menu, MenuContent, MenuList, MenuItem } from '@patternfly/react-core'; + +export const MultiSelectorMenu = props => { + const [isMenuVisible, setMenuVisible] = useState(false); + + const createSelectItems = () => { + const items = []; + + if (props.repoInfo !== undefined) { + const { repoInfo } = props; + + // eslint-disable-next-line no-unused-vars + for (const item of Object.keys(repoInfo[props.displayData])) { + items.push( + + {item} + + ); + } + } + return items; + }; + + useEffect(() => { + if (props.repoInfo !== undefined) { + setMenuVisible(true); + } + }, [props.repoInfo]); + + return ( + props.onSelect(item)} + selected={props.selectedItem} + isScrollable + hidden={!isMenuVisible} + > + + + {createSelectItems()} + + + + ); +}; + +MultiSelectorMenu.propTypes = { + repoInfo: PropTypes.object, + displayData: PropTypes.string, + onSelect: PropTypes.func, + selectedItem: PropTypes.string, +}; + +MultiSelectorMenu.defaultProps = { + repoInfo: undefined, + displayData: '', + onSelect: undefined, + selectedItem: '', +}; diff --git a/webpack/components/VcsCloneModalContent/components/__test__/BranchTagSelectionMenu.test.js b/webpack/components/VcsCloneModalContent/components/__test__/BranchTagSelectionMenu.test.js new file mode 100644 index 000000000..6d9c82487 --- /dev/null +++ b/webpack/components/VcsCloneModalContent/components/__test__/BranchTagSelectionMenu.test.js @@ -0,0 +1,95 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { BranchTagSelectionMenu } from '../BranchTagSelectionMenu'; + +describe('BranchTagSelectionMenu', () => { + const branchTagSelectionMenuEnabled = ( + BRANCH_MENU
} + tagMenu={
TAG_MENU
} + manualInput="MANUAL_INPUT" + branchTagsEnabled + /> + ); + + const branchTagSelectionMenuDisabled = ( + BRANCH_MENU} + tagMenu={
TAG_MENU
} + manualInput="MANUAL_INPUT" + branchTagsEnabled={false} + /> + ); + + it('tests the default component', () => { + const { container } = render(branchTagSelectionMenuDisabled); + + expect(container).toBeInTheDocument(); + + expect(screen.getByText('BRANCH_MENU')).toBeInTheDocument(); + expect(screen.getByText('TAG_MENU')).toBeInTheDocument(); + expect(screen.getByText('MANUAL_INPUT')).toBeInTheDocument(); + }); + + it('test whether branches/tags tabs are enabled', () => { + const { container } = render(branchTagSelectionMenuEnabled); + + const branchTab = screen + .getByTestId('BranchTagSelectionMenuBranchTab') + .closest('li'); + const tagTab = screen + .getByTestId('BranchTagSelectionMenuTagTab') + .closest('li'); + const manualTab = screen + .getByTestId('BranchTagSelectionMenuManualTab') + .closest('li'); + + expect(container).toBeInTheDocument(); + + expect(branchTab).not.toHaveClass('pf-m-disabled'); + expect(tagTab).not.toHaveClass('pf-m-disabled'); + expect(manualTab).not.toHaveClass('pf-m-disabled'); + }); + + it('test whether branches/tags tabs are disabled', () => { + const { container } = render(branchTagSelectionMenuDisabled); + + const branchTab = screen + .getByTestId('BranchTagSelectionMenuBranchTab') + .closest('li'); + const tagTab = screen + .getByTestId('BranchTagSelectionMenuTagTab') + .closest('li'); + const manualTab = screen + .getByTestId('BranchTagSelectionMenuManualTab') + .closest('li'); + + expect(container).toBeInTheDocument(); + + expect(branchTab).toHaveClass('pf-m-disabled'); + expect(tagTab).toHaveClass('pf-m-disabled'); + expect(manualTab).not.toHaveClass('pf-m-disabled'); + }); + + it('test whether tab selection works', () => { + const { container } = render(branchTagSelectionMenuEnabled); + + // [bts_button ^ tab_list_item] ^ tab_list + const tabs = screen + .getByTestId('BranchTagSelectionMenuBranchTab') + .closest('li') + .closest('ul').children; + + expect(container).toBeInTheDocument(); + + for (let i = 0; i < tabs.length; i++) { + const currentTab = tabs[i]; + const nestedButton = currentTab.querySelector('button'); + + fireEvent.click(nestedButton); + + expect(currentTab).toHaveClass('pf-m-current'); + } + }); +}); diff --git a/webpack/components/VcsCloneModalContent/components/__test__/GitLinkInputComponent.test.js b/webpack/components/VcsCloneModalContent/components/__test__/GitLinkInputComponent.test.js new file mode 100644 index 000000000..162331c78 --- /dev/null +++ b/webpack/components/VcsCloneModalContent/components/__test__/GitLinkInputComponent.test.js @@ -0,0 +1,139 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { GitLinkInputComponent } from '../GitLinkInputComponent'; + +describe('GitLinkInputComponent', () => { + it('tests the default component', () => { + const { container } = render( + + ); + + const textInput = screen.getByTestId('GitLinkInputComponentTextInput'); + const examineButton = screen.getByText('Examine'); + + expect(container).toBeInTheDocument(); + expect(textInput).toHaveValue(''); + + expect(examineButton).toBeDisabled(); + }); + + it('tests an accepted input value', () => { + const { container } = render( + + ); + const textInput = screen.getByTestId('GitLinkInputComponentTextInput'); + const examineButton = screen.getByText('Examine'); + + fireEvent.change(textInput, { + target: { value: 'https://www.github.com/theforeman/theforeman.git' }, + }); + + expect(container).toBeInTheDocument(); + expect(textInput).toHaveValue( + 'https://www.github.com/theforeman/theforeman.git' + ); + expect(textInput).toHaveClass('pf-m-success'); + + expect(examineButton).toBeEnabled(); + }); + + it('tests an invalid input value', () => { + const { container } = render( + + ); + const textInput = screen.getByTestId('GitLinkInputComponentTextInput'); + const examineButton = screen.getByText('Examine'); + + fireEvent.change(textInput, { + target: { value: 'https://www.github.com/theforeman/theforeman' }, + }); + + expect(container).toBeInTheDocument(); + expect(textInput).toHaveValue( + 'https://www.github.com/theforeman/theforeman' + ); + expect(textInput).toHaveAttribute('aria-invalid', 'true'); + + expect(examineButton).toBeDisabled(); + }); + + it('tests whether the alert is visible', () => { + const { container } = render( + + ); + + const alert = screen.getByTestId('GitLinkInputComponentAlert'); + expect(container).toBeInTheDocument(); + expect(alert).toBeVisible(); + }); + + it('tests whether the alert is invisible', () => { + const { container } = render( + + ); + + const alert = screen.getByTestId('GitLinkInputComponentAlert'); + expect(container).toBeInTheDocument(); + expect(alert).not.toBeVisible(); + }); + + it('tests whether the examine button is loading', () => { + const { container } = render( + + ); + + const examineButton = screen.getByText('Examine'); + + expect(container).toBeInTheDocument(); + + expect(examineButton).toHaveClass('pf-m-in-progress'); + }); + + it('tests whether the examine button is not loading', () => { + const { container } = render( + + ); + + const examineButton = screen.getByText('Examine'); + + expect(container).toBeInTheDocument(); + + expect(examineButton).not.toHaveClass('pf-m-in-progress'); + }); +}); diff --git a/webpack/components/VcsCloneModalContent/components/__test__/MultiSelectorMenu.test.js b/webpack/components/VcsCloneModalContent/components/__test__/MultiSelectorMenu.test.js new file mode 100644 index 000000000..06deaa8dc --- /dev/null +++ b/webpack/components/VcsCloneModalContent/components/__test__/MultiSelectorMenu.test.js @@ -0,0 +1,99 @@ +import React from 'react'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MultiSelectorMenu } from '../MultiSelectorMenu'; + +const repoInfo = { + head: { + ref: 'HEAD', + sha: '4d4f55f7988728c46f47022e2e354405ba41ff83', + }, + branches: { + '98-verify-checksums': { + ref: 'refs', + sha: 'de1378b11a514fd21e4b1ca6528d97937bfbe911', + }, + master: { + ref: 'refs', + sha: '4d4f55f7988728c46f47022e2e354405ba41ff83', + }, + }, + tags: { + '1.0.0': { + ref: 'refs', + sha: 'e4c795e6d037ebc590224243d8cec54423f015cd', + }, + '1.0.0^{}': { + ref: 'refs', + sha: '9408a6ce1f718c3f0c459887b7bc5bc9c2fc3829', + }, + '1.0.1': { + ref: 'refs', + sha: '03ce696243a45742da4b72259ad1faf7a6ce8a80', + }, + }, + vcs_url: 'https://github.com/DavidWittman/ansible-redis.git', +}; + +describe('MultiSelectorMenu', () => { + it('tests the default component', () => { + const { container } = render( + + ); + + expect(container).toBeInTheDocument(); + }); + + it('tests the adding of items', () => { + const { container } = render( + + ); + + const menuContent = screen.getByTestId('MultiSelectorMenuMenuContent'); + + expect(container).toBeInTheDocument(); + expect(menuContent.children).toHaveLength(2); + }); + + it('tests the selection of items', () => { + // eslint-disable-next-line no-unused-vars + for (const toTest of ['branches', 'tags']) { + const setState = jest.fn(); + + const { container } = render( + + ); + + const menuContent = screen.getByTestId('MultiSelectorMenuMenuContent'); + const menuItems = menuContent.children; + + const items = Object.keys(repoInfo[toTest]); + + expect(container).toBeInTheDocument(); + for (let i = 0; i < menuItems.length; i++) { + const item = menuItems[i]; + const button = item.querySelector('button'); + + fireEvent.click(button); + expect(setState).toBeCalledWith(items[i]); + } + expect(setState).toBeCalledTimes(items.length); + cleanup(); + } + }); +}); diff --git a/webpack/index.js b/webpack/index.js index 760aeda43..1f7b3d932 100644 --- a/webpack/index.js +++ b/webpack/index.js @@ -4,6 +4,7 @@ import ReportJsonViewer from './components/ReportJsonViewer'; import AnsibleRolesSwitcher from './components/AnsibleRolesSwitcher'; import WrappedImportRolesAndVariables from './components/AnsibleRolesAndVariables'; import reducer from './reducer'; +import { VcsCloneModalContent } from './components/VcsCloneModalContent/VcsCloneModalContent'; componentRegistry.register({ name: 'ReportJsonViewer', @@ -19,4 +20,9 @@ componentRegistry.register({ type: WrappedImportRolesAndVariables, }); +componentRegistry.register({ + name: 'VcsCloneModalContent', + type: VcsCloneModalContent, +}); + injectReducer('foremanAnsible', reducer);