161 lines
6.6 KiB
Groovy
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 ('..').")
|
|
}
|
|
}
|