romele / git-trace public
62 commits · 1 branch · 1 tag · 1 contributor (top: Romele) · first commit 3d ago · last push 12m ago
src/ci.c
#include "ci.h" #include "auth.h" #include "collab.h" #include "gitx.h" #include "repo.h" #include "util.h" #include "webhook.h" #include <errno.h> #include <fcntl.h> #include <ftw.h> #include <time.h> #include <limits.h> #include <signal.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/resource.h> #include <sys/stat.h> #include <sys/types.h> #include <sys/wait.h> #include <unistd.h> /* ---------- limits & knobs (defaults; env overrides where noted) ------- */ #define CI_LOG_CAP (256 * 1024) #define CI_CPU_SEC 60 #define CI_AS_MB 512 #define CI_FSIZE_MB 32 #define CI_NPROC 128 #define CI_NOFILE 256 static int ci_timeout(void) { const char *e = getenv("GIT_TRACE_CI_TIMEOUT"); int v = e ? atoi(e) : 0; return v > 0 ? v : 300; /* seconds, wall clock */ } /* ---------- small process/fs helpers ------------------------------------ */ /* fork+exec+wait with stdout/stderr to /dev/null; rc of the child */ static int xrun(const char *dir, const char *const *argv) { pid_t pid = fork(); if (pid == 0) { int dn = open("/dev/null", O_WRONLY); if (dn >= 0) { dup2(dn, 1); dup2(dn, 2); } if (dir && chdir(dir) < 0) _exit(126); execvp(argv[0], (char *const *)argv); _exit(127); } if (pid < 0) return -1; int st = 0; while (waitpid(pid, &st, 0) < 0 && errno == EINTR) ; return WIFEXITED(st) ? WEXITSTATUS(st) : -1; } static int rm_cb(const char *p, const struct stat *sb, int t, struct FTW *f) { (void)sb; (void)t; (void)f; return remove(p); } static void rm_rf(const char *path) { nftw(path, rm_cb, 16, FTW_DEPTH | FTW_PHYS); } /* ---------- one build ---------------------------------------------------- */ static void ci_limits(void) { struct rlimit rl; rl.rlim_cur = rl.rlim_max = CI_CPU_SEC; setrlimit(RLIMIT_CPU, &rl); rl.rlim_cur = rl.rlim_max = (rlim_t)CI_AS_MB * 1024 * 1024; setrlimit(RLIMIT_AS, &rl); rl.rlim_cur = rl.rlim_max = (rlim_t)CI_FSIZE_MB * 1024 * 1024; setrlimit(RLIMIT_FSIZE, &rl); rl.rlim_cur = rl.rlim_max = CI_NPROC; setrlimit(RLIMIT_NPROC, &rl); rl.rlim_cur = rl.rlim_max = CI_NOFILE; setrlimit(RLIMIT_NOFILE, &rl); } /* * Run `script` with cwd = srcdir, output captured (capped), hard wall * timeout. Returns "pass"|"fail"|"timeout"; *code gets the exit code. * The child is its own process group so the timeout kills helpers * the script may have spawned too. */ static const char *ci_exec(const char *script, const char *srcdir, const char *on, const char *sha, const char *branch, int *code, struct buf *log) { int p[2]; if (pipe(p) != 0) return "error"; pid_t pid = fork(); if (pid == 0) { setpgid(0, 0); /* own group -> kill(-pid) */ if (chdir(srcdir) < 0) _exit(126); clearenv(); setenv("PATH", "/usr/bin:/bin", 1); setenv("HOME", srcdir, 1); setenv("CI", "1", 1); setenv("GIT_TRACE_REPO", on, 1); setenv("GIT_TRACE_SHA", sha, 1); setenv("GIT_TRACE_BRANCH", branch, 1); ci_limits(); dup2(p[1], 1); dup2(p[1], 2); close(p[0]); close(p[1]); execl("/bin/sh", "sh", script, (char *)NULL); _exit(127); } close(p[1]); if (pid < 0) { close(p[0]); return "error"; } time_t deadline = time(NULL) + ci_timeout(); int st = 0, done = 0, timedout = 0; char chunk[4096]; for (;;) { ssize_t rd = read(p[0], chunk, sizeof chunk); if (rd > 0) { if (log->len < CI_LOG_CAP) buf_put(log, chunk, (size_t)rd > CI_LOG_CAP - log->len ? CI_LOG_CAP - log->len : (size_t)rd); } if (waitpid(pid, &st, WNOHANG) == pid) { done = 1; /* drain whatever is left */ while ((rd = read(p[0], chunk, sizeof chunk)) > 0) if (log->len < CI_LOG_CAP) buf_put(log, chunk, (size_t)rd); break; } if (rd <= 0) { if (time(NULL) > deadline) { timedout = 1; kill(-pid, SIGKILL); waitpid(pid, &st, 0); done = 1; break; } usleep(50000); } } close(p[0]); (void)done; if (timedout) return "timeout"; if (WIFEXITED(st)) { *code = WEXITSTATUS(st); return *code == 0 ? "pass" : "fail"; } return "error"; } static void ci_run_one(struct app_ctx *c, long id, long repo_id, const char *sha, const char *branch, const char *actor) { /* owner/name for the payload + repo_path */ char owner[64] = "_", name[80] = ""; sqlite3_stmt *st; if (sqlite3_prepare_v2(c->db, "SELECT COALESCE(u.name,'_'), r.name FROM repos r" " LEFT JOIN users u ON u.id=r.owner_id WHERE r.id=?", -1, &st, NULL) == SQLITE_OK) { sqlite3_bind_int64(st, 1, repo_id); if (sqlite3_step(st) == SQLITE_ROW) { const char *o = (const char *)sqlite3_column_text(st, 0); const char *nm = (const char *)sqlite3_column_text(st, 1); snprintf(owner, sizeof owner, "%s", o ? o : "_"); snprintf(name, sizeof name, "%s", nm ? nm : ""); } sqlite3_finalize(st); } char rdir[PATH_MAX]; struct buf log; buf_init(&log); const char *status = "error"; int code = -1; char on[200]; snprintf(on, sizeof on, "%s/%s", owner, name); if (repo_path(rdir, sizeof rdir, c->repos_root, owner, name) != 0) { buf_puts(&log, "cannot resolve repo path\n"); goto store; } /* the pipeline script is read ONLY from the default branch: * writers on feature branches cannot touch CI */ { char *def = git_default_branch(rdir); char spec[300]; snprintf(spec, sizeof spec, "%s:.git-trace-ci", def ? def : "HEAD"); free(def); struct buf script; buf_init(&script); const char *sargv[] = { "git", "show", spec, NULL }; int src = git_capture(rdir, sargv, &script); if (src != 0 || !script.data || script.len == 0 || script.len > 65536) { buf_printf(&log, "no usable .git-trace-ci on %s\n", spec); buf_free(&script); goto store; } char tmp[PATH_MAX]; snprintf(tmp, sizeof tmp, "/tmp/gtci-%ld-%ld", (long)getpid(), id); struct buf srcdir, tarf, sf; buf_init(&srcdir); buf_init(&tarf); buf_init(&sf); buf_printf(&srcdir, "%s/src", tmp); buf_printf(&tarf, "%s/src.tar", tmp); buf_printf(&sf, "%s/ci.sh", tmp); if (mkdir_p(srcdir.data) != 0) { buf_puts(&log, "cannot create workdir\n"); buf_free(&script); goto tmpdone; } { const char *aargv[] = { "git", "archive", "--format=tar", "-o", (char *)tarf.data, sha, NULL }; if (xrun(rdir, aargv) != 0) { buf_puts(&log, "git archive failed\n"); buf_free(&script); goto tmpdone; } const char *targv[] = { "tar", "-xf", (char *)tarf.data, "-C", (char *)srcdir.data, NULL }; if (xrun(NULL, targv) != 0) { buf_puts(&log, "tar extract failed\n"); buf_free(&script); goto tmpdone; } } FILE *f = fopen(sf.data, "wb"); if (!f) { buf_puts(&log, "cannot write ci.sh\n"); buf_free(&script); goto tmpdone; } fwrite(script.data, 1, script.len, f); fclose(f); buf_free(&script); status = ci_exec(sf.data, srcdir.data, on, sha, branch, &code, &log); tmpdone: rm_rf(tmp); buf_free(&srcdir); buf_free(&tarf); buf_free(&sf); } store: if (sqlite3_prepare_v2(c->db, "UPDATE ci_runs SET status=?, exit_code=?, log=?," " finished_at=strftime('%s','now') WHERE id=?", -1, &st, NULL) == SQLITE_OK) { sqlite3_bind_text(st, 1, status, -1, SQLITE_STATIC); sqlite3_bind_int(st, 2, code); sqlite3_bind_blob(st, 3, log.data ? log.data : "", (int)log.len, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 4, id); sqlite3_step(st); sqlite3_finalize(st); } db_audit(c->db, 0, actor, NULL, (int)repo_id, "ci_run", "#%ld %.12s %s", id, sha, status); /* a finished build is itself an event */ struct buf p; buf_init(&p); buf_puts(&p, "{\"event\":\"ci\",\"repo\":\""); json_put_esc(&p, on, strlen(on)); buf_puts(&p, "\",\"sha\":\""); json_put_esc(&p, sha, strlen(sha)); buf_printf(&p, "\",\"status\":\"%s\",\"code\":%d}", status, code); wh_enqueue(c->db, repo_id, "ci", p.data ? p.data : "{}"); buf_free(&p); buf_free(&log); } int ci_run_all(struct app_ctx *c) { sqlite3_stmt *st; if (sqlite3_prepare_v2(c->db, "SELECT id, repo_id, sha, branch, actor FROM ci_runs" " WHERE status='pending' ORDER BY id LIMIT 10", -1, &st, NULL) != SQLITE_OK) return -1; struct { long id, repo_id; char sha[80], br[120], actor[64]; } rr[10]; int n = 0; while (sqlite3_step(st) == SQLITE_ROW && n < 10) { rr[n].id = sqlite3_column_int64(st, 0); rr[n].repo_id = sqlite3_column_int64(st, 1); snprintf(rr[n].sha, sizeof rr[n].sha, "%s", (const char *)sqlite3_column_text(st, 2)); snprintf(rr[n].br, sizeof rr[n].br, "%s", (const char *)sqlite3_column_text(st, 3)); snprintf(rr[n].actor, sizeof rr[n].actor, "%s", (const char *)sqlite3_column_text(st, 4)); n++; } sqlite3_finalize(st); for (int i = 0; i < n; i++) { if (sqlite3_prepare_v2(c->db, "UPDATE ci_runs SET status='running' WHERE id=?" " AND status='pending'", -1, &st, NULL) == SQLITE_OK) { sqlite3_bind_int64(st, 1, rr[i].id); sqlite3_step(st); sqlite3_finalize(st); } ci_run_one(c, rr[i].id, rr[i].repo_id, rr[i].sha, rr[i].br, rr[i].actor); } return n; } /* ---------- web views ----------------------------------------------------- */ static const char *ci_badge(const char *st) { if (!strcmp(st, "pass")) return "<b style='color:#2d5a2e'>pass</b>"; if (!strcmp(st, "fail")) return "<b style='color:#a33'>fail</b>"; if (!strcmp(st, "timeout")) return "<b style='color:#a33'>timeout</b>"; if (!strcmp(st, "running")) return "<b>running</b>"; if (!strcmp(st, "pending")) return "<span class=muted>pending</span>"; return "<b style='color:#a33'>error</b>"; } void view_ci(struct app_ctx *app, struct request *r, const char *repo_dir, int rk, const char *o, const char *n, const char *sub) { (void)repo_dir; int repo_id = gt_repo_rowid(app->db, o, n); if (repo_id < 0) { http_error(r, 404, "repository not registered"); return; } struct buf b; buf_init(&b); gt_page_top(&b, n, r); gt_repo_tabs(&b, app, o, n, rk, "ci"); if (sub[0] == '/' && sub[1]) { /* /ci/<id> — run log */ long id = atol(sub + 1); sqlite3_stmt *st; sqlite3_prepare_v2(app->db, "SELECT sha, branch, status, exit_code, log, actor," " datetime(created_at,'unixepoch')," " datetime(finished_at,'unixepoch')" " FROM ci_runs WHERE id=? AND repo_id=?", -1, &st, NULL); sqlite3_bind_int64(st, 1, id); sqlite3_bind_int(st, 2, repo_id); if (sqlite3_step(st) == SQLITE_ROW) { const char *sha = (const char *)sqlite3_column_text(st, 0); const char *br = (const char *)sqlite3_column_text(st, 1); const char *stt = (const char *)sqlite3_column_text(st, 2); const char *act = (const char *)sqlite3_column_text(st, 5); const char *cr = (const char *)sqlite3_column_text(st, 6); const char *fin = (const char *)sqlite3_column_text(st, 7); buf_printf(&b, "<h3>build #%ld — %s</h3>", id, ci_badge(stt ? stt : "?")); buf_puts(&b, "<p class=muted>"); if (sha) buf_printf(&b, "<a href='/%s/%s/commit/%.12s'>%.12s</a> ", o, n, sha, sha); gt_esc(&b, br); buf_puts(&b, " · "); gt_esc(&b, act); buf_printf(&b, " · %s%s</p>", cr ? cr : "?", fin ? "" : " (running)"); const void *lg = sqlite3_column_blob(st, 4); int ll = sqlite3_column_bytes(st, 4); buf_puts(&b, "<pre>"); if (lg && ll > 0) buf_put_escaped(&b, lg, (size_t)ll); else buf_puts(&b, "(no output)"); buf_puts(&b, "</pre>"); } else { buf_puts(&b, "<p class=muted>no such run</p>"); } sqlite3_finalize(st); } else { /* /ci — run list */ sqlite3_stmt *st; sqlite3_prepare_v2(app->db, "SELECT ci_enabled FROM repos WHERE id=?", -1, &st, NULL); sqlite3_bind_int(st, 1, repo_id); int en = sqlite3_step(st) == SQLITE_ROW && sqlite3_column_int(st, 0); sqlite3_finalize(st); if (!en) { buf_puts(&b, "<p class=muted>CI is not enabled for this" " repository. An admin can turn it on in" " settings — builds run <code>.git-trace-ci" "</code> from the default branch.</p>"); } buf_puts(&b, "<h3>builds</h3><table><tr><th>#</th><th>commit" "</th><th>branch</th><th>status</th><th>by</th>" "<th>at</th></tr>"); sqlite3_prepare_v2(app->db, "SELECT id, sha, branch, status, actor," " datetime(created_at,'unixepoch') FROM ci_runs" " WHERE repo_id=? ORDER BY id DESC LIMIT 50", -1, &st, NULL); sqlite3_bind_int(st, 1, repo_id); while (sqlite3_step(st) == SQLITE_ROW) { long id = sqlite3_column_int64(st, 0); const char *sha = (const char *)sqlite3_column_text(st, 1); const char *br = (const char *)sqlite3_column_text(st, 2); const char *stt = (const char *)sqlite3_column_text(st, 3); const char *act = (const char *)sqlite3_column_text(st, 4); const char *cr = (const char *)sqlite3_column_text(st, 5); buf_printf(&b, "<tr><td><a href='/%s/%s/ci/%ld'>#%ld</a>" "</td><td class=sha>%.10s</td><td>", o, n, id, id, sha ? sha : "?"); gt_esc(&b, br); buf_printf(&b, "</td><td>%s</td><td class=muted>", ci_badge(stt ? stt : "?")); gt_esc(&b, act); buf_puts(&b, "</td><td class=muted>"); gt_esc(&b, cr); buf_puts(&b, "</td></tr>"); } sqlite3_finalize(st); buf_puts(&b, "</table>"); } gt_repo_body_end(&b); gt_page_bottom(&b); gt_send_page(r, &b); buf_free(&b); }