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..df86d25b5 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 %s already exists.') % repo_info['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 %s. 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 %s. 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..c193a2035 --- /dev/null +++ b/webpack/components/VcsCloneModalContent/VcsCloneModalContent.js @@ -0,0 +1,286 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Modal, + ModalVariant, + Button, + Grid, + GridItem, + Form, + FormGroup, + TextInput, + Text, + TextVariants, + Switch, + Alert, +} from '@patternfly/react-core'; +import { translate as __, sprintf } from 'foremanReact/common/I18n'; +import { GitLinkInputComponent } from './components/GitLinkInputComponent'; +import { BranchTagSelectionMenu } from './components/BranchTagSelectionMenu'; +import { SmartProxySelector } from './components/SmartProxySelector'; +import { ModalConfirmButton } from './components/ModalConfirmButton'; +import { fetchSmartProxies } from './VcsCloneModalContentHelpers'; + +export const VcsCloneModalContent = () => { + // STATE DEFINITION + + const [gitRef, setGitRef] = useState('master'); + const [installedRoles, setInstalledRoles] = useState({}); + const [repoName, setRepoName] = useState(''); + const [isModalButtonLoading, setIsModalButtonLoading] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + const [repoInfo, setRepoInfo] = useState({ + branches: {}, + tags: {}, + vcs_url: null, + }); + const [branchTagsEnabled, setBranchTagsEnabled] = useState(false); + const [alertText, setAlertText] = useState(''); + const [finalProcedure, setFinalProcedure] = useState(''); + const [originalRepoName, setOriginalRepoName] = useState(''); + const [smartProxies, setSmartProxies] = useState({}); + const [smartProxySelection, setSmartProxySelection] = useState([]); + const [updateExisting, setUpdateExisting] = useState(false); + const [isErrorState, setIsErrorState] = useState(false); + const [isModalButtonActive, setIsModalButtonActive] = 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(); + } + }; + }, [handleModalToggle]); + + /** + * Fetch SmartProxies when the modal is opened. + */ + useEffect(() => { + async function request() { + if (isModalOpen) { + const smartProxiesRequest = await fetchSmartProxies(); + if ( + Object.keys(smartProxiesRequest.proxies).length === 0 || + !smartProxiesRequest.ok + ) { + setAlertText( + __('No smartproxies with support for cloning from VCS found') + ); + setIsErrorState(true); + } else { + setSmartProxies(smartProxiesRequest.proxies); + } + } + } + + // eslint-disable-next-line no-unused-vars + const ignored = request(); + }, [isModalOpen]); + + /** + * 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: gitRef, + oName: originalRepoName, + sps: smartProxySelection, + rName: repoName, + update: updateExisting + ? __('updating existing') + : __('skipping existing'), + } + ) + ); + }, [gitRef, originalRepoName, smartProxySelection, repoName, updateExisting]); + + useEffect(() => { + setIsModalButtonActive( + !isErrorState && + repoName !== '' && + smartProxySelection.length !== 0 && + gitRef !== '' + ); + }, [isErrorState, repoName, gitRef, smartProxySelection.length]); + + /** + * Method to check whether a role is already present on the selected SmartProxy. + * Called when one of the deps is updated + * Checks whether 'Repo name' is a role that is already present on the selected SmartProxy. + * -> Shows the alert if a collision is present. + */ + useEffect(() => { + setIsErrorState(false); + if (smartProxySelection.length !== 0) { + // eslint-disable-next-line no-unused-vars + for (const [_, proxyId] of Object.entries(smartProxies)) { + const roles = installedRoles[proxyId]; + + 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: Object.keys(smartProxies).filter( + key => smartProxies[key] === proxyId + ), + } + ) + ); + setIsErrorState(true); + } + } + } + } + }, [ + smartProxySelection, + repoName, + smartProxies, + installedRoles, + 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) { + setGitRef('master'); + setInstalledRoles({}); + setRepoName(''); + setIsModalButtonLoading(false); + setIsModalButtonActive(false); + setIsModalOpen(false); + setRepoInfo({ + branches: {}, + tags: {}, + vcs_url: null, + }); + setIsErrorState(false); + setAlertText(''); + setFinalProcedure(''); + setOriginalRepoName(''); + setSmartProxies({}); + setSmartProxySelection([]); + setUpdateExisting(false); + setBranchTagsEnabled(false); + window.location.hash = ''; + } + setIsModalOpen(!isModalOpen); + }, [isModalOpen]); + + return ( + + , + , + ]} + > + + + + + + + + + + + + +
+ + setRepoName(value)} + /> + +
+
+ + setUpdateExisting(value)} + /> + + +
+ + + {finalProcedure} + + +
+
+
+
+
+ ); +}; diff --git a/webpack/components/VcsCloneModalContent/VcsCloneModalContentHelpers.js b/webpack/components/VcsCloneModalContent/VcsCloneModalContentHelpers.js new file mode 100644 index 000000000..ebdf435df --- /dev/null +++ b/webpack/components/VcsCloneModalContent/VcsCloneModalContentHelpers.js @@ -0,0 +1,115 @@ +import React from 'react'; +import { translate as __, sprintf } from 'foremanReact/common/I18n'; +import { showToast } from '../../toastHelper'; +import { foremanUrl } from '../AnsibleRolesAndVariables/AnsibleRolesAndVariablesActions'; + +export const fetchSmartProxies = async () => { + 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; + } + } + }) + ); + return { + ok: response.ok, + proxies: tempSmartProxies, + }; +}; + +export const getRepoInfo = async (smartProxyId, repoUrl) => { + const response = await fetch( + `/api/v2/smart_proxies/${smartProxyId}/ansible/repo_information?${new URLSearchParams( + { + vcs_url: repoUrl, + } + )}`, + { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + } + ); + + return { + ok: response.ok, + result: await response.json(), + }; +}; + +export const installRole = async ( + updateExisting, + smartProxyId, + repoUrl, + repoName, + repoRef +) => { + const response = await fetch( + updateExisting + ? `/api/v2/smart_proxies/${smartProxyId}/ansible/roles/${repoName}` + : `/api/v2/smart_proxies/${smartProxyId}/ansible/roles`, + { + method: updateExisting ? 'PUT' : 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + repo_info: { + vcs_url: repoUrl, + name: repoName, + ref: repoRef, + }, + }), + } + ); + + return { + ok: response.ok, + status: response.status, + result: await response.json(), + }; +}; + +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..8fa91e532 --- /dev/null +++ b/webpack/components/VcsCloneModalContent/components/BranchTagSelectionMenu.js @@ -0,0 +1,91 @@ +import React, { useEffect, useState } from 'react'; +import { + Tabs, + Tab, + TabTitleText, + Form, + FormGroup, + TextInput, +} from '@patternfly/react-core'; +import PropTypes from 'prop-types'; +import { translate as __ } from 'foremanReact/common/I18n'; +import { MultiSelectorMenu } from './MultiSelectorMenu'; + +export const BranchTagSelectionMenu = props => { + const [activeTabKey, setActiveTabKey] = useState(0); + + useEffect(() => { + if (!props.branchTagsEnabled) { + setActiveTabKey(0); + } + }, [props.branchTagsEnabled]); + + return ( + setActiveTabKey(tabIndex)} + isBox + > + {__('Manual input')}} + > +
+ + props.setGitRef(value)} + /> + +
+
+ {__('Branches')}} + isDisabled={!props.branchTagsEnabled} + > + + + {__('Tags')}} + isDisabled={!props.branchTagsEnabled} + > + + +
+ ); +}; + +BranchTagSelectionMenu.propTypes = { + repoInfo: PropTypes.object, + gitRef: PropTypes.string, + setGitRef: PropTypes.func, + branchTagsEnabled: PropTypes.bool, +}; + +BranchTagSelectionMenu.defaultProps = { + repoInfo: { + branches: {}, + tags: {}, + vcs_url: null, + }, + gitRef: 'master', + setGitRef: () => {}, + branchTagsEnabled: false, +}; diff --git a/webpack/components/VcsCloneModalContent/components/GitLinkInputComponent.js b/webpack/components/VcsCloneModalContent/components/GitLinkInputComponent.js new file mode 100644 index 000000000..53acc41e1 --- /dev/null +++ b/webpack/components/VcsCloneModalContent/components/GitLinkInputComponent.js @@ -0,0 +1,145 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + Button, + InputGroup, + TextInput, + Form, + FormGroup, +} from '@patternfly/react-core'; +import PropTypes from 'prop-types'; +import { translate as __ } from 'foremanReact/common/I18n'; +import { getRepoInfo } from '../VcsCloneModalContentHelpers'; + +export const GitLinkInputComponent = props => { + const [isButtonActive, setButtonActive] = useState(false); + const [textInput, setTextInput] = useState(''); + const [validated, setValidated] = useState('default'); + const [invalidText, setInvalidText] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const primaryLoadingProps = {}; + primaryLoadingProps.spinnerAriaValueText = 'Loading'; + primaryLoadingProps.spinnerAriaLabelledBy = 'primary-loading-button'; + primaryLoadingProps.isLoading = isLoading; + + /** + * 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 repoUrl => { + const roleNameExpr = new RegExp(`^.*/(.*/.*).git$`); + const matched = roleNameExpr.exec(repoUrl); + try { + props.setRepoName(matched[1].replace('/', '_').toLowerCase()); + props.setOriginalRepoName(matched[1]); + } catch (e) { + props.setRepoName(__('COULD NOT EXTRACT NAME')); + } + + setIsLoading(true); + + const repoInfoRequest = await getRepoInfo( + props.smartProxies[props.smartProxySelection], + repoUrl + ); + + if (!repoInfoRequest.ok) { + setIsLoading(false); + props.setAlertText(__('Could not request metadata. Use manual input.')); + props.setIsErrorState(true); + props.setBranchTagsEnabled(false); + } else { + props.setRepoInfo(repoInfoRequest.result); + setIsLoading(false); + props.setBranchTagsEnabled(true); + } + }, + [props] + ); + const handleTextInput = (gitLink, event) => { + setTextInput(gitLink); + props.setRepoInfo({ + branches: {}, + tags: {}, + vcs_url: gitLink, + }); + props.setBranchTagsEnabled(false); + }; + + useEffect(() => { + const validLink = /^.*\.git$/.test(textInput); + + if (validLink && props.smartProxySelection.length !== 0) { + setValidated('success'); + setButtonActive(true); + } else { + if (!validLink) { + setValidated('error'); + setInvalidText(__('Not a valid Git-Url')); + } else { + setInvalidText(__('SmartProxy required')); + } + setButtonActive(false); + } + }, [textInput, props.smartProxySelection]); + + return ( + +
+ + + + + {' '} + + + +
+
+ ); +}; + +GitLinkInputComponent.propTypes = { + setRepoName: PropTypes.func, + setOriginalRepoName: PropTypes.func, + smartProxies: PropTypes.object, + smartProxySelection: PropTypes.array, + setAlertText: PropTypes.func, + setIsErrorState: PropTypes.func, + setRepoInfo: PropTypes.func, + repoInfo: PropTypes.object, + setBranchTagsEnabled: PropTypes.func, +}; + +GitLinkInputComponent.defaultProps = { + setRepoName: () => {}, + setOriginalRepoName: () => {}, + smartProxies: {}, + smartProxySelection: [], + setAlertText: () => {}, + setIsErrorState: () => {}, + setRepoInfo: () => {}, + repoInfo: {}, + setBranchTagsEnabled: () => {}, +}; diff --git a/webpack/components/VcsCloneModalContent/components/ModalConfirmButton.js b/webpack/components/VcsCloneModalContent/components/ModalConfirmButton.js new file mode 100644 index 000000000..f30a42d21 --- /dev/null +++ b/webpack/components/VcsCloneModalContent/components/ModalConfirmButton.js @@ -0,0 +1,75 @@ +import React, { useCallback } from 'react'; +import PropTypes from 'prop-types'; +import { translate as __ } from 'foremanReact/common/I18n'; +import { Button } from '@patternfly/react-core'; +import { + installRole, + showErrorToast, + showSuccessToast, +} from '../VcsCloneModalContentHelpers'; + +export const ModalConfirmButton = props => { + /** + * 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 () => { + props.setIsModalButtonLoading(true); + + const installRequest = await installRole( + props.updateExisting, + props.smartProxyId, + props.repoInfo.vcs_url, + props.repoName, + props.gitRef + ); + + if (!installRequest.ok) { + showErrorToast(installRequest.status); + } else { + showSuccessToast(installRequest.result.task.id, props.originalRepoName); + } + props.setIsModalButtonLoading(false); + }, [props]); + + return ( + + ); +}; + +ModalConfirmButton.propTypes = { + setIsModalButtonLoading: PropTypes.func, + updateExisting: PropTypes.bool, + smartProxyId: PropTypes.number, + repoInfo: PropTypes.object, + repoName: PropTypes.string, + gitRef: PropTypes.string, + originalRepoName: PropTypes.string, + isModalButtonLoading: PropTypes.bool, + isModalButtonActive: PropTypes.bool, +}; + +ModalConfirmButton.defaultProps = { + setIsModalButtonLoading: () => {}, + updateExisting: false, + smartProxyId: 0, + repoInfo: { + branches: {}, + tags: {}, + vcs_url: null, + }, + repoName: '', + gitRef: 'master', + originalRepoName: '', + isModalButtonLoading: false, + isModalButtonActive: false, +}; diff --git a/webpack/components/VcsCloneModalContent/components/MultiSelectorMenu.js b/webpack/components/VcsCloneModalContent/components/MultiSelectorMenu.js new file mode 100644 index 000000000..d1a004a5a --- /dev/null +++ b/webpack/components/VcsCloneModalContent/components/MultiSelectorMenu.js @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Menu, MenuContent, MenuList, MenuItem } from '@patternfly/react-core'; + +export const MultiSelectorMenu = props => { + const createSelectItems = () => { + const items = []; + const inputItems = props.repoInfo?.[props.displayData]; + if (inputItems !== undefined) { + // eslint-disable-next-line no-unused-vars + for (const item of Object.keys(inputItems)) { + items.push( + + {item} + + ); + } + } + return items; + }; + + return ( + props.setGitRef(item)} + selected={props.gitRef} + isScrollable + > + + + {createSelectItems()} + + + + ); +}; + +MultiSelectorMenu.propTypes = { + repoInfo: PropTypes.object, + displayData: PropTypes.string, + setGitRef: PropTypes.func, + gitRef: PropTypes.string, +}; + +MultiSelectorMenu.defaultProps = { + repoInfo: {}, + displayData: 'branches', + setGitRef: () => {}, + gitRef: '', +}; diff --git a/webpack/components/VcsCloneModalContent/components/SmartProxySelector.js b/webpack/components/VcsCloneModalContent/components/SmartProxySelector.js new file mode 100644 index 000000000..c2a846fbf --- /dev/null +++ b/webpack/components/VcsCloneModalContent/components/SmartProxySelector.js @@ -0,0 +1,115 @@ +import React, { useState, useCallback } from 'react'; +import PropTypes from 'prop-types'; +import { translate as __ } from 'foremanReact/common/I18n'; +import { + Form, + FormGroup, + Select, + SelectVariant, + SelectOption, +} from '@patternfly/react-core'; + +export const SmartProxySelector = props => { + const [isSmartProxyDropdownOpen, setIsSmartProxyDropdownOpen] = useState( + false + ); + + /** + * 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) => { + /** + * 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 proxyId SmartProxy from which the roles should be queried. + */ + const getInstalledRolesAtProxy = async proxyId => { + const response = await fetch( + `/api/v2/smart_proxies/${proxyId}/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 = props.installedRoles; + temp[proxyId] = installedRolesMap; + props.setInstalledRoles(temp); + } + }; + + let updatedSmartProxySelection; + if (props.smartProxySelection.includes(value)) { + updatedSmartProxySelection = []; + } else { + updatedSmartProxySelection = [value]; + await getInstalledRolesAtProxy(props.smartProxies[value]); + } + + props.setSmartProxySelection(updatedSmartProxySelection); + }, + [props] + ); + + /** + * 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(props.smartProxies)) { + smartProxyArray.push(); + } + return smartProxyArray; + } + return ( +
+ + + +
+ ); +}; + +SmartProxySelector.propTypes = { + smartProxies: PropTypes.object, + smartProxySelection: PropTypes.array, + setSmartProxySelection: PropTypes.func, + installedRoles: PropTypes.object, + setInstalledRoles: PropTypes.func, +}; + +SmartProxySelector.defaultProps = { + smartProxies: {}, + smartProxySelection: [], + setSmartProxySelection: () => {}, + installedRoles: {}, + setInstalledRoles: () => {}, +}; 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..a27645e62 --- /dev/null +++ b/webpack/components/VcsCloneModalContent/components/__test__/BranchTagSelectionMenu.test.js @@ -0,0 +1,72 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { BranchTagSelectionMenu } from '../BranchTagSelectionMenu'; + +describe('BranchTagSelectionMenu', () => { + it('tests the default component', () => { + const { container } = render(); + + expect(container).toBeInTheDocument(); + }); + + it('test whether branches/tags tabs are enabled', () => { + const { container } = render(); + 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(); + + 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(); + + // [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..d43691bbc --- /dev/null +++ b/webpack/components/VcsCloneModalContent/components/__test__/GitLinkInputComponent.test.js @@ -0,0 +1,58 @@ +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://github.com/theforeman/foreman_ansible.git' }, + }); + + expect(container).toBeInTheDocument(); + expect(textInput).toHaveValue( + 'https://github.com/theforeman/foreman_ansible.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://github.com/theforeman/foreman_ansible' }, + }); + + expect(container).toBeInTheDocument(); + expect(textInput).toHaveValue( + 'https://github.com/theforeman/foreman_ansible' + ); + expect(textInput).toHaveAttribute('aria-invalid', 'true'); + + expect(examineButton).toBeDisabled(); + }); +}); 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..b4f42c93d --- /dev/null +++ b/webpack/components/VcsCloneModalContent/components/__test__/MultiSelectorMenu.test.js @@ -0,0 +1,84 @@ +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);