added files
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
// 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 ('..').")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user