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

161 lines
6.6 KiB
Groovy

// runHooks — generic per-environment CI/CD hook runner (execution core).
//
// Executes user-provided scripts (shell or python) that live in the service
// repo and are declared under `environment.<env>.hooks.<phase>` in the repo's
// config.yaml. The platform stays generic: it only locates, contextualises
// (via TT_* env vars) and runs each script — all use-case logic lives in the
// script itself.
//
// Build-phase entry (call from commonCICDFlow, repo already checked out):
// runHooks(param, 'pre_build') // before docker build
// runHooks(param, 'post_build') // after a successful build
//
// Deploy-phase re-use (from cdHookRunner, after it checks out the repo):
// runHooks.executeHooks(repoName, hooks, phase, ctxEnv)
//
// By the time the build-phase entry runs, constructParam has merged the
// resolved environment block into `param` top-level, so the current env's hooks
// are at `param.hooks`. The repo is checked out into `${repoName}`, so scripts
// run with CWD at the repo root and can reach any repo file.
def call(Map param, String phase) {
def hooksBlock = param?.hooks
if (!(hooksBlock instanceof Map)) {
return
}
def hooks = hooksBlock[phase]
if (!(hooks instanceof List) || hooks.isEmpty()) {
return
}
String repoName = param?.repo_name
if (!repoName) {
error("runHooks: param.repo_name is missing — cannot locate hook scripts.")
}
executeHooks(repoName, hooks, phase, buildPhaseContextEnv(repoName, phase))
}
// executeHooks runs each hook spec in `hooks` inside the repo working directory,
// injecting `ctxEnv` (the TT_* context contract) plus a per-hook TT_HOOK_NAME.
// Reused by both the build-phase entry and the deploy-phase cdHookRunner.
def executeHooks(String repoName, def hooks, String phase, List<String> ctxEnv) {
hooks.eachWithIndex { hook, idx ->
runOneHook(repoName, phase, idx, hook, ctxEnv)
}
}
// runOneHook executes a single hook spec inside the repo working directory.
def runOneHook(String repoName, String phase, int idx, def hook, List<String> ctxEnv) {
String name = (hook?.name ?: "${phase}-${idx}").toString()
String script = hook?.script?.toString()
String interpreter = hook?.interpreter?.toString()
String requirements = hook?.requirements?.toString()
// Blocking by default; only an explicit `blocking: false` demotes to advisory.
boolean blocking = !(hook?.blocking?.toString() == 'false')
int timeoutSeconds = 600
if (hook?.timeout_seconds) {
try { timeoutSeconds = hook.timeout_seconds.toString().toInteger() } catch (ignored) { timeoutSeconds = 600 }
}
if (!script) {
error("runHooks: ${phase}[${idx}] '${name}' has no 'script' path in config.yaml.")
}
assertRepoRelative(script, "${phase}[${idx}] '${name}' script")
if (requirements) {
assertRepoRelative(requirements, "${phase}[${idx}] '${name}' requirements")
}
stage(stageName("${phase}: ${name}")) {
dir(repoName) {
if (!fileExists(script)) {
error("runHooks: ${phase} hook '${name}' script not found in repo: ${script}")
}
List<String> hookEnv = []
hookEnv.addAll(ctxEnv)
hookEnv.add("TT_HOOK_PHASE=${phase}")
hookEnv.add("TT_HOOK_NAME=${name}")
String runCmd = buildRunCommand(script, interpreter, requirements)
log.info("runHooks: executing ${phase} hook '${name}' (${script}), blocking=${blocking}, timeout=${timeoutSeconds}s")
withEnv(hookEnv) {
try {
timeout(time: timeoutSeconds, unit: 'SECONDS') {
sh(script: runCmd)
}
} catch (Exception e) {
if (blocking) {
log.error("runHooks: blocking ${phase} hook '${name}' failed: ${e}")
throw e
}
// Advisory hook — record and continue without failing the build.
log.warning("runHooks: advisory ${phase} hook '${name}' failed (non-blocking): ${e}")
currentBuild.description = (currentBuild.description ? currentBuild.description + " | " : "") + "hook(${name}) advisory-failed"
}
}
}
}
}
// buildRunCommand resolves the interpreter (explicit > extension > shebang) and,
// when a python requirements file is given, provisions an ephemeral venv.
def buildRunCommand(String script, String interpreter, String requirements) {
String interp = interpreter
if (!interp) {
if (script.endsWith('.py')) {
interp = 'python3'
} else if (script.endsWith('.sh')) {
interp = 'bash'
}
}
boolean isPython = (interp == 'python3' || interp == 'python' || script.endsWith('.py'))
if (requirements && isPython) {
String py = interp ?: 'python3'
return """
set -e
${py} -m venv .tt_hook_venv
. .tt_hook_venv/bin/activate
pip install --quiet --disable-pip-version-check -r ${requirements}
${py} ${script}
""".stripIndent().trim()
}
if (interp) {
return "set -e\n${interp} ${script}"
}
// No interpreter resolved — rely on the script's shebang.
return "set -e\nchmod +x ${script}\n./${script}"
}
// buildPhaseContextEnv builds the standard TT_* env-var contract for build-phase
// hooks from the pipeline env already populated by constructParam / the GitHub
// Branch Source plugin.
def buildPhaseContextEnv(String repoName, String phase) {
boolean isPR = (env.CHANGE_ID ? true : false)
String branch = isPR ? (env.CHANGE_BRANCH ?: env.BRANCH_NAME ?: '') : (env.BRANCH_NAME ?: '')
boolean isHotfix = (env.hot_fix == 'true' || env.hot_fix == true)
return [
"TT_REPO_NAME=${repoName}",
"TT_ENV=${env.cicd_environment ?: ''}",
"TT_EVENT=${isPR ? 'pr' : 'push'}",
"TT_IS_HOTFIX=${isHotfix}",
"TT_BRANCH=${branch}",
"TT_TARGET_BRANCH=${env.CHANGE_TARGET ?: ''}",
"TT_PR_NUMBER=${env.CHANGE_ID ?: ''}",
"TT_COMMIT_SHA=${env.GIT_COMMIT ?: ''}",
"TT_IMAGE_TAG=${env.image_tag ?: ''}",
]
}
// assertRepoRelative rejects absolute paths and parent-directory traversal so a
// hook can only execute code that lives inside the checked-out repo.
def assertRepoRelative(String p, String what) {
if (p.startsWith('/')) {
error("runHooks: ${what} path '${p}' must be repo-relative, not absolute.")
}
if (p == '..' || p.startsWith('../') || p.contains('/../')) {
error("runHooks: ${what} path '${p}' must not traverse outside the repo ('..').")
}
}