947a9630d97706876c7136a4b41b8be9aede23ef
[ccsdk/cds.git] /
1 /*
2  *  Copyright © 2019 Bell Canada.
3  *  Modifications Copyright © 2018-2019 IBM.
4  *
5  *  Licensed under the Apache License, Version 2.0 (the "License");
6  *  you may not use this file except in compliance with the License.
7  *  You may obtain a copy of the License at
8  *
9  *      http://www.apache.org/licenses/LICENSE-2.0
10  *
11  *  Unless required by applicable law or agreed to in writing, software
12  *  distributed under the License is distributed on an "AS IS" BASIS,
13  *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14  *  See the License for the specific language governing permissions and
15  *  limitations under the License.
16  */
17
18 package org.onap.ccsdk.cds.blueprintsprocessor.functions.ansible.executor
19
20 import com.fasterxml.jackson.databind.JsonNode
21 import com.fasterxml.jackson.databind.ObjectMapper
22 import com.fasterxml.jackson.databind.node.TextNode
23 import org.onap.ccsdk.cds.blueprintsprocessor.core.api.data.ExecutionServiceInput
24 import org.onap.ccsdk.cds.blueprintsprocessor.rest.service.BluePrintRestLibPropertyService
25 import org.onap.ccsdk.cds.blueprintsprocessor.rest.service.BlueprintWebClientService
26 import org.onap.ccsdk.cds.blueprintsprocessor.services.execution.AbstractComponentFunction
27 import org.onap.ccsdk.cds.controllerblueprints.core.asJsonPrimitive
28 import org.onap.ccsdk.cds.controllerblueprints.core.asJsonString
29 import org.onap.ccsdk.cds.controllerblueprints.core.isNotNull
30 import org.onap.ccsdk.cds.controllerblueprints.core.rootFieldsToMap
31 import org.onap.ccsdk.cds.controllerblueprints.core.utils.JacksonUtils
32 import org.slf4j.LoggerFactory
33 import org.springframework.beans.factory.config.ConfigurableBeanFactory
34 import org.springframework.context.annotation.Scope
35 import org.springframework.http.HttpMethod
36 import org.springframework.stereotype.Component
37 import java.net.URI
38 import java.net.URLEncoder
39 import java.util.*
40
41 /**
42  * ComponentRemoteAnsibleExecutor
43  *
44  * Component that launches a run of a job template (INPUT_JOB_TEMPLATE_NAME) representing an Ansible playbook,
45  * and its parameters, via the AWX server identified by the INPUT_ENDPOINT_SELECTOR parameter.
46  *
47  * It supports extra_vars, limit, tags, skip-tags, inventory (by name or Id) Ansible parameters.
48  * It reports the results of the execution via properties, named execute-command-status and execute-command-logs
49  *
50  * @author Serge Simard
51  */
52 @Component("component-remote-ansible-executor")
53 @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
54 open class ComponentRemoteAnsibleExecutor(private val blueprintRestLibPropertyService: BluePrintRestLibPropertyService,
55                                           private val mapper: ObjectMapper)
56     : AbstractComponentFunction() {
57
58     // HTTP related constants
59     private val HTTP_SUCCESS = 200..202
60     private val GET = HttpMethod.GET.name
61     private val POST = HttpMethod.POST.name
62
63     var checkDelay: Long = 1_000
64
65     companion object {
66         private val log = LoggerFactory.getLogger(ComponentRemoteAnsibleExecutor::class.java)
67
68         // input fields names accepted by this executor
69         const val INPUT_ENDPOINT_SELECTOR = "endpoint-selector"
70         const val INPUT_JOB_TEMPLATE_NAME = "job-template-name"
71         const val INPUT_LIMIT_TO_HOST = "limit"
72         const val INPUT_INVENTORY = "inventory"
73         const val INPUT_EXTRA_VARS = "extra-vars"
74         const val INPUT_TAGS = "tags"
75         const val INPUT_SKIP_TAGS = "skip-tags"
76
77         // output fields names (and values) populated by this executor; aligned with job details status field values.
78         const val ATTRIBUTE_EXEC_CMD_STATUS = "ansible-command-status"
79         const val ATTRIBUTE_EXEC_CMD_LOG = "ansible-command-logs"
80         const val ATTRIBUTE_EXEC_CMD_STATUS_ERROR = "error"
81     }
82
83     override suspend fun processNB(executionRequest: ExecutionServiceInput) {
84
85         try {
86             val restClientService = getAWXRestClient()
87
88             val jobTemplateName = getOperationInput(INPUT_JOB_TEMPLATE_NAME).asText()
89             val jtId = lookupJobTemplateIDByName(restClientService, jobTemplateName)
90             if (jtId.isNotEmpty()) {
91                 runJobTemplateOnAWX(restClientService, jobTemplateName, jtId)
92             } else {
93                 val message = "Job template ${jobTemplateName} does not exists"
94                 log.error(message)
95                 setNodeOutputErrors(ATTRIBUTE_EXEC_CMD_STATUS_ERROR, message)
96             }
97         } catch (e: Exception) {
98             log.error("Failed to process on remote executor (${e.message})", e)
99             setNodeOutputErrors(ATTRIBUTE_EXEC_CMD_STATUS_ERROR, "Failed to process on remote executor (${e.message})")
100         }
101     }
102
103
104     override suspend fun recoverNB(runtimeException: RuntimeException, executionRequest: ExecutionServiceInput) {
105         val message = "Error in ComponentRemoteAnsibleExecutor : ${runtimeException.message}"
106         log.error(message, runtimeException)
107         setNodeOutputErrors(ATTRIBUTE_EXEC_CMD_STATUS_ERROR, message)
108     }
109
110     /** Creates a TokenAuthRestClientService, since this executor expect type property to be "token-auth" and the
111      * token to be an OAuth token (access_token response field) generated via the AWX /api/o/token rest endpoint
112      * The token field is of the form "Bearer access_token_from_response", for example :
113      *  "blueprintsprocessor.restclient.awx.type=token-auth"
114      *  "blueprintsprocessor.restclient.awx.url=http://awx-endpoint"
115      *  "blueprintsprocessor.restclient.awx.token=Bearer J9gEtMDzxcqw25574fioY9VAhLDIs1"
116      *
117      * Also supports json endpoint definition via DSL entry, e.g.:
118      *     "ansible-remote-endpoint": {
119      *        "type": "token-auth",
120      *        "url": "http://awx-endpoint",
121      *        "token": "Bearer J9gEtMDzxcqw25574fioY9VAhLDIs1"
122      *     }
123      */
124     private fun getAWXRestClient(): BlueprintWebClientService {
125
126         val endpointSelector = getOperationInput(INPUT_ENDPOINT_SELECTOR)
127
128         try {
129             return blueprintRestLibPropertyService.blueprintWebClientService(endpointSelector)
130         } catch (e: NoSuchElementException) {
131             throw IllegalArgumentException("No value provided for input selector $endpointSelector", e)
132         }
133     }
134
135     /**
136      * Finds the job template ID based on the job template name provided in the request
137      */
138     private fun lookupJobTemplateIDByName(awxClient: BlueprintWebClientService, job_template_name: String?): String {
139         val encodedJTName = URI(null, null,
140                 "/api/v2/job_templates/${job_template_name}/",
141                 null, null).rawPath
142
143         // Get Job Template details by name
144         var response = awxClient.exchangeResource(GET, encodedJTName, "")
145         val jtDetails: JsonNode = mapper.readTree(response.body)
146         return jtDetails.at("/id").asText()
147     }
148
149     /**
150      * Performs the job template execution on AWX, ie. prepare arguments as per job template
151      * requirements (ask fields) and provided overriding values. Then it launches the run, and monitors
152      * its execution. Finally, it retrieves the job results via the stdout api.
153      * The status and output attributes are populated in the process.
154      */
155     private fun runJobTemplateOnAWX(awxClient: BlueprintWebClientService, job_template_name: String?, jtId: String) {
156         setNodeOutputProperties("preparing".asJsonPrimitive(), "".asJsonPrimitive())
157
158         // Get Job Template requirements
159         var response = awxClient.exchangeResource(GET, "/api/v2/job_templates/${jtId}/launch/", "")
160         // FIXME: handle non-successful SC
161         val jtLaunchReqs: JsonNode = mapper.readTree(response.body)
162         val payload = prepareLaunchPayload(awxClient, jtLaunchReqs)
163         log.info("Running job with $payload, for requestId $processId.")
164
165         // Launch the job for the targeted template
166         var jtLaunched: JsonNode = JacksonUtils.objectMapper.createObjectNode()
167         response = awxClient.exchangeResource(POST, "/api/v2/job_templates/${jtId}/launch/", payload)
168         if (response.status in HTTP_SUCCESS) {
169             jtLaunched = mapper.readTree(response.body)
170             val fieldsIgnored: JsonNode = jtLaunched.at("/ignored_fields")
171             if (fieldsIgnored.rootFieldsToMap().isNotEmpty()) {
172                 log.warn("Ignored fields : $fieldsIgnored, for requestId $processId.")
173             }
174         }
175
176         if (response.status in HTTP_SUCCESS) {
177             val jobId: String = jtLaunched.at("/id").asText()
178
179             // Poll current job status while job is not executed
180             var jobStatus = "unknown"
181             var jobEndTime = "null"
182             while (jobEndTime == "null") {
183                 response = awxClient.exchangeResource(GET, "/api/v2/jobs/${jobId}/", "")
184                 val jobLaunched: JsonNode = mapper.readTree(response.body)
185                 jobStatus = jobLaunched.at("/status").asText()
186                 jobEndTime = jobLaunched.at("/finished").asText()
187                 Thread.sleep(checkDelay)
188             }
189
190             log.info("Execution of job template $job_template_name in job #$jobId finished with status ($jobStatus) for requestId $processId")
191
192             // Get job execution results (stdout)
193             val plainTextHeaders = mutableMapOf<String, String>()
194             plainTextHeaders["Content-Type"] = "text/plain ;utf-8"
195             response = awxClient.exchangeResource(GET, "/api/v2/jobs/${jobId}/stdout/?format=txt", "", plainTextHeaders)
196
197             setNodeOutputProperties(jobStatus.asJsonPrimitive(), response.body.asJsonPrimitive())
198         } else {
199             // The job template requirements were not fulfilled with the values passed in. The message below will
200             // provide more information via the response, like the ignored_fields, or variables_needed_to_start,
201             // or resources_needed_to_start, in order to help user pinpoint the problems with the request.
202             val message = "Execution of job template $job_template_name could not be started for requestId $processId." +
203                     " (Response: ${response.body}) "
204             log.error(message)
205             setNodeOutputErrors(ATTRIBUTE_EXEC_CMD_STATUS_ERROR, message)
206         }
207     }
208
209     /**
210      * Prepares the JSON payload expected by the job template api,
211      * by applying the overrides that were provided
212      * and allowed by the template definition flags in jtLaunchReqs
213      */
214     private fun prepareLaunchPayload(awxClient: BlueprintWebClientService, jtLaunchReqs: JsonNode): String {
215         val payload = JacksonUtils.objectMapper.createObjectNode()
216
217         // Parameter defaults
218         val limitProp = getOptionalOperationInput(INPUT_LIMIT_TO_HOST)
219         val tagsProp = getOptionalOperationInput(INPUT_TAGS)
220         val skipTagsProp = getOptionalOperationInput(INPUT_SKIP_TAGS)
221         val inventoryProp = getOptionalOperationInput(INPUT_INVENTORY)
222         val extraArgs = getOperationInput(INPUT_EXTRA_VARS)
223
224         val askLimitOnLaunch = jtLaunchReqs.at("/ask_limit_on_launch").asBoolean()
225         if (askLimitOnLaunch && limitProp.isNotNull()) {
226             payload.set(INPUT_LIMIT_TO_HOST, limitProp)
227         }
228         val askTagsOnLaunch = jtLaunchReqs.at("/ask_tags_on_launch").asBoolean()
229         if (askTagsOnLaunch && tagsProp.isNotNull()) {
230             payload.set(INPUT_TAGS, tagsProp)
231         }
232         if (askTagsOnLaunch && skipTagsProp.isNotNull()) {
233             payload.set("skip_tags", skipTagsProp)
234         }
235         val askInventoryOnLaunch = jtLaunchReqs.at("/ask_inventory_on_launch").asBoolean()
236         if (askInventoryOnLaunch && inventoryProp.isNotNull()) {
237             var inventoryKeyId = if (inventoryProp is TextNode) {
238                 resolveInventoryIdByName(awxClient, inventoryProp!!.textValue())?.asJsonPrimitive()
239             } else {
240                 inventoryProp
241             }
242             payload.set(INPUT_INVENTORY, inventoryKeyId)
243         }
244         val askVariablesOnLaunch = jtLaunchReqs.at("/ask_variables_on_launch").asBoolean()
245         if (askVariablesOnLaunch && extraArgs != null) {
246             payload.set("extra_vars", extraArgs)
247         }
248         return payload.asJsonString(false)
249     }
250
251     private fun resolveInventoryIdByName(awxClient: BlueprintWebClientService, inventoryProp: String): Int? {
252         var invId: Int? = null
253
254         // Get Inventory by name
255         val encoded = URLEncoder.encode(inventoryProp)
256         val response = awxClient.exchangeResource(GET, "/api/v2/inventories/?name=$encoded", "")
257         if (response.status in HTTP_SUCCESS) {
258             // Extract the inventory ID from response
259             val invDetails = mapper.readTree(response.body)
260             val nbInvFound = invDetails.at("/count").asInt()
261             if (nbInvFound == 1) {
262                 invId = invDetails["results"][0]["id"].asInt()
263                 log.info("Resolved inventory $inventoryProp to ID #: $invId")
264             }
265         }
266
267         if (invId == null) {
268             val message = "Could not resolve inventory $inventoryProp by name..."
269             log.error(message)
270             throw IllegalArgumentException(message)
271         }
272
273         return invId
274     }
275
276     /**
277      * Utility function to set the output properties of the executor node
278      */
279     private fun setNodeOutputProperties(status: JsonNode, message: JsonNode) {
280         setAttribute(ATTRIBUTE_EXEC_CMD_STATUS, status)
281         log.info("Executor status: $status")
282         setAttribute(ATTRIBUTE_EXEC_CMD_LOG, message)
283         log.info("Executor message: $message")
284     }
285
286     /**
287      * Utility function to set the output properties and errors of the executor node, in cas of errors
288      */
289     private fun setNodeOutputErrors(status: String, message: String) {
290         setAttribute(ATTRIBUTE_EXEC_CMD_STATUS, status.asJsonPrimitive())
291         setAttribute(ATTRIBUTE_EXEC_CMD_LOG, message.asJsonPrimitive())
292
293         addError(status, ATTRIBUTE_EXEC_CMD_LOG, message)
294     }
295 }