// 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..hooks.` 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 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 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 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 ('..').") } }