Files
devops-lib-gcp/vars/cdHookRunner.groovy
T
2026-08-26 02:02:24 +05:30

196 lines
8.9 KiB
Groovy

// 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.<TT_ENV>.hooks.<HOOK_PHASE>` in
// the per-app deployment file (deployments/<APP_NAME>.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/Meesho/${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/meesho/${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-meesho']],
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<String> 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/<appName>.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.meeshogcp.in'
break
default:
baseUrl = 'http://turbo-turtle.admin.meeshogcp.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()
}