git-trace

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);
}