Skip to content

Commit

Permalink
Fixes #37327 - GUI/API to allow importing/overriding of Ansible varia…
Browse files Browse the repository at this point in the history
…bles directly from YAML/JSON
  • Loading branch information
Thorben-D committed Apr 5, 2024
1 parent 00f9d79 commit 5e7f409
Show file tree
Hide file tree
Showing 37 changed files with 2,329 additions and 54 deletions.
101 changes: 100 additions & 1 deletion app/controllers/api/v2/ansible_variables_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ class AnsibleVariablesController < ::Api::V2::BaseController
api_base_url '/ansible/api'
end

skip_before_action :verify_authenticity_token
before_action :find_resource, :only => [:show, :destroy, :update]
before_action :find_proxy, :only => [:import, :obsolete]
before_action :create_importer, :only => [:import, :obsolete]
before_action :create_importer, :only => [:import, :obsolete, :from_json, :from_yaml, :yaml_to_json]

api :GET, '/ansible_variables/:id', N_('Show variable')
param :id, :identifier, :required => true
Expand Down Expand Up @@ -90,8 +91,106 @@ def obsolete
@obsoleted = old_variables
end

api :POST, '/ansible_variables/import/yaml_to_json',
N_('Converts the YAML-file into a JSON-representation the UI can work with')
param :data, Integer, N_('YAML-file as a base64-encoded string')
def yaml_to_json
enc_file = params.require(:data)
render json: encoded_yaml_to_hash(enc_file)
end

api :PUT, '/ansible_variables/import/from_yaml', N_('Import Ansible variables directly from YAML files')
param :data, Hash, N_('Dictionary of role names and base64-encoded YAML files')
# param structure:
# {
# "data": {
# <role_name>: {
# "data": <base64-enc YAML-file>
# },
# <role_name>: {
# "data": <base64-enc YAML-file>
# }
# ...
# }
# }
# How to document this using apipie?
def from_yaml
level_zero_data = params.require(:data)
temp_hash = {}
level_zero_data.each do |role_name, data|
enc_file = data.require(:data)
yaml_hash = encoded_yaml_to_hash(enc_file)
temp_hash[role_name] = {} unless temp_hash[role_name]
temp_hash[role_name] = yaml_hash.merge(temp_hash[role_name])
end
from_hash(temp_hash)
end

api :PUT, '/ansible_variables/import/from_json', N_('Import Ansible variables from JSON. Allows finer control over names, types and default values.')
param :data, Hash, N_('Dictionary of role names and dictionaries of variable names and a hash containing variable value and type.')
# param structure:
# {
# "data": {
# <role_name>: {
# <var_name>: {
# "value": <var_value>,
# "type": <var_type>
# },
# ...
# },
# <role_name>: {
# <var_name>: {
# "value": <var_value>,
# "type": <var_type>
# },
# ...
# },
# ...
# }
# }
# How to document this using apipie?
def from_json
data = params.require(:data).permit!.to_h
from_hash(data)
end

private

def from_hash(data)
data.each do |role_name, variables|
variables.each do |variable_name, variable_data|
role = find_role(role_name)
return role if performed?
existing_variable = AnsibleRole.find_by(name: role_name).ansible_variables.find_by(key: variable_name)
if existing_variable.nil?
if !variable_data.key?(:type) || variable_data["type"] == "auto"
@importer.create_base_variable(variable_name, role, variable_data["value"], @importer.infer_key_type(variable_data["value"])).save
else
@importer.create_base_variable(variable_name, role, variable_data["value"], variable_data["type"]).save
end
else
existing_variable.update({ :override => true, :key_type => variable_data["type"], :default_value => variable_data["value"] })
end
end
end
end

def encoded_yaml_to_hash(encoded_string)
loaded_hash = YAML.safe_load(Base64.decode64(encoded_string))
loaded_hash.each do |variable_name, variable_value|
loaded_hash[variable_name] = {
:value => variable_value,
:type => @importer.infer_key_type(variable_value)
}
end
loaded_hash
end

def find_role(role_name)
role = AnsibleRole.find_by(name: role_name)
!role.nil? ? role : render_error('custom_error', :status => :unprocessable_entity, :locals => { :message => _("#{role_name} does not exist") })
end

def find_proxy
return nil unless params[:proxy_id]
@proxy = SmartProxy.
Expand Down
12 changes: 12 additions & 0 deletions app/helpers/foreman_ansible/ansible_variables_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true

module ForemanAnsible
module AnsibleVariablesHelper
def yaml_import
return '' unless authorized_for({ :controller => :ansible_variables, :action => :new })
select_action_button('',
{ :primary => true, :class => 'yaml-import' },
link_to(_('Import from YAML-File'), '#yaml_import'))
end
end
end
29 changes: 17 additions & 12 deletions app/services/foreman_ansible/variables_importer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,18 +77,22 @@ def import_new_role(role_name, new_roles)

def initialize_variables(variables, role)
variables.map do |variable_name, variable_default|
variable = AnsibleVariable.find_or_initialize_by(
:key => variable_name,
:ansible_role_id => role.id
)
variable.assign_attributes(:hidden_value => false,
:default_value => variable_default,
:key_type => infer_key_type(variable_default))
variable.imported = true if variable.new_record?
variable.valid? ? variable : nil
create_base_variable(variable_name, role, variable_default)
end
end

def create_base_variable(variable_name, role, variable_default, variable_type = nil)
variable = AnsibleVariable.find_or_initialize_by(
:key => variable_name,
:ansible_role_id => role.id
)
variable.assign_attributes(:hidden_value => false,
:default_value => variable_default,
:key_type => variable_type || infer_key_type(variable_default))
variable.imported = true if variable.new_record?
variable.valid? ? variable : nil
end

def detect_changes(imported)
changes = {}.with_indifferent_access
persisted, changes[:new] = imported.partition { |var| var.id.present? }
Expand Down Expand Up @@ -140,6 +144,10 @@ def delete_old_variables(variables)
end
end

def infer_key_type(value)
VARIABLE_TYPES[value.class.to_s] || 'string'
end

private

def local_variables
Expand All @@ -150,9 +158,6 @@ def remote_variables
proxy_api.all_variables
end

def infer_key_type(value)
VARIABLE_TYPES[value.class.to_s] || 'string'
end

def iterate_over_variables(variables)
variables.reduce([]) do |memo, (role, vars)|
Expand Down
54 changes: 14 additions & 40 deletions app/views/ansible_roles/index.html.erb
Original file line number Diff line number Diff line change
@@ -1,47 +1,21 @@
<%= webpacked_plugins_js_for :foreman_ansible %>
<%= webpacked_plugins_css_for :foreman_ansible %>

<% title _("Ansible Roles") %>

<% title_actions ansible_proxy_import(hash_for_import_ansible_roles_path),
documentation_button('#4.1ImportingRoles', :root_url => ansible_doc_url) %>

<table class="<%= table_css_classes 'table-fixed' %>">
<thead>
<tr>
<th class="col-md-6"><%= sort :name, :as => s_("Role|Name") %></th>
<th class="col-md-2"><%= _("Hostgroups") %></th>
<th class="col-md-2"><%= _("Hosts") %></th>
<th class="col-md-2"><%= _("Variables") %></th>
<th class="col-md-2"><%= sort :updated_at, :as => _("Imported at") %></th>
<th class="col-md-2"><%= _("Actions") %></th>
</tr>
</thead>
<tbody>
<% @ansible_roles.each do |role| %>
<tr>
<td class="ellipsis"><%= role.name %></td>
<td class="ellipsis"><%= link_to role.hostgroups.count, hostgroups_path(:search => "ansible_role = #{role.name}") %></td>
<td class="ellipsis"><%= link_to role.hosts.count, hosts_path(:search => "ansible_role = #{role.name}")%></td>
<td class="ellipsis"><%= link_to(role.ansible_variables.count, ansible_variables_path(:search => "ansible_role = #{role}")) %></td>
<td class="ellipsis"><%= import_time role %></td>
<td>
<%
links = [
link_to(
_('Variables'),
ansible_variables_path(:search => "ansible_role = #{role}")
),
display_delete_if_authorized(
hash_for_ansible_role_path(:id => role).
merge(:auth_object => role, :authorizer => authorizer),
:data => { :confirm => _("Delete %s?") % role.name },
:action => :delete
)
]
%>
<%= action_buttons(*links) %>
</td>
</tr>
<% end %>
</tbody>
</table>
<%= react_component('AnsibleRolesTable', {:ansibleRoles => @ansible_roles.map { |role| {
:name => role.name,
:id => role.id,
:hostgroupsCount => role.hostgroups.count,
:hostsCount => role.hosts.count,
:variablesCount => role.ansible_variables.count,
:importTime => import_time(role),
:updatedAt => role.updated_at
} }})%>

<%= react_component('YamlVariablesImporterWrapper')%>

<%= will_paginate_with_info @ansible_roles %>
6 changes: 6 additions & 0 deletions app/views/ansible_variables/index.html.erb
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
<%= webpacked_plugins_js_for :foreman_ansible %>
<%= webpacked_plugins_css_for :foreman_ansible %>

<% title _("Ansible Variables") %>
<%= stylesheet 'foreman_ansible/foreman-ansible' %>

<%= title_actions display_link_if_authorized(_('New Ansible Variable'), hash_for_new_ansible_variable_path, :class => "btn btn-default no-float"),
yaml_import,
documentation_button('#4.3Variables', :root_url => ansible_doc_url)
%>

Expand Down Expand Up @@ -47,4 +51,6 @@
</tbody>
</table>

<%= react_component('YamlVariablesImporterWrapper')%>

<%= will_paginate_with_info @ansible_variables %>
5 changes: 5 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@
collection do
put :import
put :obsolete
scope '/import' do
put 'from_yaml', to: 'ansible_variables#from_yaml'
put 'from_json', to: 'ansible_variables#from_json'
post 'yaml_to_json', to: 'ansible_variables#yaml_to_json'
end
end
end

Expand Down
3 changes: 2 additions & 1 deletion lib/foreman_ansible/register.rb
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,8 @@
permission :import_ansible_variables,
{
:ansible_variables => [:import, :confirm_import],
:'api/v2/ansible_variables' => [:import]
:'api/v2/ansible_variables' => [:import],
:'api/v2/ansible_variables/import' => [:yaml_to_json, :from_yaml, :from_json]
},
:resource_type => 'AnsibleVariable'
permission :view_hosts,
Expand Down
39 changes: 39 additions & 0 deletions test/functional/api/v2/ansible_variables_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,45 @@ class AnsibleVariablesControllerTest < ActionController::TestCase
res = JSON.parse(@response.body)
assert_equal new_value, res['default_value']
end

test 'should convert yaml to json' do
test_yaml = "---\nvar_0: \"test_string\"\n"

testyaml64 = Base64.encode64 test_yaml
put :yaml_to_json, :params => { :data => testyaml64 }, :session => set_session_user
assert_response :success
assert_equal({ 'var_0' => { 'value' => 'test_string', 'type' => 'string' } }, JSON.parse(@response.body))
end

test 'should import from json' do
put :from_json, :params => {
"data": {
"#{FactoryBot.create(:ansible_role).name}": {
"some_variable": {
"value": 'test_value',
"type": 'string'
}
}
}
}, :session => set_session_user
assert_response :success
end

test 'should reject if role does not exist' do
put :from_json, :params => {
"data": {
"non_existent_role": {
"some_variable": {
"value": 'test_value',
"type": 'string'
}
}
}
}, :session => set_session_user
assert_response 422
assert_equal({ 'error' => { 'message' => 'non_existent_role does not exist' } },
JSON.parse(@response.body))
end
end
end
end
64 changes: 64 additions & 0 deletions webpack/components/AnsibleRoles/AnsibleRolesTable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React from 'react';
import PropTypes from 'prop-types';
import { TableComposable, Thead, Tr, Th, Tbody } from '@patternfly/react-table';
import { translate as __ } from 'foremanReact/common/I18n';
import { AnsibleRolesTableRow } from './components/AnsibleRolesTableRow';

export const AnsibleRolesTable = props => {
const searchParams = new URLSearchParams(window.location.search);
const sortString = searchParams.get('order');

let sortIndex = null;
let sortDirection = null;
if (sortString) {
const sortStrings = sortString.split(' ');
sortIndex = sortStrings[0] === 'name' ? 0 : 6;
// eslint-disable-next-line prefer-destructuring
sortDirection = sortStrings[1];
}

const getSortParams = columnIndex => ({
sortBy: {
index: sortIndex,
direction: sortDirection,
defaultDirection: 'asc',
},
onSort: (_event, index, direction) => {
if (direction !== null && index !== null) {
searchParams.set(
'order',
`${index === 0 ? 'name' : 'updated_at'} ${direction}`
);
window.location.search = searchParams.toString();
}
},
columnIndex,
});
return (
<TableComposable variant="compact" borders="compactBorderless">
<Thead>
<Tr>
<Th sort={getSortParams(0)}>{__('Name')}</Th>
<Th>{__('Hostgroups')}</Th>
<Th>{__('Hosts')}</Th>
<Th>{__('Variables')}</Th>
<Th sort={getSortParams(6)}>{__('Imported at')}</Th>
<Th>{__('Actions')}</Th>
</Tr>
</Thead>
<Tbody>
{props.ansibleRoles.map(role => (
<AnsibleRolesTableRow key={role.name} ansibleRole={role} />
))}
</Tbody>
</TableComposable>
);
};

AnsibleRolesTable.propTypes = {
ansibleRoles: PropTypes.array,
};

AnsibleRolesTable.defaultProps = {
ansibleRoles: [],
};
Loading

0 comments on commit 5e7f409

Please sign in to comment.