// cdHookRunner — standalone Jenkins job that runs turbo-turtle deploy-phase // CI/CD hooks (pre_deploy / post_deploy) in an isolated agent pod. // // turbo-turtle triggers this parameterized job during the CD workflow (see // TriggerCdHookPipelineActivity). It checks out the target repo, reads the // deploy-phase hooks declared under `environment..hooks.` in // the per-app deployment file (deployments/.yaml), executes them via // the shared runHooks execution core, and POSTs an aggregate completion callback // to /api/v1/cd/hook/callback which signals the waiting workflow. A blocking hook // failure makes success=false so the workflow can fail the deploy; advisory // (blocking:false) failures keep success=true. // // Required parameters (Jenkins job params, read from params/env): // REPO_URL, TT_REPO, APP_NAME, BRANCH, COMMIT, TT_ENV, HOOK_PHASE, // IMAGE_TAG, TT_IS_HOTFIX, TT_PR_NUMBER, TT_WORKFLOW_ID, TT_RUN_ID def call(Map jobParams = [:]) { String repoUrl = param('REPO_URL', jobParams) String repoName = param('TT_REPO', jobParams) String appName = param('APP_NAME', jobParams) String branch = param('BRANCH', jobParams) String commit = param('COMMIT', jobParams) String ttEnv = param('TT_ENV', jobParams) String phase = param('HOOK_PHASE', jobParams) String workflowID = param('TT_WORKFLOW_ID', jobParams) String runID = param('TT_RUN_ID', jobParams) if (!repoUrl && repoName) { repoUrl = "https://github.com/Homelab/${repoName}" } // TT_WORKFLOW_ID / TT_RUN_ID are required: the completion callback correlates // to the waiting Temporal run by them. If either is missing (e.g. incomplete // job-param registration), fail before checkout/hook execution rather than run // hooks whose callback can never be matched (workflow would wait to timeout). if (!repoName || !appName || !ttEnv || !phase || !workflowID || !runID) { error("cdHookRunner: TT_REPO, APP_NAME, TT_ENV, HOOK_PHASE, TT_WORKFLOW_ID and TT_RUN_ID are required parameters.") } // Make env available to stageName / the hook context. env.cicd_environment = ttEnv String podyaml = "org/homelab/${env.INFRA_ENV ?: 'prd'}-pod.yaml" podTemplate(yaml: libraryResource(podyaml)) { node(POD_LABEL) { container('devops-tools') { boolean success = true String errMsg = '' try { stage(stageName("cd-hook checkout: ${repoName}")) { deleteDir() dir(repoName) { checkout([ $class: 'GitSCM', branches: [[name: commit ?: "*/${branch}"]], userRemoteConfigs: [[url: repoUrl, credentialsId: 'svc-devops-homelab']], extensions: [[$class: 'CloneOption', shallow: false, noTags: false]], ]) } } def hooks = resolveHooks(repoName, appName, ttEnv, phase) if (!hooks) { log.info("cdHookRunner: no ${phase} hooks declared for env ${ttEnv} in ${repoName}/deployments/${appName}.yaml — nothing to run.") } else { List ctxEnv = deployPhaseContextEnv(repoName, ttEnv, jobParams) runHooks.executeHooks(repoName, hooks, phase, ctxEnv) } } catch (Exception e) { success = false errMsg = e.toString() log.error("cdHookRunner: ${phase} hook run failed for ${repoName} (env ${ttEnv}): ${errMsg}") } finally { postCdHookCallback(repoName, ttEnv, phase, success, errMsg, workflowID, runID) } if (!success) { // Surface as a build failure too (the workflow reads the callback, // but a red build aids debugging). currentBuild.result = 'FAILURE' error("cdHookRunner: ${phase} hooks failed for ${repoName}: ${errMsg}") } } } } } // resolveHooks reads the per-app deployment file (deployments/.yaml) and // returns the deploy-phase hook list for env+phase (or null). Deploy-phase hooks // live in deployment.yaml, not config.yaml. def resolveHooks(String repoName, String appName, String ttEnv, String phase) { dir(repoName) { String deployFile = "deployments/${appName}.yaml" if (!fileExists(deployFile)) { error("cdHookRunner: ${deployFile} not found in ${repoName}.") } def deployment = readYaml file: deployFile def envBlock = deployment?.environment?.get(ttEnv) def hooksBlock = envBlock?.hooks if (!(hooksBlock instanceof Map)) { return null } def hooks = hooksBlock[phase] return (hooks instanceof List && !hooks.isEmpty()) ? hooks : null } } // deployPhaseContextEnv builds the TT_* context contract from the job params. def deployPhaseContextEnv(String repoName, String ttEnv, Map jobParams) { return [ "TT_REPO_NAME=${repoName}", "TT_ENV=${ttEnv}", "TT_EVENT=push", "TT_IS_HOTFIX=${param('TT_IS_HOTFIX', jobParams) ?: 'false'}", "TT_BRANCH=${param('BRANCH', jobParams) ?: ''}", "TT_TARGET_BRANCH=", "TT_PR_NUMBER=${param('TT_PR_NUMBER', jobParams) ?: ''}", "TT_COMMIT_SHA=${param('COMMIT', jobParams) ?: ''}", "TT_IMAGE_TAG=${param('IMAGE_TAG', jobParams) ?: ''}", ] } // postCdHookCallback POSTs the aggregate result to turbo-turtle, which signals // the waiting CD workflow. Env-routed exactly like the Jenkins CI callback. def postCdHookCallback(String repoName, String ttEnv, String phase, boolean success, String errMsg, String workflowID, String runID) { String baseUrl switch (ttEnv) { case 'prd': case 'int': baseUrl = 'http://turbo-turtle.homelabgcp.in' break default: baseUrl = 'http://turbo-turtle.admin.homelabgcp.in' } Map payload = [ repo_name : repoName, env : ttEnv, phase : phase, success : success, error : errMsg, build_url : env.BUILD_URL ?: '', workflow_id: workflowID, run_id : runID, ] String url = baseUrl + "/api/v1/cd/hook/callback" String jsonFilePath = "cd_hook_callback_${env.BUILD_NUMBER}.json" int maxAttempts = 3 // The callback is turbo-turtle's ONLY completion signal for the waiting run. // Retry delivery (bounded, with connect/request timeouts) and, if every attempt // fails, fail the job so the failure is visible — otherwise the job goes green // while the workflow waits until its backstop timeout. Duplicate delivery is // safe: turbo-turtle correlates callbacks by workflow/run/repo/env/phase. try { writeFile(file: jsonFilePath, text: writeJSON(returnText: true, json: payload)) for (int attempt = 1; attempt <= maxAttempts; attempt++) { boolean delivered = false try { final def(String response, String code) = sh( returnStdout: true, script: """ curl -s --connect-timeout 10 --max-time 30 -X POST \\ -H 'Content-Type: application/json' \\ -w '\\n%{response_code}' \\ $url \\ -d @$jsonFilePath """ ).trim().tokenize("\n") if (code == "200") { log.info("cdHookRunner: callback delivered (attempt ${attempt}/${maxAttempts})") delivered = true } else { log.warn("cdHookRunner: callback attempt ${attempt}/${maxAttempts} failed code=${code} response=${response}") } } catch (Exception e) { log.warn("cdHookRunner: callback attempt ${attempt}/${maxAttempts} error: ${e}") } if (delivered) { return } if (attempt < maxAttempts) { sleep(time: attempt * 5, unit: 'SECONDS') } } error("cdHookRunner: callback POST to ${url} failed after ${maxAttempts} attempts; turbo-turtle will not receive completion for repo=${repoName} env=${ttEnv} phase=${phase}") } finally { sh(script: "rm -f ${jsonFilePath}", returnStatus: true) } } // param reads a job parameter, preferring an explicit map, then params, then env. def param(String key, Map jobParams) { if (jobParams?.containsKey(key)) { return jobParams[key]?.toString() } if (params?.containsKey(key) && params[key] != null) { return params[key].toString() } return env[key]?.toString() }