foreman_acd 0.7.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +5 -5
- data/app/controllers/foreman_acd/ansible_playbooks_controller.rb +17 -2
- data/app/controllers/foreman_acd/app_definitions_controller.rb +104 -7
- data/app/controllers/foreman_acd/app_instances_controller.rb +15 -30
- data/app/controllers/foreman_acd/concerns/app_instance_mixins.rb +36 -0
- data/app/controllers/ui_acd_controller.rb +42 -3
- data/app/lib/actions/foreman_acd/run_configurator.rb +1 -0
- data/app/models/concerns/foreman_acd/host_managed_extensions.rb +40 -27
- data/app/models/foreman_acd/app_instance.rb +47 -2
- data/app/models/foreman_acd/foreman_host.rb +8 -0
- data/app/services/foreman_acd/app_deployer.rb +19 -2
- data/app/services/foreman_acd/inventory_creator.rb +11 -1
- data/app/views/foreman_acd/app_definitions/_form.html.erb +4 -0
- data/app/views/foreman_acd/app_definitions/import.html.erb +20 -1
- data/app/views/foreman_acd/app_definitions/index.html.erb +3 -6
- data/app/views/foreman_acd/app_instances/_form.html.erb +4 -0
- data/app/views/foreman_acd/app_instances/index.html.erb +15 -11
- data/app/views/foreman_acd/app_instances/report.html.erb +7 -2
- data/app/views/ui_acd/host_report.json.rabl +4 -0
- data/app/views/ui_acd/report_data.json.rabl +10 -0
- data/app/views/ui_acd/validate_hostname.json.rabl +6 -0
- data/config/routes.rb +3 -0
- data/db/migrate/20210818125913_add_is_existing_host_to_foreman_host.rb +8 -0
- data/db/migrate/20210902110645_add_initial_configure_task.rb +8 -0
- data/lib/foreman_acd/plugin.rb +9 -9
- data/lib/foreman_acd/version.rb +1 -1
- data/lib/foreman_acd.rb +27 -9
- data/package.json +8 -25
- data/test/controllers/ui_acd_controller_test.rb +4 -1
- data/webpack/__mocks__/foremanReact/components/ForemanModal/ForemanModalActions.js +2 -0
- data/webpack/__snapshots__/helper.test.js.snap +1 -1
- data/webpack/components/ApplicationDefinition/ApplicationDefinition.js +34 -10
- data/webpack/components/ApplicationDefinition/ApplicationDefinitionActions.js +12 -0
- data/webpack/components/ApplicationDefinition/ApplicationDefinitionConstants.js +1 -0
- data/webpack/components/ApplicationDefinition/ApplicationDefinitionReducer.js +30 -9
- data/webpack/components/ApplicationDefinition/ApplicationDefinitionSelectors.js +4 -0
- data/webpack/components/ApplicationDefinition/__tests__/ApplicationDefinition.test.js +1 -0
- data/webpack/components/ApplicationDefinition/__tests__/ApplicationDefinitionSelectors.test.js +12 -0
- data/webpack/components/ApplicationDefinition/__tests__/__snapshots__/ApplicationDefinition.test.js.snap +31 -5
- data/webpack/components/ApplicationDefinition/__tests__/__snapshots__/ApplicationDefinitionSelectors.test.js.snap +8 -0
- data/webpack/components/ApplicationDefinition/components/AnsiblePlaybookSelector.js +1 -1
- data/webpack/components/ApplicationDefinition/components/__tests__/__snapshots__/AnsiblePlaybookSelector.test.js.snap +3 -3
- data/webpack/components/ApplicationDefinition/index.js +8 -0
- data/webpack/components/ApplicationDefinitionImport/ApplicationDefinitionImport.js +214 -0
- data/webpack/components/ApplicationDefinitionImport/ApplicationDefinitionImport.scss +1 -0
- data/webpack/components/ApplicationDefinitionImport/ApplicationDefinitionImportActions.js +161 -0
- data/webpack/components/ApplicationDefinitionImport/ApplicationDefinitionImportConstants.js +6 -0
- data/webpack/components/ApplicationDefinitionImport/ApplicationDefinitionImportReducer.js +79 -0
- data/webpack/components/ApplicationDefinitionImport/ApplicationDefinitionImportSelectors.js +8 -0
- data/webpack/components/ApplicationDefinitionImport/__fixtures__/applicationDefinitionImportConfData_1.fixtures.js +129 -0
- data/webpack/components/ApplicationDefinitionImport/__fixtures__/applicationDefinitionImportReducer.fixtures.js +29 -0
- data/webpack/components/ApplicationDefinitionImport/__tests__/ApplicationDefinitionImport.test.js +20 -0
- data/webpack/components/ApplicationDefinitionImport/__tests__/ApplicationDefinitionImportReducer.test.js +43 -0
- data/webpack/components/ApplicationDefinitionImport/__tests__/ApplicationDefinitionImportSelectors.test.js +29 -0
- data/webpack/components/ApplicationDefinitionImport/__tests__/__snapshots__/ApplicationDefinitionImport.test.js.snap +62 -0
- data/webpack/components/ApplicationDefinitionImport/__tests__/__snapshots__/ApplicationDefinitionImportReducer.test.js.snap +362 -0
- data/webpack/components/ApplicationDefinitionImport/__tests__/__snapshots__/ApplicationDefinitionImportSelectors.test.js.snap +130 -0
- data/webpack/components/ApplicationDefinitionImport/index.js +32 -0
- data/webpack/components/ApplicationInstance/ApplicationInstance.js +102 -26
- data/webpack/components/ApplicationInstance/ApplicationInstanceActions.js +118 -6
- data/webpack/components/ApplicationInstance/ApplicationInstanceConstants.js +4 -0
- data/webpack/components/ApplicationInstance/ApplicationInstanceHelper.js +15 -0
- data/webpack/components/ApplicationInstance/ApplicationInstanceReducer.js +71 -30
- data/webpack/components/ApplicationInstance/ApplicationInstanceSelectors.js +4 -0
- data/webpack/components/ApplicationInstance/__fixtures__/applicationInstanceReducer.fixtures.js +2 -0
- data/webpack/components/ApplicationInstance/__tests__/ApplicationInstance.test.js +1 -0
- data/webpack/components/ApplicationInstance/__tests__/ApplicationInstanceReducer.test.js +12 -0
- data/webpack/components/ApplicationInstance/__tests__/ApplicationInstanceSelectors.test.js +12 -0
- data/webpack/components/ApplicationInstance/__tests__/__snapshots__/ApplicationInstance.test.js.snap +98 -7
- data/webpack/components/ApplicationInstance/__tests__/__snapshots__/ApplicationInstanceReducer.test.js.snap +271 -0
- data/webpack/components/ApplicationInstance/__tests__/__snapshots__/ApplicationInstanceSelectors.test.js.snap +8 -0
- data/webpack/components/ApplicationInstance/components/AppDefinitionSelector.js +1 -0
- data/webpack/components/ApplicationInstance/components/ServiceCounter.js +1 -1
- data/webpack/components/ApplicationInstance/helper.js +0 -0
- data/webpack/components/ApplicationInstance/index.js +8 -0
- data/webpack/components/ApplicationInstanceReport/ApplicationInstanceReport.js +81 -6
- data/webpack/components/ApplicationInstanceReport/ApplicationInstanceReportActions.js +35 -1
- data/webpack/components/ApplicationInstanceReport/ApplicationInstanceReportConstants.js +3 -0
- data/webpack/components/ApplicationInstanceReport/ApplicationInstanceReportReducer.js +19 -0
- data/webpack/components/ApplicationInstanceReport/ApplicationInstanceReportSelectors.js +4 -0
- data/webpack/components/ApplicationInstanceReport/__tests__/__snapshots__/ApplicationInstanceReport.test.js.snap +1 -124
- data/webpack/components/ApplicationInstanceReport/index.js +8 -1
- data/webpack/components/ExistingHostSelection/ExistingHostSelection.js +104 -0
- data/webpack/components/ExistingHostSelection/ExistingHostSelection.scss +15 -0
- data/webpack/components/ExistingHostSelection/ExistingHostSelectionActions.js +71 -0
- data/webpack/components/ExistingHostSelection/ExistingHostSelectionConstants.js +4 -0
- data/webpack/components/ExistingHostSelection/ExistingHostSelectionHelper.js +0 -0
- data/webpack/components/ExistingHostSelection/ExistingHostSelectionReducer.js +90 -0
- data/webpack/components/ExistingHostSelection/ExistingHostSelectionSelectors.js +8 -0
- data/webpack/components/ExistingHostSelection/__fixtures__/existingHostSelectionConfData_1.fixtures.js +191 -0
- data/webpack/components/ExistingHostSelection/__fixtures__/existingHostSelectionReducer.fixtures.js +203 -0
- data/webpack/components/ExistingHostSelection/__tests__/ExistingHostSelection.test.js +19 -0
- data/webpack/components/ExistingHostSelection/__tests__/ExistingHostSelectionReducer.test.js +59 -0
- data/webpack/components/ExistingHostSelection/__tests__/ExistingHostSelectionSelectors.test.js +36 -0
- data/webpack/components/ExistingHostSelection/__tests__/__snapshots__/ExistingHostSelection.test.js.snap +35 -0
- data/webpack/components/ExistingHostSelection/__tests__/__snapshots__/ExistingHostSelectionReducer.test.js.snap +614 -0
- data/webpack/components/ExistingHostSelection/__tests__/__snapshots__/ExistingHostSelectionSelectors.test.js.snap +27 -0
- data/webpack/components/ExistingHostSelection/components/ServiceSelector.js +48 -0
- data/webpack/components/ExistingHostSelection/components/__tests__/ServiceSelector.test.js +35 -0
- data/webpack/components/ExistingHostSelection/components/__tests__/__snapshots__/ServiceSelector.test.js.snap +77 -0
- data/webpack/components/ExistingHostSelection/index.js +26 -0
- data/webpack/components/ParameterSelection/ParameterSelection.js +103 -1
- data/webpack/components/ParameterSelection/ParameterSelection.scss +7 -0
- data/webpack/components/ParameterSelection/ParameterSelectionActions.js +46 -4
- data/webpack/components/ParameterSelection/ParameterSelectionConstants.js +2 -0
- data/webpack/components/ParameterSelection/ParameterSelectionHelper.js +5 -1
- data/webpack/components/ParameterSelection/ParameterSelectionReducer.js +52 -11
- data/webpack/components/ParameterSelection/ParameterSelectionSelectors.js +2 -0
- data/webpack/components/ParameterSelection/__fixtures__/parameterSelectionData_1.fixtures.js +8 -0
- data/webpack/components/ParameterSelection/__tests__/ParameterSelectionReducer.test.js +2 -0
- data/webpack/components/ParameterSelection/__tests__/ParameterSelectionSelectors.test.js +6 -0
- data/webpack/components/ParameterSelection/__tests__/__snapshots__/ParameterSelection.test.js.snap +96 -0
- data/webpack/components/ParameterSelection/__tests__/__snapshots__/ParameterSelectionReducer.test.js.snap +117 -17
- data/webpack/components/ParameterSelection/__tests__/__snapshots__/ParameterSelectionSelectors.test.js.snap +13 -0
- data/webpack/components/ParameterSelection/index.js +4 -1
- data/webpack/components/SyncGitRepo/SyncGitRepo.js +2 -10
- data/webpack/components/SyncGitRepo/SyncGitRepoActions.js +2 -3
- data/webpack/components/SyncGitRepo/SyncGitRepoConstants.js +0 -1
- data/webpack/components/SyncGitRepo/__tests__/__snapshots__/SyncGitRepo.test.js.snap +1 -0
- data/webpack/components/SyncGitRepo/components/FormTextInput.js +1 -1
- data/webpack/components/SyncGitRepo/components/ScmTypeSelector.js +3 -2
- data/webpack/components/common/DeleteTableEntry.js +16 -2
- data/webpack/components/common/__tests__/__snapshots__/DeleteTableEntry.test.js.snap +38 -0
- data/webpack/helper.js +21 -1
- data/webpack/helper.test.js +20 -1
- data/webpack/index.js +5 -0
- data/webpack/js-yaml.js +3874 -0
- data/webpack/reducer.js +13 -2
- data/webpack/test_setup.js +0 -2
- metadata +46 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a14a2b2d7f2353e0a908bf3b0de9ea3126fff402e1003f9bea648afe260c73c0
|
|
4
|
+
data.tar.gz: 6eecc24dc7be78b4ddc04da2b50a525dd95a2b358647d8701e1ea6479eef5332
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 372bb6339ec6ff38e80dbc8df019083ff76a660c64cd23b40ab7da5b6c035617e7395f3c2738a04f21dd327c796ca959cb250ef6bbbbfb4b23d1220177ffab6f
|
|
7
|
+
data.tar.gz: 5a68e1cb910e7fca00e0f44172ff391a93557a7c9febbb8eb40a2711471c7d67bfc29ea5c70e763a7b94aa7c732e406b42d2d36daa05971f950824c6eae10d80
|
data/README.md
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
[](https://travis-ci.org/ATIX-AG/foreman_acd)
|
|
2
|
-
|
|
3
1
|
# Foreman Application Centric Deployment
|
|
4
2
|
|
|
5
3
|
A Foreman plugin providing application centric deployment and a self-service portal.
|
|
@@ -55,6 +53,10 @@ This plugin aims to setup all six hosts and to deploy the application.
|
|
|
55
53
|
|
|
56
54
|
:warning: This plugin is still in development.
|
|
57
55
|
|
|
56
|
+
## Documentation
|
|
57
|
+
|
|
58
|
+
See [Application Centric Deployment Guide](https://docs.theforeman.org/nightly/Application_Centric_Deployment/index-foreman-el.html)
|
|
59
|
+
|
|
58
60
|
## Installation
|
|
59
61
|
|
|
60
62
|
See the [installation](https://theforeman.org/plugins/#2.Installation) chapter of the Foreman plugins documentation on how to install Foreman plugins.
|
|
@@ -88,7 +90,7 @@ You need to refresh the smart proxy features in *Infrastructure > Smart Proxies
|
|
|
88
90
|
### Ansible Playbook
|
|
89
91
|
|
|
90
92
|
* Copy (or checkout a git repository) an Ansible playbook.
|
|
91
|
-
Store it in `/
|
|
93
|
+
Store it in `/var/lib/foreman/foreman_acd/ansible-playbooks/` so that SELinux is able to read it.
|
|
92
94
|
* Add a new Ansible Playbook via *Applications > Ansible Playbooks*.
|
|
93
95
|
* Specify the path to the Ansible playbook and name of the playbook file. (e.g. `site.yml`).
|
|
94
96
|
* Save it and press *Import group variables* for this newly created Ansible playbook.
|
|
@@ -117,8 +119,6 @@ All Foreman parameters require a value.
|
|
|
117
119
|
|
|
118
120
|
## TODO
|
|
119
121
|
|
|
120
|
-
* Add `git` support for the Ansible playbooks.
|
|
121
|
-
* Provide application templates which contains application definition and the required Ansible-playbook.
|
|
122
122
|
* Add Saltstack support to configure the application.
|
|
123
123
|
* Extend the Foreman parameter and value validation.
|
|
124
124
|
|
|
@@ -77,9 +77,20 @@ module ForemanAcd
|
|
|
77
77
|
logger.info("HTTP Proxy used: #{git.config['http.proxy']}")
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
if sync_params[:git_commit].empty?
|
|
81
|
+
if ForemanAcd.proxy_setting.present?
|
|
82
|
+
err_msg = _('Please set the Git Branch/Tag/Commit. This setting is necessary if a HTTP proxy is used!')
|
|
83
|
+
raise StandardError.new err_msg
|
|
84
|
+
else
|
|
85
|
+
commit = Git.ls_remote(sync_params[:git_url])['head'][:sha]
|
|
86
|
+
end
|
|
87
|
+
else
|
|
88
|
+
commit = sync_params[:git_commit]
|
|
89
|
+
end
|
|
90
|
+
|
|
80
91
|
git.add_remote('origin', sync_params[:git_url])
|
|
81
92
|
git.fetch
|
|
82
|
-
git.checkout(
|
|
93
|
+
git.checkout(commit)
|
|
83
94
|
|
|
84
95
|
session[:git_path] = git.dir.path
|
|
85
96
|
rescue StandardError => e
|
|
@@ -189,7 +200,11 @@ module ForemanAcd
|
|
|
189
200
|
end
|
|
190
201
|
|
|
191
202
|
def remove_ansible_dir(dirpath)
|
|
192
|
-
|
|
203
|
+
unless dirpath.start_with? ForemanAcd.ansible_playbook_path
|
|
204
|
+
logger.error("Sorry, the directory #{dirpath} is not within #{ForemanAcd.ansible_playbook_path} and will therefore not be cleaned up!")
|
|
205
|
+
return false
|
|
206
|
+
end
|
|
207
|
+
FileUtils.rm_rf(dirpath) if Dir.exist?(dirpath)
|
|
193
208
|
end
|
|
194
209
|
|
|
195
210
|
def ansible_playbook_full_path(dirname)
|
|
@@ -9,7 +9,7 @@ module ForemanAcd
|
|
|
9
9
|
before_action :find_resource, :only => [:edit, :update, :destroy, :export]
|
|
10
10
|
before_action :read_hostgroups, :only => [:edit, :new, :import]
|
|
11
11
|
before_action :read_ansible_playbooks, :only => [:edit, :new]
|
|
12
|
-
before_action :
|
|
12
|
+
before_action :assign_ansible_playbook, :only => [:create]
|
|
13
13
|
|
|
14
14
|
def index
|
|
15
15
|
@app_definitions = resource_base.search_for(params[:search], :order => params[:order]).paginate(:page => params[:page])
|
|
@@ -37,6 +37,9 @@ module ForemanAcd
|
|
|
37
37
|
process_error
|
|
38
38
|
end
|
|
39
39
|
rescue StandardError, ValidationError => e
|
|
40
|
+
if params[:foreman_acd_app_definition_import].present?
|
|
41
|
+
AnsiblePlaybook.find(params[:foreman_acd_app_definition][:acd_ansible_playbook_id]).delete if params[:foreman_acd_app_definition][:acd_ansible_playbook_id].present?
|
|
42
|
+
end
|
|
40
43
|
redirect_to new_app_definition_path, :flash => { :error => _(e.message) }
|
|
41
44
|
end
|
|
42
45
|
end
|
|
@@ -75,16 +78,110 @@ module ForemanAcd
|
|
|
75
78
|
end
|
|
76
79
|
|
|
77
80
|
def export
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
dir_path = "#{Dir.mktmpdir}/#{@app_definition.name}.tar"
|
|
82
|
+
`tar -cvf "#{dir_path}" "#{@app_definition.ansible_playbook.path}" #{export_app_template_data.path}`
|
|
83
|
+
logger.info("Successfully created application template tar file for #{@app_definition.name}")
|
|
84
|
+
send_file dir_path
|
|
85
|
+
rescue StandardError => e
|
|
86
|
+
logger.info("Export of #{@app_definition.name} failed with the error: #{e}")
|
|
87
|
+
redirect_to app_definitions_path, :flash => { :error => _(e.message) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def handle_file_upload
|
|
91
|
+
return unless params[:app_definition_file] && (raw_directory = params[:app_definition_file])
|
|
92
|
+
begin
|
|
93
|
+
dir = Dir.mktmpdir
|
|
94
|
+
untar_import_directory(raw_directory, dir)
|
|
95
|
+
ansible_file = Dir.glob("#{dir}/tmp/*.yaml")
|
|
96
|
+
data = JSON.parse(YAML.load_file(ansible_file[0]).to_json) if ansible_file
|
|
97
|
+
ansible_playbook_import = data.find { |d| d if d['ansible_playbook'] }
|
|
98
|
+
|
|
99
|
+
session[:ansible_playbook_params] = { :dir => dir, :ansible_playbook => ansible_playbook_import['ansible_playbook'] }
|
|
100
|
+
render :json => { :ansible_services => create_ansible_services(data, ansible_playbook_import) }, :status => :ok
|
|
101
|
+
rescue StandardError => e
|
|
102
|
+
render :json => { :status => 'error', :message => e }, :status => :internal_server_error
|
|
103
|
+
end
|
|
81
104
|
end
|
|
82
105
|
|
|
83
106
|
private
|
|
84
107
|
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
|
|
108
|
+
def export_app_template_data
|
|
109
|
+
file = Tempfile.open([@app_definition.name, '.yaml'])
|
|
110
|
+
data = JSON.parse(@app_definition.services).append(:ansible_playbook => @app_definition.ansible_playbook.attributes.except('id', 'created_at', 'updated_at').as_json).to_yaml
|
|
111
|
+
file.write(data)
|
|
112
|
+
file.close
|
|
113
|
+
logger.info("Successfully created yaml file for app_template data: #{file.path}")
|
|
114
|
+
file
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
logger.info("Creation of app template data failed: #{e}")
|
|
117
|
+
redirect_to app_definitions_path, :flash => { :error => _(e.message) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def rename_path_name(dir_path)
|
|
121
|
+
return dir_path unless Dir.exist?(dir_path)
|
|
122
|
+
ind = 1
|
|
123
|
+
loop do
|
|
124
|
+
path = dir_path
|
|
125
|
+
path += ind.to_s
|
|
126
|
+
return path unless Dir.exist?(path)
|
|
127
|
+
ind += 1
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def create_ansible_playbook(dir, ansible_playbook, dir_path)
|
|
132
|
+
FileUtils.cp_r "#{dir}/#{ansible_playbook['path']}/.", dir_path
|
|
133
|
+
ansible_playbook['path'] = dir_path
|
|
134
|
+
ansible_playbook['name'] = File.basename(dir_path)
|
|
135
|
+
new_playbook = AnsiblePlaybook.create(ansible_playbook)
|
|
136
|
+
new_playbook
|
|
137
|
+
rescue StandardError => e
|
|
138
|
+
logger.info("Error while creating AnsiblePlaybook: #{e}")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def create_ansible_services(data, ansible_playbook_import)
|
|
142
|
+
ansible_services = []
|
|
143
|
+
session[:data_services] = data - [ansible_playbook_import]
|
|
144
|
+
session[:data_services].each do |d|
|
|
145
|
+
ansible_services.append({ :id => d['id'], :value => d['name'] })
|
|
146
|
+
end
|
|
147
|
+
ansible_services
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def untar_import_directory(directory, dir)
|
|
151
|
+
`tar -xvf #{directory.path} -C #{dir}`
|
|
152
|
+
rescue StandardError => e
|
|
153
|
+
logger.info("Failed to untar imported directory: #{e}")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def assign_ansible_playbook
|
|
157
|
+
return unless params[:foreman_acd_app_definition_import]
|
|
158
|
+
dir_path = "#{ForemanAcd.ansible_playbook_path}/#{session[:ansible_playbook_params][:ansible_playbook]['name'].split(/\W+/).join('_')}"
|
|
159
|
+
|
|
160
|
+
# Append path and name of ansible with n + 1 if ansible_playbook with same name or name[n] exists
|
|
161
|
+
ansible_playbook = create_ansible_playbook(session[:ansible_playbook_params][:dir], session[:ansible_playbook_params][:ansible_playbook], rename_path_name(dir_path))
|
|
162
|
+
params[:foreman_acd_app_definition][:acd_ansible_playbook_id] = ansible_playbook.id
|
|
163
|
+
|
|
164
|
+
begin
|
|
165
|
+
services = JSON.parse(params[:foreman_acd_app_definition_import][:services])
|
|
166
|
+
flag = 0
|
|
167
|
+
session[:data_services].each do |d|
|
|
168
|
+
hostgroup = services.find { |service| service['name'] == d['name'] }['hostgroup']
|
|
169
|
+
if hostgroup == ''
|
|
170
|
+
flag += 1
|
|
171
|
+
break
|
|
172
|
+
else
|
|
173
|
+
d['hostgroup'] = hostgroup
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
params[:foreman_acd_app_definition][:services] = session[:data_services].to_json
|
|
177
|
+
rescue StandardError => e
|
|
178
|
+
ansible_playbook&.destroy
|
|
179
|
+
redirect_to({ :action => 'import' }, :error => _("Hostgroups are not configured properly: #{e}"))
|
|
180
|
+
else
|
|
181
|
+
ansible_playbook&.destroy
|
|
182
|
+
redirect_to({ :action => 'import' }, :error => _('Some services are not assigned Hostgroups')) if flag.positive?
|
|
183
|
+
end
|
|
184
|
+
session[:data_services] = nil
|
|
88
185
|
end
|
|
89
186
|
end
|
|
90
187
|
end
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
module ForemanAcd
|
|
4
4
|
# Application Instance Controller
|
|
5
5
|
class AppInstancesController < ::ForemanAcd::ApplicationController
|
|
6
|
-
include Foreman::Controller::AutoCompleteSearch
|
|
6
|
+
include ::Foreman::Controller::AutoCompleteSearch
|
|
7
7
|
include ::ForemanAcd::Concerns::AppInstanceParameters
|
|
8
|
+
include ::ForemanAcd::Concerns::AppInstanceMixins
|
|
8
9
|
|
|
9
10
|
before_action :find_resource, :only => [:edit, :update, :destroy_with_hosts, :deploy, :report]
|
|
10
11
|
before_action :read_applications, :only => [:new, :edit]
|
|
@@ -73,13 +74,17 @@ module ForemanAcd
|
|
|
73
74
|
|
|
74
75
|
def deploy
|
|
75
76
|
value = false
|
|
77
|
+
@app_instance.update!({ :last_deploy_task_id => nil,
|
|
78
|
+
:initial_configure_task_id => nil })
|
|
79
|
+
@app_instance.foreman_hosts.each { |f| f.update!(:last_progress_report => nil) }
|
|
76
80
|
@app_instance.clean_all_hosts if params[:delete_hosts]
|
|
77
81
|
value = safe_deploy? if params[:safe_deploy]
|
|
78
82
|
session.delete(:remember_hosts)
|
|
79
83
|
logger.info('Run async foreman task to deploy hosts')
|
|
80
84
|
async_task = ForemanTasks.async_task(::Actions::ForemanAcd::DeployAllHosts, @app_instance, value)
|
|
81
85
|
@app_instance.update!(:last_deploy_task => async_task)
|
|
82
|
-
|
|
86
|
+
|
|
87
|
+
redirect_to report_app_instance_path, :success => _('Started task to deploy hosts for %s') % @app_instance
|
|
83
88
|
rescue StandardError => e
|
|
84
89
|
error_msg = "Error happend while deploying hosts of #{@app_instance}: #{e.message}"
|
|
85
90
|
logger.error("#{error_msg} - #{e.class}\n#{e.backtrace.join($INPUT_RECORD_SEPARATOR)}")
|
|
@@ -92,7 +97,7 @@ module ForemanAcd
|
|
|
92
97
|
end
|
|
93
98
|
|
|
94
99
|
def report
|
|
95
|
-
@report_hosts = collect_host_report_data
|
|
100
|
+
@report_hosts = collect_host_report_data(@app_instance)
|
|
96
101
|
logger.debug("app instance host details: #{@report_hosts.inspect}")
|
|
97
102
|
end
|
|
98
103
|
|
|
@@ -112,8 +117,12 @@ module ForemanAcd
|
|
|
112
117
|
# Store hosts if updated for safe deploy
|
|
113
118
|
session[:remember_hosts] << updated_host.id if updated_host.updated_at != old_host.updated_at
|
|
114
119
|
else
|
|
115
|
-
@app_instance.foreman_hosts.create(:hostname => h['hostname'],
|
|
116
|
-
:
|
|
120
|
+
@app_instance.foreman_hosts.create(:hostname => h['hostname'],
|
|
121
|
+
:service => h['service'],
|
|
122
|
+
:description => h['description'],
|
|
123
|
+
:is_existing_host => h['isExistingHost'],
|
|
124
|
+
:foremanParameters => JSON.dump(h['foremanParameters']),
|
|
125
|
+
:ansibleParameters => JSON.dump(h['ansibleParameters']))
|
|
117
126
|
# Store new hosts for safe deploy
|
|
118
127
|
session[:remember_hosts] << @app_instance.foreman_hosts.find_by(:hostname => h['hostname']).id
|
|
119
128
|
end
|
|
@@ -132,6 +141,7 @@ module ForemanAcd
|
|
|
132
141
|
:hostname => h.hostname,
|
|
133
142
|
:service => h.service,
|
|
134
143
|
:description => h.description,
|
|
144
|
+
:isExistingHost => h.is_existing_host,
|
|
135
145
|
:foremanParameters => JSON.parse(h.foremanParameters),
|
|
136
146
|
:ansibleParameters => JSON.parse(h.ansibleParameters)
|
|
137
147
|
}
|
|
@@ -152,30 +162,5 @@ module ForemanAcd
|
|
|
152
162
|
def read_applications
|
|
153
163
|
@applications = AppDefinition.all.map { |elem| { elem.id => elem.name } }.reduce({}) { |h, v| h.merge v }
|
|
154
164
|
end
|
|
155
|
-
|
|
156
|
-
def collect_host_report_data
|
|
157
|
-
report_data = []
|
|
158
|
-
|
|
159
|
-
@app_instance.foreman_hosts.each do |foreman_host|
|
|
160
|
-
a_host = {
|
|
161
|
-
:id => nil,
|
|
162
|
-
:name => foreman_host.hostname,
|
|
163
|
-
:build => nil,
|
|
164
|
-
:hostUrl => nil,
|
|
165
|
-
:progress_report => foreman_host.last_progress_report.empty? ? [] : JSON.parse(foreman_host.last_progress_report)
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
if foreman_host.host.present?
|
|
169
|
-
a_host.update({
|
|
170
|
-
:id => foreman_host.host.id,
|
|
171
|
-
:build => foreman_host.host.build,
|
|
172
|
-
:hostUrl => host_path(foreman_host.host),
|
|
173
|
-
:powerStatusUrl => power_api_host_path(foreman_host.host)
|
|
174
|
-
})
|
|
175
|
-
end
|
|
176
|
-
report_data << a_host
|
|
177
|
-
end
|
|
178
|
-
report_data
|
|
179
|
-
end
|
|
180
165
|
end
|
|
181
166
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ForemanAcd
|
|
4
|
+
module Concerns
|
|
5
|
+
# Shared code for AppInstance API and UI controller
|
|
6
|
+
module AppInstanceMixins
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
def collect_host_report_data(app_instance)
|
|
10
|
+
report_data = []
|
|
11
|
+
|
|
12
|
+
app_instance.foreman_hosts.each do |foreman_host|
|
|
13
|
+
a_host = {
|
|
14
|
+
:id => nil,
|
|
15
|
+
:name => foreman_host.hostname,
|
|
16
|
+
:build => nil,
|
|
17
|
+
:hostUrl => nil,
|
|
18
|
+
:progress_report => foreman_host.last_progress_report.empty? ? [] : JSON.parse(foreman_host.last_progress_report)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if foreman_host.host.present?
|
|
22
|
+
a_host.update({
|
|
23
|
+
:id => foreman_host.host.id,
|
|
24
|
+
:build => foreman_host.host.build,
|
|
25
|
+
:hostUrl => host_path(foreman_host.host),
|
|
26
|
+
:isExistingHost => foreman_host.is_existing_host,
|
|
27
|
+
:powerStatusUrl => power_api_host_path(foreman_host.host)
|
|
28
|
+
})
|
|
29
|
+
end
|
|
30
|
+
report_data << OpenStruct.new(a_host)
|
|
31
|
+
end
|
|
32
|
+
report_data
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
# Controller to create JSON data to be used in react app
|
|
4
4
|
class UiAcdController < ::Api::V2::BaseController
|
|
5
|
+
include ::ForemanAcd::Concerns::AppInstanceMixins
|
|
6
|
+
|
|
5
7
|
def app
|
|
6
8
|
@app_data = {}
|
|
7
9
|
app_definition = ForemanAcd::AppDefinition.find(params[:id])
|
|
@@ -16,9 +18,17 @@ class UiAcdController < ::Api::V2::BaseController
|
|
|
16
18
|
@ansible_data = collect_ansible_data(params['id'])
|
|
17
19
|
end
|
|
18
20
|
|
|
21
|
+
def report_data
|
|
22
|
+
@report_data = collect_report_data(params['id'])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validate_hostname
|
|
26
|
+
@host_validation = hostname_duplicate?(params['appDefId'].to_i, params['serviceId'].to_i, params['hostname'])
|
|
27
|
+
end
|
|
28
|
+
|
|
19
29
|
def action_permission
|
|
20
30
|
case params[:action]
|
|
21
|
-
when 'app', 'foreman_data', 'ansible_data'
|
|
31
|
+
when 'app', 'foreman_data', 'ansible_data', 'validate_hostname'
|
|
22
32
|
:view
|
|
23
33
|
else
|
|
24
34
|
super
|
|
@@ -30,17 +40,46 @@ class UiAcdController < ::Api::V2::BaseController
|
|
|
30
40
|
def collect_foreman_data(hostgroup_id)
|
|
31
41
|
hg = Hostgroup.find(hostgroup_id)
|
|
32
42
|
fdata = OpenStruct.new(
|
|
33
|
-
:environments => Environment.all,
|
|
34
|
-
:lifecycle_environments => Katello::KTEnvironment.all,
|
|
35
43
|
:domains => Domain.all,
|
|
36
44
|
:computeprofiles => ComputeProfile.all,
|
|
37
45
|
:hostgroup_id => hg.id,
|
|
38
46
|
:ptables => hg&.operatingsystem&.ptables
|
|
39
47
|
)
|
|
48
|
+
|
|
49
|
+
fdata[:environments] = Environment.all if defined?(ForemanPuppet)
|
|
50
|
+
fdata[:lifecycle_environments] = Katello::KTEnvironment.all if defined?(Katello)
|
|
51
|
+
|
|
40
52
|
fdata
|
|
41
53
|
end
|
|
42
54
|
|
|
43
55
|
def collect_ansible_data(playbook_id)
|
|
44
56
|
ForemanAcd::AnsiblePlaybook.find(playbook_id).as_unified_structobj
|
|
45
57
|
end
|
|
58
|
+
|
|
59
|
+
def collect_report_data(app_instance_id)
|
|
60
|
+
app_instance = ForemanAcd::AppInstance.find(app_instance_id)
|
|
61
|
+
|
|
62
|
+
report_data = {
|
|
63
|
+
:hosts => collect_host_report_data(app_instance),
|
|
64
|
+
:deploymentState => app_instance.deployment_state.to_s,
|
|
65
|
+
:initialConfigureState => app_instance.initial_configure_state.to_s
|
|
66
|
+
}
|
|
67
|
+
report_data['initialConfigureJobUrl'] = job_invocation_path(app_instance.initial_configure_job) unless app_instance.initial_configure_job.nil?
|
|
68
|
+
|
|
69
|
+
OpenStruct.new(report_data)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def hostname_duplicate?(app_def_id, service_id, hostname)
|
|
73
|
+
app_definition = ForemanAcd::AppDefinition.find(app_def_id)
|
|
74
|
+
service_data = JSON.parse(app_definition.services).select { |k| k['id'] == service_id }.first
|
|
75
|
+
domain_name = Hostgroup.find(service_data['hostgroup']).domain.name
|
|
76
|
+
validation_hostname = "#{hostname}.#{domain_name}"
|
|
77
|
+
|
|
78
|
+
vdata = OpenStruct.new(
|
|
79
|
+
:hostname => hostname,
|
|
80
|
+
:fqdn => validation_hostname,
|
|
81
|
+
:result => Host.find_by(:name => validation_hostname).nil?
|
|
82
|
+
)
|
|
83
|
+
vdata
|
|
84
|
+
end
|
|
46
85
|
end
|
|
@@ -19,6 +19,7 @@ module Actions
|
|
|
19
19
|
if result.success
|
|
20
20
|
::Foreman::Logging.logger('foreman_acd').info "Creating job to configure the app #{app_instance}"
|
|
21
21
|
job.trigger!
|
|
22
|
+
output[:configure_job_id] = job.job_invocation.id
|
|
22
23
|
else
|
|
23
24
|
::Foreman::Logging.logger('foreman_acd').error "Could not create the job to configure the app #{app_instance}: #{result.error}"
|
|
24
25
|
end
|
|
@@ -5,47 +5,60 @@ module ForemanAcd
|
|
|
5
5
|
module HostManagedExtensions
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
7
|
|
|
8
|
-
RUN_CONFIGURATOR_DELAY = 240
|
|
9
|
-
|
|
10
8
|
def self.prepended(base)
|
|
11
9
|
base.instance_eval do
|
|
12
|
-
before_provision :
|
|
10
|
+
before_provision :initiate_acd_app_configurator_after_host_deployment, :if => :deployed_via_acd?
|
|
11
|
+
before_destroy :check_deletable, :prepend => true, :if => :deployed_via_acd?
|
|
12
|
+
|
|
13
|
+
has_many :app_instances, :through => :foreman_hosts, :class_name => 'ForemanAcd::AppInstance'
|
|
14
|
+
|
|
15
|
+
scoped_search :relation => :app_instances,
|
|
16
|
+
:on => :name,
|
|
17
|
+
:rename => :acd_app_instance,
|
|
18
|
+
:only_explicit => true,
|
|
19
|
+
:complete_value => true,
|
|
20
|
+
:operators => ['= '],
|
|
21
|
+
:ext_method => :find_by_acd_app_instance_name
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
base.singleton_class.prepend ClassMethods
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# New class methods for Host::Managed
|
|
28
|
+
module ClassMethods
|
|
29
|
+
def find_by_acd_app_instance_name(_key, operator, acd_instance_name)
|
|
30
|
+
cond = sanitize_sql_for_conditions(["acd_app_instances.name #{operator} ?", value_to_sql(operator, acd_instance_name)])
|
|
31
|
+
hosts = ForemanAcd::AppInstance.where(cond).joins(:foreman_hosts).pluck(:host_id)
|
|
32
|
+
if hosts.empty?
|
|
33
|
+
{ :condition => '1=0' }
|
|
34
|
+
else
|
|
35
|
+
{ :conditions => Host::Managed.arel_table[:id].in(hosts).to_sql }
|
|
36
|
+
end
|
|
13
37
|
end
|
|
14
38
|
end
|
|
15
39
|
|
|
16
40
|
def deployed_via_acd?
|
|
17
|
-
|
|
18
|
-
@
|
|
41
|
+
find_foreman_host
|
|
42
|
+
@foreman_host.present?
|
|
19
43
|
end
|
|
20
44
|
|
|
21
45
|
private
|
|
22
46
|
|
|
23
|
-
def
|
|
24
|
-
|
|
47
|
+
def check_deletable
|
|
48
|
+
return if @foreman_host.blank?
|
|
49
|
+
::Foreman::Logging.logger('foreman_acd').warn "Could not delete host '#{name}' because it is used in Applications > App Instances '#{@foreman_host.app_instance.name}'"
|
|
50
|
+
raise _("Could not delete host '%{host_name}' because it is used in Applications > App Instances '%{app_instance_name}'") % {
|
|
51
|
+
:host_name => name, :app_instance_name => @foreman_host.app_instance.name
|
|
52
|
+
}
|
|
25
53
|
end
|
|
26
54
|
|
|
27
|
-
def
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
run_it = true
|
|
31
|
-
@app_instance_host.app_instance.foreman_hosts.each do |foreman_host|
|
|
32
|
-
# if there is ONE host, which is still in build phase we don't let the app_configuator run
|
|
33
|
-
next unless foreman_host.host.build?
|
|
34
|
-
::Foreman::Logging.logger('foreman_acd').info("Another host (#{foreman_host.host.name} is still in build-phase. Wait for it...")
|
|
35
|
-
run_it = false
|
|
36
|
-
break
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
return unless run_it
|
|
40
|
-
|
|
41
|
-
::Foreman::Logging.logger('foreman_acd').info("All hosts of app (#{@app_instance_host.app_instance.name}) were built. Schedule app configurator...")
|
|
42
|
-
start_acd_app_configurator
|
|
55
|
+
def find_foreman_host
|
|
56
|
+
@foreman_host = ForemanAcd::ForemanHost.find_by(:host_id => id)
|
|
43
57
|
end
|
|
44
58
|
|
|
45
|
-
def
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
@app_instance_host.app_instance)
|
|
59
|
+
def initiate_acd_app_configurator_after_host_deployment
|
|
60
|
+
return if @foreman_host.blank?
|
|
61
|
+
ForemanAcd.initiate_acd_app_configurator(@foreman_host.app_instance)
|
|
49
62
|
end
|
|
50
63
|
end
|
|
51
64
|
end
|
|
@@ -11,6 +11,7 @@ module ForemanAcd
|
|
|
11
11
|
|
|
12
12
|
self.table_name = 'acd_app_instances'
|
|
13
13
|
belongs_to :last_deploy_task, :class_name => 'ForemanTasks::Task'
|
|
14
|
+
belongs_to :initial_configure_task, :class_name => 'ForemanTasks::Task'
|
|
14
15
|
validates :name, :presence => true, :uniqueness => true
|
|
15
16
|
validates :app_definition, :presence => true
|
|
16
17
|
belongs_to :app_definition, :inverse_of => :app_instances
|
|
@@ -32,7 +33,7 @@ module ForemanAcd
|
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
def clean_all_hosts
|
|
35
|
-
remember_host_ids = foreman_hosts.map(&:host_id)
|
|
36
|
+
remember_host_ids = foreman_hosts.select(&:fresh_host?).map(&:host_id)
|
|
36
37
|
|
|
37
38
|
# Clean the app instance association first
|
|
38
39
|
foreman_hosts.update_all(:host_id => nil)
|
|
@@ -43,12 +44,56 @@ module ForemanAcd
|
|
|
43
44
|
|
|
44
45
|
def clean_hosts_by_id(ids = [])
|
|
45
46
|
# Clean the app instance association first
|
|
46
|
-
foreman_hosts.where(:host_id => ids).update_all(:host_id => nil)
|
|
47
|
+
foreman_hosts.where(:host_id => ids, :is_existing_host => false).update_all(:host_id => nil)
|
|
47
48
|
|
|
48
49
|
# Remove all hosts afterwards
|
|
49
50
|
delete_hosts(ids)
|
|
50
51
|
end
|
|
51
52
|
|
|
53
|
+
def hosts_deployment_finished?
|
|
54
|
+
return true if all_hosts_deployed?
|
|
55
|
+
|
|
56
|
+
::Foreman::Logging.logger('foreman_acd').info('Another host is still in build-phase. Wait for it...')
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def deployment_state
|
|
61
|
+
return :new if last_deploy_task.nil?
|
|
62
|
+
return :initiated if !last_deploy_task.nil? && last_deploy_task.ended_at.nil?
|
|
63
|
+
|
|
64
|
+
state = if all_hosts_deployed?
|
|
65
|
+
:finished
|
|
66
|
+
elsif last_deploy_task.ended_at? && last_deploy_task.result != 'success'
|
|
67
|
+
:failed
|
|
68
|
+
else
|
|
69
|
+
:pending
|
|
70
|
+
end
|
|
71
|
+
state
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def initial_configure_job
|
|
75
|
+
return nil if initial_configure_task.nil?
|
|
76
|
+
return JobInvocation.find(initial_configure_task.output['configure_job_id']) if initial_configure_task.output.key?('configure_job_id') &&
|
|
77
|
+
!initial_configure_task.output['configure_job_id'].nil?
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def initial_configure_state
|
|
82
|
+
return :unconfigured if initial_configure_job.nil? && initial_configure_task.nil?
|
|
83
|
+
return :scheduled if initial_configure_job.nil?
|
|
84
|
+
return :pending unless initial_configure_job.finished?
|
|
85
|
+
initial_configure_job.status_label.to_sym
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def all_hosts_deployed?
|
|
91
|
+
foreman_hosts.each do |foreman_host|
|
|
92
|
+
return false if foreman_host.host.nil? || foreman_host.host.build?
|
|
93
|
+
end
|
|
94
|
+
true
|
|
95
|
+
end
|
|
96
|
+
|
|
52
97
|
def delete_hosts(ids = [])
|
|
53
98
|
return if ids.empty?
|
|
54
99
|
ids.each do |host_id|
|
|
@@ -22,8 +22,17 @@ module ForemanAcd
|
|
|
22
22
|
|
|
23
23
|
foreman_hosts.each do |foreman_host|
|
|
24
24
|
service_data = services.select { |k| k['id'] == foreman_host.service.to_i }.first
|
|
25
|
-
host_params = set_host_params(foreman_host, service_data)
|
|
26
25
|
|
|
26
|
+
# Handle already deployed hosts
|
|
27
|
+
if foreman_host.existing_host?
|
|
28
|
+
domain = Hostgroup.find(service_data['hostgroup']).domain.name
|
|
29
|
+
fqdn = "#{foreman_host.hostname}.#{domain}"
|
|
30
|
+
h = Host.find_by(:name => fqdn)
|
|
31
|
+
foreman_host.update!(:host_id => h.id)
|
|
32
|
+
next
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
host_params = set_host_params(foreman_host, service_data)
|
|
27
36
|
host = foreman_host.host.presence
|
|
28
37
|
|
|
29
38
|
is_rebuild = false
|
|
@@ -45,7 +54,10 @@ module ForemanAcd
|
|
|
45
54
|
end
|
|
46
55
|
|
|
47
56
|
# REMOVE ME (but very nice for testing)
|
|
48
|
-
#
|
|
57
|
+
# prng = Random.new
|
|
58
|
+
# x = prng.rand(100)
|
|
59
|
+
# y = prng.rand(100)
|
|
60
|
+
# host.mac = "00:11:22:33:#{x}:#{y}"
|
|
49
61
|
|
|
50
62
|
apply_compute_profile(host)
|
|
51
63
|
host.suggest_default_pxe_loader
|
|
@@ -73,6 +85,11 @@ module ForemanAcd
|
|
|
73
85
|
output << msg
|
|
74
86
|
end
|
|
75
87
|
end
|
|
88
|
+
|
|
89
|
+
# Try to start the configuration, too. In case of a app instance including only already deployed hosts
|
|
90
|
+
# this would start the configuration job then.
|
|
91
|
+
ForemanAcd.initiate_acd_app_configurator(@app_instance)
|
|
92
|
+
|
|
76
93
|
output
|
|
77
94
|
end
|
|
78
95
|
|