import groovy.io.FileType; import org.yaml.snakeyaml.Yaml import org.codehaus.groovy.control.CompilerConfiguration import com.sap.piper.DefaultValueCache import java.util.regex.Matcher // // Collects helper functions for rendering the docu // class TemplateHelper { static createParametersTable(Map parameters) { def t = '' t += '| name | mandatory | default | possible values |\n' t += '|------|-----------|---------|-----------------|\n' parameters.keySet().toSorted().each { def props = parameters.get(it) t += "| `${it}` | ${props.required ? 'yes' : 'no'} | ${(props.defaultValue ? '`' + props.defaultValue + '`' : '') } | ${props.value ?: ''} |\n" } t } static createParameterDescriptionSection(Map parameters) { def t = '' parameters.keySet().toSorted().each { def props = parameters.get(it) t += "* `${it}` - ${props.docu ?: ''}\n" } t } static createStepConfigurationSection(Map parameters) { def t = '' t += '| parameter | general | step | stage |\n' t += '|-----------|---------|------|-------|\n' parameters.keySet().toSorted().each { def props = parameters.get(it) t += "| `${it}` | ${props.GENERAL_CONFIG ? 'X' : ''} | ${props.STEP_CONFIG ? 'X' : ''} | ${props.PARAMS ? 'X' : ''} |\n" } t } } // // Collects generic helper functions // class Helper { static getConfigHelper(classLoader, roots) { def compilerConfig = new CompilerConfiguration() compilerConfig.setClasspathList( roots ) new GroovyClassLoader(classLoader, compilerConfig, true) .parseClass(new File('src/com/sap/piper/ConfigurationHelper.groovy')) .newInstance() } static getPrepareDefaultValuesStep(def gse) { def prepareDefaultValuesStep = gse.createScript('prepareDefaultValues.groovy', new Binding()) prepareDefaultValuesStep.metaClass.handlePipelineStepErrors { m, c -> c() } prepareDefaultValuesStep.metaClass.libraryResource { f -> new File("resources/${f}").text } prepareDefaultValuesStep.metaClass.readYaml { m -> new Yaml().load(m.text) } prepareDefaultValuesStep } static getDummyScript(def prepareDefaultValuesStep, def stepName) { def _prepareDefaultValuesStep = prepareDefaultValuesStep def _stepName = stepName return new Script() { def STEP_NAME = _stepName def prepareDefaultValues() { _prepareDefaultValuesStep() } def run() { throw new UnsupportedOperationException() } } } private static normalize(Set p) { def normalized = [] as Set def interim = [:] p.each { def parts = it.split('/') as List _normalize(parts, interim) } interim.each { k, v -> flatten (normalized, k, v) } normalized } private static void _normalize(List parts, Map interim) { if( parts.size >= 1) { if( ! interim[parts.head()]) interim[parts.head()] = [:] _normalize(parts.tail(), interim[parts.head()]) } } private static flatten(Set flat, def key, Map interim) { if( ! interim ) flat << (key as String) interim.each { k, v -> def _key = "${key}/${k}" if( v && v.size() > 0 ) flatten(flat, _key, v) else { flat << (_key as String) } } } static void scanDocu(File f, Map step) { boolean docu = false, value = false, docuEnd = false def docuLines = [], valueLines = [] f.eachLine { line -> if(docuEnd) { docuEnd = false if(isHeader(line)) { def _docu = [] docuLines.each { _docu << it } step.description = _docu*.trim().join('\n') } else { def param = retrieveParameterName(line) if(!param) { throw new RuntimeException('Cannot retrieve parameter for a comment') } if(step.parameters[param].docu || step.parameters[param].value) System.err << "[WARNING] There is already some documentation for parameter '${param}. Is this parameter documented twice?'\n" def _docu = [], _value = [] docuLines.each { _docu << it } valueLines.each { _value << it} step.parameters[param].docu = _docu*.trim().join(' ').trim() step.parameters[param].value = _value*.trim().join(' ').trim() } docuLines.clear() valueLines.clear() } if( line.trim() ==~ /^\/\*\*/ ) { docu = true } if(docu) { def _line = line _line = _line.replaceAll('^\\s*', '') // leading white spaces if(_line.startsWith('/**')) _line = _line.replaceAll('^\\/\\*\\*', '') // start comment if(_line.startsWith('*/')) _line = _line.replaceAll('^\\*/', '') // end comment if(_line.startsWith('*')) _line = _line.replaceAll('^\\*', '') // continue comment if(_line ==~ /.*@possibleValues.*/) { value = true } if(value) { if(_line) { _line = (_line =~ /.*@possibleValues\s*?(.*)/)[0][1] valueLines << _line } } else { docuLines << _line.trim() } } if(docu && line.trim() ==~ /^\*\//) { docu = false value = false docuEnd = true } } } private static isHeader(line) { Matcher headerMatcher = (line =~ /(def|void)\s*call\s*\(/ ) return headerMatcher.size() == 1 && headerMatcher[0].size() == 2 } private static retrieveParameterName(line) { Matcher m = (line =~ /.*'(.*)'.*/) if(m.size() == 1 && m[0].size() == 2) return m[0][1] return null } static getScopedParameters(def script) { def params = [:] params.put('STEP_CONFIG', script.STEP_CONFIG_KEYS ?: []) params.put('GENERAL_CONFIG', script.GENERAL_CONFIG_KEYS ?: [] ) params.put('PARAMS', script.PARAMETER_KEYS ?: [] ) return params } static getRequiredParameters(File f) { def params = [] as Set f.eachLine { line -> if( line ==~ /.*withMandatoryProperty.*/ ) { def param = (line =~ /.*withMandatoryProperty\('(.*)'/)[0][1] params << param } } return params } static getValue(Map config, def pPath) { def p =config[pPath.head()] if(pPath.size() == 1) return p // there is no tail if(p in Map) getValue(p, pPath.tail()) else return p } } roots = [ 'vars', 'src', ] stepsDir = null outDir = null stepsDocuDir = null steps = [] // // assign parameters if(args.length >= 1) stepsDir = new File(args[0]) stepsDir = stepsDir ?: new File('vars') if(args.length >= 2) outDir = new File(args[1]) outDir = outDir ?: new File('out') if(args.length >= 3) stepsDocuDir = new File(args[2]) stepsDocuDir = stepsDocuDir ?: new File('documentation/docs/steps') if(args.length >= 4) steps << args[3] // assign parameters // // // sanity checks if( ! outDir.exists() ) { if(! outDir.mkdirs()) { System.err << "Cannot create output direcrory '${outDir}'.\n" System.exit(1) } } if( !stepsDocuDir.exists() ) { System.err << "Steps docu dir '${stepsDocuDir}' does not exist.\n" System.exit(1) } if( !stepsDir.exists() ) { System.err << "Steps dir '${stepsDir}' does not exist.\n" System.exit(1) } // sanity checks // // // find all the steps we have to document (if no step has been provided from outside) if( ! steps) { stepsDir.traverse(type: FileType.FILES, maxDepth: 0) { if(it.getName().endsWith('.groovy')) steps << (it =~ /vars\/(.*)\.groovy/)[0][1] } } else { System.err << "[INFO] Generating docu only for step ${steps.size > 1 ? 's' : ''} ${steps}.\n" } def gse = new GroovyScriptEngine( [ stepsDir.getName() ] as String[] , getClass().getClassLoader() ) def prepareDefaultValuesStep = Helper.getPrepareDefaultValuesStep(gse) boolean exceptionCaught = false def stepDescriptors = [:] for (step in steps) { try { stepDescriptors."${step}" = handleStep(step, prepareDefaultValuesStep, gse) } catch(Exception e) { exceptionCaught = true System.err << "${e.getClass().getName()} caught while handling step '${step}': ${e.getMessage()}.\n" } } for(step in stepDescriptors) { try { renderStep(step.key, step.value) System.err << "[INFO] Step '${step.key}' has been rendered.\n" } catch(Exception e) { exceptionCaught = true System.err << "${e.getClass().getName()} caught while rendering step '${step}': ${e.getMessage()}.\n" System.exit(1) } } if(exceptionCaught) { System.err << "[ERROR] Exception caught during generating documentation. Check earlier log for details.\n" System.exit(1) } System.err << "[INFO] done.\n" void renderStep(stepName, stepProperties) { File theStepDocuInput = new File(stepsDocuDir, "${stepName}.md") File theGeneratedStepDocu = new File(outDir, "${stepName}.md") if(!theStepDocuInput.exists()) { System.err << "[WARNING] step docu input file for step '${stepName}' is missing.\n" return } def text = theStepDocuInput.text if(stepProperties.description) { text = text.replace('__STEP_DESCRIPTION__', stepProperties.description) } if(stepProperties.parameters) { text = text.replace('__PARAMETER_TABLE__', TemplateHelper.createParametersTable(stepProperties.parameters)) text = text.replace('__PARAMETER_DESCRIPTION__', TemplateHelper.createParameterDescriptionSection(stepProperties.parameters)) text = text.replace('__STEP_CONFIGURATION_SECTION__', TemplateHelper.createStepConfigurationSection(stepProperties.parameters)) } theGeneratedStepDocu.withWriter { w -> w.write text } } def handleStep(stepName, prepareDefaultValuesStep, gse) { File theStep = new File(stepsDir, "${stepName}.groovy") File theStepDocuInput = new File(stepsDocuDir, "${stepName}.md") if(!theStepDocuInput.exists()) { System.err << "[WARNING] step docu input file for step '${stepName}' is missing.\n" return } System.err << "[INFO] Handling step '${stepName}'.\n" def defaultConfig = Helper.getConfigHelper(getClass().getClassLoader(), roots).loadStepDefaults(Helper.getDummyScript(prepareDefaultValuesStep, stepName)).use() def params = [] as Set // // scopedParameters is a map containing the scope as key and the parameters // defined with that scope as a set of strings. def scopedParameters try { scopedParameters = Helper.getScopedParameters(gse.createScript( "${stepName}.groovy", new Binding() )) scopedParameters.each { k, v -> params.addAll(v) } } catch(Exception e) { System.err << "[ERROR] Step '${stepName}' violates naming convention for scoped parameters: ${e}.\n" throw e } def requiredParameters = Helper.getRequiredParameters(theStep) params.addAll(requiredParameters) def step = [parameters:[:]] // // START special handling for 'script' parameter // ... would be better if there is no special handling required ... step.parameters['script'] = [ docu: 'The common script environment of the Jenkinsfile running. ' + 'Typically the reference to the script calling the pipeline ' + 'step is provided with the this parameter, as in script: this. ' + 'This allows the function to access the ' + 'commonPipelineEnvironment for retrieving, for example, configuration parameters.', required: true, GENERAL_CONFIG: 'false', STEP_CONFIG: 'false', PARAMS: 'true' ] // END special handling for 'script' parameter Helper.normalize(params).toSorted().each { it -> def parameterProperties = [ defaultValue: Helper.getValue(defaultConfig, it.split('/')), required: requiredParameters.contains((it as String)) ] step.parameters.put(it, parameterProperties) // The scope is only defined for the first level of a hierarchical configuration. // If the first part is found, all nested parameters are allowed with that scope. def firstPart = it.split('/').head() scopedParameters.each { key, val -> parameterProperties.put(key, val.contains(firstPart)) } } Helper.scanDocu(theStep, step) step }