diff --git a/vars/ansible.groovy b/vars/ansible.groovy new file mode 100644 index 0000000..2f96503 --- /dev/null +++ b/vars/ansible.groovy @@ -0,0 +1,165 @@ +// Ansible operations for Jenkins pipelines (Docker-based) + +// Get the Docker image to use for ansible commands +def _getImage() { + return 'rancher-infra-tools:latest' +} + +// Run an Ansible playbook +// [ dir: string, inventory: string, playbook: string, extraVars?: Map, tags?: string, limit?: string, verbose?: bool ] +def runPlaybook(Map config) { + if (!(config.dir && config.inventory && config.playbook)) { + error 'Directory, inventory, and playbook must be provided.' + } + + steps.echo "Running Ansible playbook: ${config.playbook}" + + def ansibleArgs = [ + "ansible-playbook", + "-i ${config.inventory}", + config.playbook + ] + + // Add extra variables if provided + if (config.extraVars) { + def extraVarsStr = config.extraVars.collect { k, v -> + "${k}=${v}" + }.join(' ') + ansibleArgs.add("--extra-vars \"${extraVarsStr}\"") + } + + // Add tags if provided + if (config.tags) { + ansibleArgs.add("--tags ${config.tags}") + } + + // Add limit if provided + if (config.limit) { + ansibleArgs.add("--limit ${config.limit}") + } + + // Add verbosity if requested + if (config.verbose) { + ansibleArgs.add("-vvv") + } + + // Properly escape ansible arguments to prevent command injection + def escapedAnsibleArgs = ansibleArgs.collect { arg -> + // Escape any double quotes and backslashes in the argument + arg.replace('\\', '\\\\').replace('"', '\\"') + } + + def ansibleCommand = "cd ${config.dir} && ${escapedAnsibleArgs.join(' ')}" + + def workspace = steps.pwd() + def globalConfig = new config() + def platform = globalConfig.getDockerPlatform() + def dockerCommand = "docker run --rm --platform ${platform} -v ${workspace}:/workspace -v ${workspace}/.ssh:/root/.ssh:ro -w /workspace ${_getImage()} sh -c \"${ansibleCommand}\"" + + def status = steps.sh(script: dockerCommand, returnStatus: true) + + if (status != 0) { + error "Ansible playbook execution failed with status ${status}" + } + + steps.echo "Ansible playbook completed successfully" +} + +// Write variables to Ansible inventory group_vars +// [ path: string, content: string ] +def writeInventoryVars(Map config) { + if (!(config.path && config.content)) { + error 'Path and content must be provided.' + } + + steps.echo "Writing Ansible variables to ${config.path}" + + try { + steps.writeFile file: config.path, text: config.content + steps.echo "Ansible variables written successfully" + } catch (e) { + error "Failed to write Ansible variables: ${e.message}" + } +} + +// Validate Ansible inventory +// [ dir: string, inventory: string ] +def validateInventory(Map config) { + if (!(config.dir && config.inventory)) { + error 'Directory and inventory must be provided.' + } + + steps.echo "Validating Ansible inventory: ${config.inventory}" + + // Properly escape inventory path to prevent command injection + def escapedDir = config.dir.replace('\\', '\\\\').replace('"', '\\"') + def escapedInventory = config.inventory.replace('\\', '\\\\').replace('"', '\\"') + + def validateCommand = "cd ${escapedDir} && ansible-inventory -i ${escapedInventory} --list > /dev/null" + + def status = steps.sh(script: validateCommand, returnStatus: true) + + if (status != 0) { + steps.echo "Warning: Ansible inventory validation failed" + return false + } + + steps.echo "Ansible inventory validated successfully" + return true +} + +// Run ansible-playbook in a container +// [ container: string, dir: string, inventory: string, playbook: string, extraVars?: Map, tags?: string, envVars?: Map ] +def runPlaybookInContainer(Map config) { + if (!(config.container && config.dir && config.inventory && config.playbook)) { + error 'Container, directory, inventory, and playbook must be provided.' + } + + steps.echo "Running Ansible playbook in container: ${config.container}" + + def ansibleArgs = [ + "ansible-playbook", + "-i ${config.inventory}", + config.playbook + ] + + if (config.extraVars) { + def extraVarsStr = config.extraVars.collect { k, v -> + "${k}=${v}" + }.join(' ') + ansibleArgs.add("--extra-vars \"${extraVarsStr}\"") + } + + if (config.tags) { + ansibleArgs.add("--tags ${config.tags}") + } + + def envArgs = [] + if (config.envVars) { + envArgs = config.envVars.collect { k, v -> "-e ${k}=${v}" } + } + + // Properly escape ansible arguments to prevent command injection + def escapedAnsibleArgs = ansibleArgs.collect { arg -> + // Escape any double quotes and backslashes in the argument + arg.replace('\\', '\\\\').replace('"', '\\"') + } + + def dockerCommand = [ + "docker run --rm", + envArgs.join(' '), + "-v ${config.dir}:/workspace", + "-w /workspace", + config.container, + "-c", + "\"${escapedAnsibleArgs.join(' ')}\"" + ].join(' ') + + def status = steps.sh(script: dockerCommand, returnStatus: true) + + if (status != 0) { + error "Ansible playbook in container failed with status ${status}" + } + + steps.echo "Ansible playbook in container completed successfully" +} diff --git a/vars/config.groovy b/vars/config.groovy new file mode 100644 index 0000000..b3f5280 --- /dev/null +++ b/vars/config.groovy @@ -0,0 +1,136 @@ +// Centralized configuration management for Jenkins shared library + +// Default configuration values +def getDefaultConfig() { + return [ + // Docker configuration + docker: [ + images: [ + infraTools: env.RANCHER_INFRA_TOOLS_IMAGE ?: 'rancher-infra-tools:latest' + ], + platform: env.DOCKER_PLATFORM ?: 'linux/amd64', + defaultEnvFile: env.DOCKER_DEFAULT_ENV_FILE ?: '.env' + ], + + // Testing configuration + testing: [ + defaultTags: env.TEST_DEFAULT_TAGS ?: 'validation', + defaultTimeout: env.TEST_DEFAULT_TIMEOUT ?: '60m', + defaultResultsXML: env.TEST_DEFAULT_RESULTS_XML ?: 'results.xml', + defaultResultsJSON: env.TEST_DEFAULT_RESULTS_JSON ?: 'results.json' + ], + + // Naming conventions + naming: [ + containerSuffix: env.CONTAINER_SUFFIX ?: 'test', + imagePrefix: env.IMAGE_PREFIX ?: 'rancher-validation-' + ], + + // UI/Display configuration + ui: [ + colorMapName: env.COLOR_MAP_NAME ?: 'XTerm', + defaultFg: env.DEFAULT_FG_COLOR ? env.DEFAULT_FG_COLOR.toInteger() : 2, + defaultBg: env.DEFAULT_BG_COLOR ? env.DEFAULT_BG_COLOR.toInteger() : 1 + ], + + // Path configuration + paths: [ + defaultDir: env.DEFAULT_DIR ?: '.', + sshDir: env.SSH_DIR ?: '.ssh', + validationDir: env.VALIDATION_DIR ?: 'validation' + ] + ] +} + +// Get specific configuration section +def getConfig(String section) { + def config = getDefaultConfig() + return config[section] ?: [:] +} + +// Get specific configuration value with fallback +def getConfigValue(String section, String key, def defaultValue = null) { + def config = getDefaultConfig() + def sectionConfig = config[section] ?: [:] + return sectionConfig[key] ?: defaultValue +} + +// Merge user configuration with defaults +def mergeConfig(Map userConfig = [:]) { + def defaultConfig = getDefaultConfig() + + // Deep merge configuration + return deepMerge(defaultConfig, userConfig) +} + +// Deep merge two maps recursively +def deepMerge(Map target, Map source) { + source.each { key, value -> + if (value instanceof Map && target[key] instanceof Map) { + target[key] = deepMerge(target[key], value) + } else { + target[key] = value + } + } + return target +} + +// Validate configuration +def validateConfig(Map config) { + def errors = [] + + // Validate required Docker configuration + if (!config.docker?.images?.infraTools) { + errors.add("Docker infra tools image is required") + } + + if (!config.docker?.platform) { + errors.add("Docker platform is required") + } + + // Validate required testing configuration + if (!config.testing?.defaultTags) { + errors.add("Default test tags are required") + } + + if (!config.testing?.defaultTimeout) { + errors.add("Default test timeout is required") + } + + if (errors) { + error "Configuration validation failed: ${errors.join(', ')}" + } + + return true +} + +// Get Docker image name +def getDockerImage(String imageType = 'infraTools') { + def dockerConfig = getConfig('docker') + return dockerConfig.images[imageType] +} + +// Get Docker platform +def getDockerPlatform() { + return getConfigValue('docker', 'platform') +} + +// Get default test configuration +def getTestConfig() { + return getConfig('testing') +} + +// Get naming configuration +def getNamingConfig() { + return getConfig('naming') +} + +// Get UI configuration +def getUIConfig() { + return getConfig('ui') +} + +// Get path configuration +def getPathConfig() { + return getConfig('paths') +} \ No newline at end of file diff --git a/vars/container.groovy b/vars/container.groovy index baa2ccc..904b9e1 100644 --- a/vars/container.groovy +++ b/vars/container.groovy @@ -150,7 +150,9 @@ def _containerCommand(Map container) { } if ( !(container?.envFile) ) { - container.envFile = '.env' + def globalConfig = new config() + def dockerConfig = globalConfig.getConfig('docker') + container.envFile = dockerConfig.defaultEnvFile } args.addAll(['--env-file', container.envFile]) @@ -232,13 +234,17 @@ def _goTestCommand(Map test) { args.add("--packages=${test.packages}") if (!(test?.resultsXML)) { - test.resultsXML = 'results.xml' + def globalConfig = new config() + def testConfig = globalConfig.getConfig('testing') + test.resultsXML = testConfig.defaultResultsXML } args.addAll(['--junitfile', test.resultsXML]) if (!(test?.resultsJSON)) { - test.resultsJSON = 'results.json' + def globalConfig = new config() + def testConfig = globalConfig.getConfig('testing') + test.resultsJSON = testConfig.defaultResultsJSON } args.addAll(['--jsonfile', test.resultsJSON]) @@ -246,7 +252,9 @@ def _goTestCommand(Map test) { args.add('--') if (!(test?.tags)) { - test.tags = 'validation' + def globalConfig = new config() + def testConfig = globalConfig.getConfig('testing') + test.tags = testConfig.defaultTags } args.add("-tags=${test.tags}") @@ -254,7 +262,9 @@ def _goTestCommand(Map test) { args.add(test.cases) if (!(test?.timeout)) { - test.timeout = '60m' + def globalConfig = new config() + def testConfig = globalConfig.getConfig('testing') + test.timeout = testConfig.defaultTimeout } args.add("-timeout=${test.timeout}") diff --git a/vars/generate.groovy b/vars/generate.groovy index 6e9b2dc..a66921b 100644 --- a/vars/generate.groovy +++ b/vars/generate.groovy @@ -1,18 +1,8 @@ // suffix string def names(String suffix = 'test') { - def jobName = env.JOB_NAME - def buildNumber = env.BUILD_NUMBER - - if (jobName.contains('/')) { - def jobNames = jobName.split('/') - - jobName = jobNames[jobNames.size() - 1] - } - - def containerName = "${jobName}${buildNumber}_test" - def imageName = "rancher-validation-${jobName}${buildNumber}" - - return [container: containerName, image: imageName] + def naming = new naming() + return naming.generateNames([suffix: suffix]) +} //Below enables name generation with Integer count param //requires docker's build.sh changes for name, right now it's not an ENV @@ -33,4 +23,3 @@ def names(String suffix = 'test') { // } // // return resourceNames -} diff --git a/vars/infrastructure.groovy b/vars/infrastructure.groovy new file mode 100644 index 0000000..b30a766 --- /dev/null +++ b/vars/infrastructure.groovy @@ -0,0 +1,186 @@ +// High-level infrastructure helpers for Jenkins pipelines + +// Write configuration file with variable substitutions +// [ path: string, content: string, substitutions?: Map ] +def writeConfig(Map config) { + if (!(config.path && config.content)) { + error 'Path and content must be provided.' + } + + steps.echo "Writing configuration to ${config.path}" + + def processedContent = config.content + + // Apply substitutions if provided + if (config.substitutions) { + config.substitutions.each { key, value -> + processedContent = processedContent.replace("\${${key}}", value.toString()) + } + } + + try { + // Ensure directory exists + def dirPath = config.path.substring(0, config.path.lastIndexOf('/')) + steps.sh "mkdir -p ${dirPath}" + + steps.writeFile file: config.path, text: processedContent + steps.echo "Configuration written successfully to ${config.path}" + } catch (e) { + error "Failed to write configuration: ${e.message}" + } +} + +// Decode and write SSH key from base64, and generate public key +// [ keyContent: string, keyName: string, dir?: string ] +def writeSshKey(Map config) { + if (!(config.keyContent && config.keyName)) { + error 'SSH key content and name must be provided.' + } + + def sshDir = config.dir ?: '.ssh' + + steps.echo "Writing SSH key: ${config.keyName}" + + try { + // Create SSH directory if it doesn't exist + steps.sh "mkdir -p ${sshDir}" + + // Decode base64 key content + def decoded = new String(config.keyContent.decodeBase64()) + + // Write private key file + def keyPath = "${sshDir}/${config.keyName}" + steps.writeFile file: keyPath, text: decoded + + // Set proper permissions for private key + steps.sh "chmod 600 ${keyPath}" + + // Generate public key from private key + // Strip extension (e.g., .pem) and add .pub + def keyBaseName = config.keyName.replaceAll(/\.[^.]+$/, '') + def pubKeyPath = "${sshDir}/${keyBaseName}.pub" + steps.sh "ssh-keygen -y -f ${keyPath} > ${pubKeyPath}" + steps.sh "chmod 644 ${pubKeyPath}" + + steps.echo "SSH key pair written successfully: ${keyPath} and ${pubKeyPath}" + return keyPath + } catch (e) { + error "Failed to write SSH key: ${e.message}" + } +} + +// Generate unique workspace name +// [ prefix?: string, suffix?: string, includeTimestamp?: bool ] +def generateWorkspaceName(Map config = [:]) { + def prefix = config.prefix ?: 'jenkins_workspace' + def buildNumber = env.BUILD_NUMBER ?: 'unknown' + + // Sanitize suffix for Terraform workspace naming (alphanumeric, hyphens, underscores only) + def sanitizedSuffix = '' + if (config.suffix) { + sanitizedSuffix = config.suffix.toString() + .replaceAll(/[^a-zA-Z0-9-_]/, '-') // Replace invalid chars with hyphens + .replaceAll(/-+/, '-') // Collapse multiple hyphens + .replaceAll(/^-|-$/, '') // Remove leading/trailing hyphens + if (sanitizedSuffix) { + sanitizedSuffix = "_${sanitizedSuffix}" + } + } + + if (config.includeTimestamp != false) { + def timestamp = new Date().format('yyyyMMddHHmmss') + return "${prefix}_${buildNumber}${sanitizedSuffix}_${timestamp}" + } + + return "${prefix}_${buildNumber}${sanitizedSuffix}" +} + +// Parse YAML-like content and apply environment variable substitutions +// [ content: string, envVars: Map ] +def parseAndSubstituteVars(Map config) { + if (!config.content) { + error 'Content must be provided.' + } + + def processedContent = config.content + + if (config.envVars) { + config.envVars.each { key, value -> + // Replace both ${VAR} and $VAR patterns + processedContent = processedContent.replaceAll(/\$\{${key}\}/, value.toString()) + processedContent = processedContent.replaceAll(/\$${key}(?![a-zA-Z0-9_])/, value.toString()) + } + } + + return processedContent +} + +// Create directory structure +// [ paths: List ] +def createDirectories(Map config) { + if (!config.paths) { + error 'Paths must be provided.' + } + + config.paths.each { path -> + steps.echo "Creating directory: ${path}" + steps.sh "mkdir -p ${path}" + } +} + +// Clean up workspace artifacts +// [ paths: List, force?: bool ] +def cleanupArtifacts(Map config) { + if (!config.paths) { + error 'Paths must be provided.' + } + + steps.echo 'Cleaning up artifacts' + + def forceFlag = config.force ? '-f' : '' + + config.paths.each { path -> + try { + steps.sh "rm -rf ${forceFlag} ${path}" + steps.echo "Removed: ${path}" + } catch (e) { + steps.echo "Warning: Could not remove ${path}: ${e.message}" + } + } +} + +// Archive workspace name for later use +// [ workspaceName: string, fileName?: string ] +def archiveWorkspaceName(Map config) { + if (!config.workspaceName) { + error 'Workspace name must be provided.' + } + + def fileName = config.fileName ?: 'workspace_name.txt' + + steps.echo "Archiving workspace name: ${config.workspaceName}" + + try { + steps.writeFile file: fileName, text: config.workspaceName + steps.archiveArtifacts artifacts: fileName, fingerprint: true + steps.echo "Workspace name archived to ${fileName}" + } catch (e) { + error "Failed to archive workspace name: ${e.message}" + } +} + +// Extract archived workspace name +// [ fileName?: string ] +def getArchivedWorkspaceName(Map config = [:]) { + def fileName = config.fileName ?: 'workspace_name.txt' + + steps.echo "Retrieving archived workspace name from ${fileName}" + + try { + def workspaceName = steps.readFile(fileName).trim() + steps.echo "Retrieved workspace name: ${workspaceName}" + return workspaceName + } catch (e) { + error "Failed to retrieve workspace name: ${e.message}" + } +} diff --git a/vars/naming.groovy b/vars/naming.groovy new file mode 100644 index 0000000..cf36fe7 --- /dev/null +++ b/vars/naming.groovy @@ -0,0 +1,181 @@ +// Naming convention utilities for Jenkins shared library + +// Generate container and image names based on job context +// [ suffix?: string, prefix?: string, includeBuildNumber?: bool ] +def generateNames(Map params = [:]) { + def config = new config() + def namingConfig = config.getNamingConfig() + + def suffix = params.suffix ?: namingConfig.containerSuffix + def prefix = params.prefix ?: namingConfig.imagePrefix + + def jobName = env.JOB_NAME ?: 'unknown' + def buildNumber = env.BUILD_NUMBER ?: '0' + + // Extract job name from full path if it contains '/' + if (jobName.contains('/')) { + def jobNames = jobName.split('/') + jobName = jobNames[jobNames.size() - 1] + } + + def containerName = "${jobName}${buildNumber}_${suffix}" + def imageName = "${prefix}${jobName}${buildNumber}" + + return [container: containerName, image: imageName] +} + +// Generate workspace name with optional timestamp +// [ prefix?: string, includeTimestamp?: bool ] +def generateWorkspaceName(Map params = [:]) { + def config = new config() + def namingConfig = config.getNamingConfig() + + def prefix = params.prefix ?: 'jenkins_workspace' + def includeTimestamp = params.includeTimestamp != false + def buildNumber = env.BUILD_NUMBER ?: 'unknown' + + if (includeTimestamp) { + def timestamp = new Date().format('yyyyMMddHHmmss') + return "${prefix}_${buildNumber}_${timestamp}" + } + + return "${prefix}_${buildNumber}" +} + +// Generate resource names with count +// [ suffix?: string, prefix?: string, count?: int, maxCount?: int ] +def generateMultipleNames(Map params = [:]) { + def config = new config() + def suffix = params.suffix ?: 'test' + def prefix = params.prefix ?: 'rancher-validation-' + def count = params.count ?: 1 + def maxCount = params.maxCount ?: 10 + + // Limit count to maxCount + if (count > maxCount) { + count = maxCount + } + + def jobName = env.JOB_NAME ?: 'unknown' + def buildNumber = env.BUILD_NUMBER ?: '0' + + // Extract job name from full path if it contains '/' + if (jobName.contains('/')) { + def jobNames = jobName.split('/') + jobName = jobNames[jobNames.size() - 1] + } + + def resourceNames = [] + + for (int i = 1; i <= count; i++) { + def containerName = "${jobName}-${buildNumber}-${suffix}-${i}" + def imageName = "${prefix}${jobName}-${buildNumber}-${i}" + + resourceNames << [containerName: containerName, imageName: imageName] + } + + return resourceNames +} + +// Generate SSH key file names +// [ keyType?: string, keyName?: string ] +def generateSshKeyNames(Map params = [:]) { + def keyType = params.keyType ?: 'pem' + def keyName = params.keyName ?: 'id_rsa' + + def privateKey = "${keyName}.${keyType}" + def publicKey = "${keyName}.pub" + + return [privateKey: privateKey, publicKey: publicKey] +} + +// Generate report file names +// [ reportType?: string, suffix?: string ] +def generateReportNames(Map params = [:]) { + def config = new config() + def reportType = params.reportType ?: 'results' + def suffix = params.suffix ?: '' + + def xmlFile = "${reportType}${suffix}.xml" + def jsonFile = "${reportType}${suffix}.json" + + return [xml: xmlFile, json: jsonFile] +} + +// Generate environment file name +// [ envName?: string, suffix?: string ] +def generateEnvFileName(Map params = [:]) { + def config = new config() + def dockerConfig = config.getConfig('docker') + + def envName = params.envName ?: 'env' + def suffix = params.suffix ?: '' + + def defaultEnvFile = dockerConfig.defaultEnvFile ?: '.env' + + if (suffix) { + return "${envName}${suffix}.env" + } + + return defaultEnvFile +} + +// Sanitize name for use in containers, files, etc. +// [ name: string, replacement?: string ] +def sanitizeName(Map params) { + if (!params.name) { + error 'Name must be provided for sanitization' + } + + def name = params.name + def replacement = params.replacement ?: '_' + + // Replace invalid characters with replacement + def sanitized = name.replaceAll(/[^a-zA-Z0-9._-]/, replacement) + + // Remove consecutive replacements + sanitized = sanitized.replaceAll(/${replacement}+/, replacement) + + // Remove leading/trailing replacements + sanitized = sanitized.replaceAll(/^${replacement}|${replacement}$/, '') + + // Ensure name is not empty + if (!sanitized) { + sanitized = 'resource' + } + + return sanitized +} + +// Validate name meets requirements +// [ name: string, maxLength?: int, minLength?: int ] +def validateName(Map params) { + if (!params.name) { + error 'Name must be provided for validation' + } + + def name = params.name + def maxLength = params.maxLength ?: 255 + def minLength = params.minLength ?: 1 + + def errors = [] + + if (name.length() < minLength) { + errors.add("Name must be at least ${minLength} characters long") + } + + if (name.length() > maxLength) { + errors.add("Name must be no more than ${maxLength} characters long") + } + + // Check for invalid characters + if (name =~ /[^a-zA-Z0-9._-]/) { + errors.add("Name contains invalid characters. Only letters, numbers, dots, hyphens, and underscores are allowed") + } + + if (errors) { + error "Name validation failed: ${errors.join(', ')}" + } + + return true +} \ No newline at end of file diff --git a/vars/property.groovy b/vars/property.groovy index 6505ebb..511d48a 100644 --- a/vars/property.groovy +++ b/vars/property.groovy @@ -9,7 +9,9 @@ def useWithProperties(List credentials, Closure body) { // body {} def useWithColor(Closure body) { - wrap([$class: 'AnsiColorBuildWrapper', 'colorMapName': 'XTerm', 'defaultFg': 2, 'defaultBg':1]) { + def globalConfig = new config() + def uiConfig = globalConfig.getUIConfig() + wrap([$class: 'AnsiColorBuildWrapper', 'colorMapName': uiConfig.colorMapName, 'defaultFg': uiConfig.defaultFg, 'defaultBg': uiConfig.defaultBg]) { try { body() } catch (e) { @@ -34,8 +36,13 @@ def useWithFolderProperties(Closure body) { withFolderProperties { paramsMap = [] params.each { - if (it.value && it.value.trim() != '') { - paramsMap << "$it.key=$it.value" + // Coerce non-string values (e.g., booleans) to String before trim to avoid MissingMethodException + def v = it.value + if (v != null) { + def s = v.toString() + if (s.trim() != '') { + paramsMap << "$it.key=$s" + } } } try { diff --git a/vars/tofu.groovy b/vars/tofu.groovy new file mode 100644 index 0000000..92ff043 --- /dev/null +++ b/vars/tofu.groovy @@ -0,0 +1,227 @@ +// Tofu/Terraform operations for Jenkins pipelines (Docker-based) + +// Get the Docker image to use for tofu commands +def _getImage() { + def config = new config() + return config.getDockerImage('infraTools') +} + +// Run a command in the tofu/ansible container +def _runInContainer(String command, Map envVars = [:], boolean returnStdout = false) { + def workspace = steps.pwd() + + // Build environment variable arguments + // AWS credentials will be automatically inherited from withCredentials block + def envArgs = "-e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY" + if (envVars) { + envArgs += " " + envVars.collect { k, v -> "-e ${k}='${v}'" }.join(' ') + } + + def config = new config() + def platform = config.getDockerPlatform() + def dockerCommand = "docker run --rm --platform ${platform} ${envArgs} -v ${workspace}:/workspace -w /workspace ${_getImage()} sh -c \"${command}\"" + + if (returnStdout) { + return steps.sh(script: dockerCommand, returnStdout: true).trim() + } else { + return steps.sh(script: dockerCommand, returnStatus: true) + } +} + +// Initialize Tofu backend with S3 configuration using init-backend.sh script +// [ dir: string, bucket: string, key: string, region: string, dynamodbTable?: string ] +def initBackend(Map config) { + if (!(config.dir && config.backendInitScript && config.bucket && config.key && config.region)) { + error 'Directory, BackendScript, bucket, key, and region must be provided for backend initialization.' + } + + steps.echo "Initializing Tofu backend in ${config.dir}" + + // Build the init-backend.sh command + def scriptArgs = [ + "s3", + "--bucket ${config.bucket}", + "--key ${config.key}", + "--region ${config.region}" + ] + + if (config.dynamodbTable) { + scriptArgs.add("--dynamodb-table ${config.dynamodbTable}") + } + + // Run the init-backend.sh script which generates backend.tf and runs tofu init + def initCommand = "cd ${config.dir} && ${config.backendInitScript} ${scriptArgs.join(' ')}" + + def envVars = [:] + // AWS credentials should be passed from the calling context + // They are available through withCredentials in the Jenkinsfile + + def status = _runInContainer(initCommand, envVars) + + if (status != 0) { + error "Tofu backend initialization failed with status ${status}" + } + + steps.echo "Tofu backend initialized successfully" +} + +// Create and select a new workspace +// [ dir: string, name: string ] +def createWorkspace(Map config) { + if (!(config.dir && config.name)) { + error 'Directory and workspace name must be provided.' + } + + steps.echo "Creating and selecting workspace: ${config.name}" + + def command = "tofu -chdir=${config.dir} workspace new ${config.name}" + def envVars = [:] + + def status = _runInContainer(command, envVars) + + if (status != 0) { + error "Failed to create workspace ${config.name}" + } + + steps.echo "Workspace ${config.name} created and selected" + return config.name +} + +// Select an existing workspace +// [ dir: string, name: string ] +def selectWorkspace(Map config) { + if (!(config.dir && config.name)) { + error 'Directory and workspace name must be provided.' + } + + steps.echo "Selecting workspace: ${config.name}" + + def command = "tofu -chdir=${config.dir} workspace select ${config.name}" + def envVars = [:] + + def status = _runInContainer(command, envVars) + + if (status != 0) { + error "Failed to select workspace ${config.name}" + } + + steps.echo "Workspace ${config.name} selected" +} + +// Run tofu apply +// [ dir: string, varFile?: string, autoApprove?: bool ] +def apply(Map config) { + if (!config.dir) { + error 'Directory must be provided for apply operation.' + } + + steps.echo "Running tofu apply in ${config.dir}" + + def applyArgs = [] + + if (config.varFile) { + applyArgs.add("-var-file=${config.varFile}") + } + + if (config.autoApprove != false) { + applyArgs.add("-auto-approve") + } + + def applyCommand = "tofu -chdir=${config.dir} apply ${applyArgs.join(' ')}" + + def envVars = [:] + + def status = _runInContainer(applyCommand, envVars) + + if (status != 0) { + error "Tofu apply failed with status ${status}" + } + + steps.echo "Tofu apply completed successfully" +} + +// Run tofu destroy +// [ dir: string, varFile?: string, autoApprove?: bool ] +def destroy(Map config) { + if (!config.dir) { + error 'Directory must be provided for destroy operation.' + } + + steps.echo "Running tofu destroy in ${config.dir}" + + def destroyArgs = [] + + if (config.varFile) { + destroyArgs.add("-var-file=${config.varFile}") + } + + if (config.autoApprove != false) { + destroyArgs.add("-auto-approve") + } + + def destroyCommand = "tofu -chdir=${config.dir} destroy ${destroyArgs.join(' ')}" + + def envVars = [:] + + def status = _runInContainer(destroyCommand, envVars) + + if (status != 0) { + error "Tofu destroy failed with status ${status}" + } + + steps.echo "Tofu destroy completed successfully" +} + +// Delete a workspace +// [ dir: string, name: string ] +def deleteWorkspace(Map config) { + if (!(config.dir && config.name)) { + error 'Directory and workspace name must be provided.' + } + + steps.echo "Deleting workspace: ${config.name}" + + def envVars = [:] + + // First, select default workspace + _runInContainer("tofu -chdir=${config.dir} workspace select default", envVars) + + // Then delete the target workspace + def status = _runInContainer("tofu -chdir=${config.dir} workspace delete ${config.name}", envVars) + + if (status != 0) { + steps.echo "Warning: Failed to delete workspace ${config.name}" + } else { + steps.echo "Workspace ${config.name} deleted" + } +} + +// Get tofu outputs as a map +// [ dir: string, output?: string ] +def getOutputs(Map config) { + if (!config.dir) { + error 'Directory must be provided to get outputs.' + } + + steps.echo "Retrieving Tofu outputs from ${config.dir}" + + def outputCommand = config.output + ? "tofu -chdir=${config.dir} output -raw ${config.output}" + : "tofu -chdir=${config.dir} output -json" + + def envVars = [:] + + def outputJson = _runInContainer(outputCommand, envVars, true) + + if (config.output) { + return outputJson + } + + try { + def outputs = readJSON(text: outputJson) + return outputs + } catch (e) { + steps.echo "Warning: Could not parse outputs as JSON" + return outputJson + } +}