diff --git a/actions/cairn-zig-fuzz/action.yml b/actions/cairn-zig-fuzz-afl/action.yml similarity index 57% rename from actions/cairn-zig-fuzz/action.yml rename to actions/cairn-zig-fuzz-afl/action.yml index cf90c0f..d3cb160 100644 --- a/actions/cairn-zig-fuzz/action.yml +++ b/actions/cairn-zig-fuzz-afl/action.yml @@ -1,5 +1,5 @@ name: 'Cairn Zig Fuzz (AFL++)' -description: 'Install AFL++, then build and run AFL++ fuzz targets, reporting crashes to Cairn. Each line in zig_build_args is a separate target.' +description: 'Build and run Zig AFL++ fuzz targets, reporting crashes and corpus to Cairn. Each target is built via `zig build fuzz -Dfuzz-target=`.' inputs: cairn_server: @@ -15,22 +15,18 @@ inputs: description: 'Commit SHA' required: false default: '${{ github.sha }}' - zig_build_args: + targets: description: | - Line-delimited zig build argument sets. Each line builds and fuzzes one target. + Line-delimited fuzz target names. Each line is passed as -Dfuzz-target=. Example: - fuzz -Dfuzz-target=lexer - fuzz -Dfuzz-target=parser - fuzz -Dfuzz-target=varint_decode + lexer + parser + varint_decode required: true fuzz_binary: description: 'Binary name in zig-out/bin/ (auto-detected if only one)' required: false default: '' - corpus_dir: - description: 'Seed corpus directory (minimal seed created if empty)' - required: false - default: '' duration: description: 'Fuzz duration per target in seconds' required: false @@ -60,15 +56,6 @@ runs: set -eu command -v zig >/dev/null 2>&1 || { echo "ERROR: zig not found in PATH. Install Zig before using this action (e.g. https://codeberg.org/mlugg/setup-zig@v2)."; exit 1; } command -v afl-cc >/dev/null 2>&1 || { echo "ERROR: afl-cc not found in PATH after setup-afl step."; exit 1; } - if ! command -v jq >/dev/null 2>&1; then - if command -v apt-get >/dev/null 2>&1; then - apt-get update -qq - apt-get install -y -qq jq >/dev/null 2>&1 - else - echo "ERROR: jq not found and apt-get unavailable" - exit 1 - fi - fi echo "zig $(zig version), afl-cc found" - name: Setup Cairn CLI @@ -84,62 +71,65 @@ runs: REPO: ${{ inputs.repo }} OWNER: ${{ inputs.owner }} COMMIT: ${{ inputs.commit }} - ZIG_BUILD_ARGS: ${{ inputs.zig_build_args }} + TARGETS: ${{ inputs.targets }} FUZZ_BINARY: ${{ inputs.fuzz_binary }} - CORPUS_DIR: ${{ inputs.corpus_dir }} DURATION: ${{ inputs.duration }} EXTRA_AFL_ARGS: ${{ inputs.afl_args }} - TARGET: ${{ inputs.target }} + TARGET_PLATFORM: ${{ inputs.target }} run: | set -eu - # ── Start Cairn campaign ── - SHORT_SHA=$(printf '%.8s' "${COMMIT}") - if [ -n "${TARGET}" ]; then - CAMPAIGN_NAME="fuzz-${TARGET}" - else - CAMPAIGN_NAME="fuzz-${SHORT_SHA}" - fi - CAMPAIGN_OUTPUT=$(cairn campaign start \ - -server "${CAIRN_SERVER}" \ - -repo "${REPO}" \ - -owner "${OWNER}" \ - -name "${CAMPAIGN_NAME}" \ - -type fuzzing) - CAMPAIGN_ID="${CAMPAIGN_OUTPUT#Campaign started: }" - echo "Campaign ${CAMPAIGN_ID} started" - - cleanup() { - cairn campaign finish -server "${CAIRN_SERVER}" -id "${CAMPAIGN_ID}" || true - } - trap cleanup EXIT - TOTAL_CRASHES=0 TARGET_NUM=0 - # ── Iterate over each line of zig_build_args ── - while IFS= read -r BUILD_ARGS; do + # ── Iterate over each target name ── + while IFS= read -r FUZZ_TARGET; do # Skip empty lines and comments - BUILD_ARGS=$(echo "${BUILD_ARGS}" | sed 's/#.*//' | xargs) - [ -z "${BUILD_ARGS}" ] && continue + FUZZ_TARGET=$(echo "${FUZZ_TARGET}" | sed 's/#.*//' | xargs) + [ -z "${FUZZ_TARGET}" ] && continue TARGET_NUM=$((TARGET_NUM + 1)) + BUILD_ARGS="fuzz -Dfuzz-target=${FUZZ_TARGET}" echo "" echo "==========================================" - echo "Target ${TARGET_NUM}: zig build ${BUILD_ARGS}" + echo "Target ${TARGET_NUM}: ${FUZZ_TARGET} (zig build ${BUILD_ARGS})" echo "==========================================" - # Special-case Zig fuzz targets: if build args contain -Dfuzz-target=, - # use that as the effective Cairn target for metadata/track keying. - LINE_FUZZ_TARGET=$(printf '%s' "${BUILD_ARGS}" | sed -n 's/.*-Dfuzz-target=\([^[:space:]]*\).*/\1/p') - EFFECTIVE_TARGET="${TARGET}" - if [ -n "${LINE_FUZZ_TARGET}" ]; then - EFFECTIVE_TARGET="${LINE_FUZZ_TARGET}" - fi + # ── Ensure Cairn target ── + CAIRN_TARGET_ID=$(cairn target ensure \ + -server "${CAIRN_SERVER}" \ + -repo "${REPO}" \ + -owner "${OWNER}" \ + -name "${FUZZ_TARGET}" \ + -type fuzz) + echo "Cairn target ID: ${CAIRN_TARGET_ID}" - TRACK_SOURCE="${OWNER}/${REPO}|${EFFECTIVE_TARGET}|${BUILD_ARGS}" - TRACK_KEY=$(printf '%s' "${TRACK_SOURCE}" | sha256sum | awk '{print $1}') - echo "Track key: ${TRACK_KEY}" + # ── Start a run ── + RUN_ID=$(cairn run start \ + -server "${CAIRN_SERVER}" \ + -target-id "${CAIRN_TARGET_ID}" \ + -commit "${COMMIT}") + echo "Run ID: ${RUN_ID}" + + finish_run() { + cairn run finish -server "${CAIRN_SERVER}" -id "${RUN_ID}" || true + } + trap finish_run EXIT + + # ── Download existing corpus ── + SEEDS="afl-seeds-${TARGET_NUM}" + rm -rf "${SEEDS}" + mkdir -p "${SEEDS}" + + echo "Downloading existing corpus..." + cairn corpus download \ + -server "${CAIRN_SERVER}" \ + -target-id "${CAIRN_TARGET_ID}" \ + -dir "${SEEDS}" || true + + if [ "$(find "${SEEDS}" -maxdepth 1 -type f | wc -l)" -eq 0 ]; then + printf 'A' > "${SEEDS}/seed-0" + fi # ── Build ── rm -rf zig-out @@ -167,31 +157,6 @@ runs: fi echo "Fuzz binary: ${FUZZ_BIN}" - # ── Seed corpus ── - SEEDS="afl-seeds-${TARGET_NUM}" - rm -rf "${SEEDS}" - mkdir -p "${SEEDS}" - - if [ -n "${CORPUS_DIR}" ] && [ -d "${CORPUS_DIR}" ]; then - cp -a "${CORPUS_DIR}/." "${SEEDS}/" 2>/dev/null || true - fi - - LATEST_CORPUS_ID=$(curl -fsSL "${CAIRN_SERVER}/api/v1/artifacts?type=fuzz&limit=200" \ - | jq -r --arg track "${TRACK_KEY}" --arg repo "${REPO}" \ - '.artifacts[]? | select(.repo_name == $repo and (.metadata.kind // "") == "corpus" and (.metadata.track_key // "") == $track) | .id' \ - | head -n1) - if [ -n "${LATEST_CORPUS_ID}" ] && [ "${LATEST_CORPUS_ID}" != "null" ]; then - echo "Downloading prior corpus artifact: ${LATEST_CORPUS_ID}" - if cairn download -server "${CAIRN_SERVER}" -id "${LATEST_CORPUS_ID}" -o "prior-corpus-${TARGET_NUM}.tar.gz"; then - tar xzf "prior-corpus-${TARGET_NUM}.tar.gz" -C "${SEEDS}" || true - rm -f "prior-corpus-${TARGET_NUM}.tar.gz" - fi - fi - - if [ "$(find "${SEEDS}" -maxdepth 1 -type f | wc -l)" -eq 0 ]; then - printf 'A' > "${SEEDS}/seed-0" - fi - # ── Run AFL++ ── FINDINGS="findings-${TARGET_NUM}" rm -rf "${FINDINGS}" @@ -234,12 +199,12 @@ runs: echo "Uploading crash: ${CRASH_NAME}" set -- -server "${CAIRN_SERVER}" -repo "${REPO}" -owner "${OWNER}" \ - -commit "${COMMIT}" -campaign-id "${CAMPAIGN_ID}" -type fuzz -file "${crash_file}" \ - -kind crash -track-key "${TRACK_KEY}" \ - -crash-message "AFL++ crash (${BUILD_ARGS}): ${CRASH_NAME}" + -commit "${COMMIT}" -run-id "${RUN_ID}" -type fuzz -file "${crash_file}" \ + -kind crash \ + -crash-message "AFL++ crash (${FUZZ_TARGET}): ${CRASH_NAME}" - if [ -n "${EFFECTIVE_TARGET}" ]; then - set -- "$@" -target "${EFFECTIVE_TARGET}" + if [ -n "${TARGET_PLATFORM}" ]; then + set -- "$@" -target "${TARGET_PLATFORM}" fi if [ -n "${SIG}" ]; then set -- "$@" -signal "${SIG}" @@ -250,29 +215,26 @@ runs: done fi - # ── Upload corpus ── + # ── Upload new corpus entries ── QUEUE_DIR="${FINDINGS}/default/queue" if [ -d "${QUEUE_DIR}" ]; then QUEUE_COUNT=$(find "${QUEUE_DIR}" -maxdepth 1 -type f -name 'id:*' | wc -l) if [ "${QUEUE_COUNT}" -gt 0 ]; then echo "Uploading corpus (${QUEUE_COUNT} entries)..." - tar czf "corpus-${TARGET_NUM}.tar.gz" -C "${QUEUE_DIR}" . - - set -- -server "${CAIRN_SERVER}" -repo "${REPO}" -owner "${OWNER}" \ - -commit "${COMMIT}" -campaign-id "${CAMPAIGN_ID}" -type fuzz \ - -kind corpus -track-key "${TRACK_KEY}" \ - -file "corpus-${TARGET_NUM}.tar.gz" - if [ -n "${EFFECTIVE_TARGET}" ]; then - set -- "$@" -target "${EFFECTIVE_TARGET}" - fi - - cairn upload "$@" - rm -f "corpus-${TARGET_NUM}.tar.gz" + cairn corpus upload \ + -server "${CAIRN_SERVER}" \ + -target-id "${CAIRN_TARGET_ID}" \ + -run-id "${RUN_ID}" \ + -dir "${QUEUE_DIR}" fi fi + # ── Finish run ── + finish_run + trap - EXIT + done <\n") + fmt.Fprintf(os.Stderr, "usage: cairn target \n") os.Exit(1) } - if err := cmdCampaign(os.Args[2], os.Args[3:]); err != nil { + if err := cmdTarget(os.Args[2], os.Args[3:]); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + case "run": + if len(os.Args) < 3 { + fmt.Fprintf(os.Stderr, "usage: cairn run \n") + os.Exit(1) + } + if err := cmdRun(os.Args[2], os.Args[3:]); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + case "corpus": + if len(os.Args) < 3 { + fmt.Fprintf(os.Stderr, "usage: cairn corpus \n") + os.Exit(1) + } + if err := cmdCorpus(os.Args[2], os.Args[3:]); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } @@ -59,10 +77,13 @@ func usage() { fmt.Fprintf(os.Stderr, `Usage: cairn [args] Commands: - upload Upload an artifact to Cairn + upload Upload a crash artifact check Check for regressions between two commits - campaign start Start a new campaign - campaign finish Finish a running campaign + target ensure Get or create a target (idempotent) + run start Start a new run for a target + run finish Finish a running run + corpus upload Upload a corpus entry to a target + corpus download Download corpus entries from a target download Download an artifact Upload flags: @@ -70,7 +91,7 @@ Upload flags: -repo NAME Repository name (required) -owner OWNER Repository owner (required) -commit SHA Commit SHA (required) - -campaign-id ID Campaign ID (optional) + -run-id ID Run ID (optional) -type TYPE Artifact type: coredump, fuzz, sanitizer, simulation (required) -file PATH Path to artifact file (required) -crash-message MSG Crash message (optional) @@ -89,7 +110,7 @@ func cmdUpload(args []string) error { repo string owner string commitSHA string - campaignID string + runID string artifactType string filePath string crashMessage string @@ -115,9 +136,9 @@ func cmdUpload(args []string) error { case "-commit": i++ commitSHA = args[i] - case "-campaign-id": + case "-run-id": i++ - campaignID = args[i] + runID = args[i] case "-type": i++ artifactType = args[i] @@ -160,12 +181,12 @@ func cmdUpload(args []string) error { "commit_sha": commitSHA, "type": artifactType, } - if campaignID != "" { - id, err := strconv.ParseUint(campaignID, 10, 64) + if runID != "" { + id, err := strconv.ParseUint(runID, 10, 64) if err != nil { - return fmt.Errorf("invalid campaign id: %w", err) + return fmt.Errorf("invalid run id: %w", err) } - meta["campaign_id"] = id + meta["run_id"] = id } if crashMessage != "" { meta["crash_message"] = crashMessage @@ -316,12 +337,12 @@ func cmdCheck(args []string) error { return nil } -func cmdCampaign(subcmd string, args []string) error { +func cmdTarget(subcmd string, args []string) error { serverURL := envOr("CAIRN_SERVER_URL", "http://localhost:8080") switch subcmd { - case "start": - var repo, owner, name, ctype string + case "ensure": + var repo, owner, name, ttype string for i := 0; i < len(args); i++ { switch args[i] { case "-server": @@ -338,18 +359,71 @@ func cmdCampaign(subcmd string, args []string) error { name = args[i] case "-type": i++ - ctype = args[i] + ttype = args[i] default: return fmt.Errorf("unknown flag: %s", args[i]) } } - if repo == "" || owner == "" || name == "" || ctype == "" { + if repo == "" || owner == "" || name == "" || ttype == "" { return fmt.Errorf("required flags: -repo, -owner, -name, -type") } body, _ := json.Marshal(map[string]string{ - "repository": repo, "owner": owner, "name": name, "type": ctype, + "repository": repo, "owner": owner, "name": name, "type": ttype, }) - resp, err := http.Post(serverURL+"/api/v1/campaigns", "application/json", bytes.NewReader(body)) + resp, err := http.Post(serverURL+"/api/v1/targets", "application/json", bytes.NewReader(body)) + if err != nil { + return err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("server returned %d: %s", resp.StatusCode, respBody) + } + var result map[string]any + if err := json.Unmarshal(respBody, &result); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + // Print just the ID for easy capture in scripts. + fmt.Printf("%v\n", result["id"]) + + default: + return fmt.Errorf("unknown target subcommand: %s (use ensure)", subcmd) + } + return nil +} + +func cmdRun(subcmd string, args []string) error { + serverURL := envOr("CAIRN_SERVER_URL", "http://localhost:8080") + + switch subcmd { + case "start": + var targetID, commitSHA string + for i := 0; i < len(args); i++ { + switch args[i] { + case "-server": + i++ + serverURL = args[i] + case "-target-id": + i++ + targetID = args[i] + case "-commit": + i++ + commitSHA = args[i] + default: + return fmt.Errorf("unknown flag: %s", args[i]) + } + } + if targetID == "" || commitSHA == "" { + return fmt.Errorf("required flags: -target-id, -commit") + } + tid, err := strconv.ParseUint(targetID, 10, 64) + if err != nil { + return fmt.Errorf("invalid target id: %w", err) + } + body, _ := json.Marshal(map[string]any{ + "target_id": tid, "commit_sha": commitSHA, + }) + resp, err := http.Post(serverURL+"/api/v1/runs", "application/json", bytes.NewReader(body)) if err != nil { return err } @@ -362,7 +436,8 @@ func cmdCampaign(subcmd string, args []string) error { if err := json.Unmarshal(respBody, &result); err != nil { return fmt.Errorf("parsing response: %w", err) } - fmt.Printf("Campaign started: %v\n", result["id"]) + // Print just the ID for easy capture in scripts. + fmt.Printf("%v\n", result["id"]) case "finish": var id string @@ -381,7 +456,7 @@ func cmdCampaign(subcmd string, args []string) error { if id == "" { return fmt.Errorf("required flag: -id") } - resp, err := http.Post(serverURL+"/api/v1/campaigns/"+id+"/finish", "application/json", nil) + resp, err := http.Post(serverURL+"/api/v1/runs/"+id+"/finish", "application/json", nil) if err != nil { return err } @@ -390,10 +465,174 @@ func cmdCampaign(subcmd string, args []string) error { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("server returned %d: %s", resp.StatusCode, body) } - fmt.Println("Campaign finished") + fmt.Println("Run finished") default: - return fmt.Errorf("unknown campaign subcommand: %s (use start or finish)", subcmd) + return fmt.Errorf("unknown run subcommand: %s (use start or finish)", subcmd) + } + return nil +} + +func cmdCorpus(subcmd string, args []string) error { + serverURL := envOr("CAIRN_SERVER_URL", "http://localhost:8080") + + switch subcmd { + case "upload": + var targetID, runID, dir string + for i := 0; i < len(args); i++ { + switch args[i] { + case "-server": + i++ + serverURL = args[i] + case "-target-id": + i++ + targetID = args[i] + case "-run-id": + i++ + runID = args[i] + case "-dir": + i++ + dir = args[i] + default: + return fmt.Errorf("unknown flag: %s", args[i]) + } + } + if targetID == "" || dir == "" { + return fmt.Errorf("required flags: -target-id, -dir") + } + + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("reading directory: %w", err) + } + + var uploaded int + for _, entry := range entries { + if entry.IsDir() { + continue + } + filePath := filepath.Join(dir, entry.Name()) + if err := uploadCorpusFile(serverURL, targetID, runID, filePath); err != nil { + return fmt.Errorf("uploading %s: %w", entry.Name(), err) + } + uploaded++ + } + fmt.Printf("Uploaded %d corpus entries\n", uploaded) + + case "download": + var targetID, dir string + for i := 0; i < len(args); i++ { + switch args[i] { + case "-server": + i++ + serverURL = args[i] + case "-target-id": + i++ + targetID = args[i] + case "-dir": + i++ + dir = args[i] + default: + return fmt.Errorf("unknown flag: %s", args[i]) + } + } + if targetID == "" || dir == "" { + return fmt.Errorf("required flags: -target-id, -dir") + } + + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("creating output dir: %w", err) + } + + // List all corpus entries. + resp, err := http.Get(serverURL + "/api/v1/targets/" + targetID + "/corpus?limit=10000") + if err != nil { + return fmt.Errorf("listing corpus: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("server returned %d: %s", resp.StatusCode, body) + } + + var listResp struct { + Entries []struct { + ID float64 `json:"id"` + BlobKey string `json:"blob_key"` + } `json:"entries"` + } + if err := json.Unmarshal(body, &listResp); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + + var downloaded int + for _, entry := range listResp.Entries { + entryID := fmt.Sprintf("%d", int(entry.ID)) + dlURL := serverURL + "/api/v1/targets/" + targetID + "/corpus/" + entryID + "/download" + dlResp, err := http.Get(dlURL) + if err != nil { + return fmt.Errorf("downloading entry %s: %w", entryID, err) + } + + filename := filepath.Base(entry.BlobKey) + outPath := filepath.Join(dir, filename) + out, err := os.Create(outPath) + if err != nil { + dlResp.Body.Close() + return fmt.Errorf("creating file %s: %w", outPath, err) + } + _, err = io.Copy(out, dlResp.Body) + dlResp.Body.Close() + out.Close() + if err != nil { + return fmt.Errorf("writing file %s: %w", outPath, err) + } + downloaded++ + } + fmt.Printf("Downloaded %d corpus entries to %s\n", downloaded, dir) + + default: + return fmt.Errorf("unknown corpus subcommand: %s (use upload or download)", subcmd) + } + return nil +} + +func uploadCorpusFile(serverURL, targetID, runID, filePath string) error { + f, err := os.Open(filePath) + if err != nil { + return err + } + defer f.Close() + + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + + if runID != "" { + if err := w.WriteField("run_id", runID); err != nil { + return err + } + } + + fw, err := w.CreateFormFile("file", filepath.Base(filePath)) + if err != nil { + return err + } + if _, err := io.Copy(fw, f); err != nil { + return err + } + if err := w.Close(); err != nil { + return err + } + + resp, err := http.Post(serverURL+"/api/v1/targets/"+targetID+"/corpus", w.FormDataContentType(), &buf) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("server returned %d: %s", resp.StatusCode, body) } return nil } diff --git a/internal/api/types.go b/internal/api/types.go index d64e3f9..ae99c3b 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -29,7 +29,7 @@ type Artifact struct { RepositoryID uint `json:"repository_id"` CommitID uint `json:"commit_id"` BuildID *uint `json:"build_id,omitempty"` - CampaignID *uint `json:"campaign_id,omitempty"` + RunID *uint `json:"run_id,omitempty"` CrashSignatureID *uint `json:"crash_signature_id,omitempty"` Type string `json:"type"` BlobKey string `json:"blob_key"` @@ -44,22 +44,48 @@ type Artifact struct { CommitSHA string `json:"commit_sha,omitempty"` } -type Campaign struct { +type Target struct { ID uint `json:"id"` RepositoryID uint `json:"repository_id"` Name string `json:"name"` Type string `json:"type"` - Status string `json:"status"` - StartedAt time.Time `json:"started_at"` - FinishedAt *time.Time `json:"finished_at,omitempty"` Tags json.RawMessage `json:"tags,omitempty"` Metadata json.RawMessage `json:"metadata,omitempty"` CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + RepoName string `json:"repo_name,omitempty"` + RunCount int64 `json:"run_count,omitempty"` + CorpusCount int64 `json:"corpus_count,omitempty"` +} + +type Run struct { + ID uint `json:"id"` + TargetID uint `json:"target_id"` + CommitID uint `json:"commit_id"` + Status string `json:"status"` + StartedAt time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + Tags json.RawMessage `json:"tags,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + + TargetName string `json:"target_name,omitempty"` RepoName string `json:"repo_name,omitempty"` + CommitSHA string `json:"commit_sha,omitempty"` ArtifactCount int64 `json:"artifact_count,omitempty"` } +type CorpusEntry struct { + ID uint `json:"id"` + TargetID uint `json:"target_id"` + RunID *uint `json:"run_id,omitempty"` + BlobKey string `json:"blob_key"` + BlobSize int64 `json:"blob_size"` + Fingerprint *string `json:"fingerprint,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + type CrashSignature struct { ID uint `json:"id"` RepositoryID uint `json:"repository_id"` diff --git a/internal/database/migrate.go b/internal/database/migrate.go index 903f183..f7bb0ac 100644 --- a/internal/database/migrate.go +++ b/internal/database/migrate.go @@ -12,7 +12,9 @@ func Migrate(db *gorm.DB) error { &models.Repository{}, &models.Commit{}, &models.Build{}, - &models.Campaign{}, + &models.Target{}, + &models.Run{}, + &models.CorpusEntry{}, &models.CrashSignature{}, &models.CrashGroup{}, &models.Artifact{}, diff --git a/internal/handler/campaigns.go b/internal/handler/campaigns.go deleted file mode 100644 index a68a235..0000000 --- a/internal/handler/campaigns.go +++ /dev/null @@ -1,110 +0,0 @@ -package handler - -import ( - "net/http" - "strconv" - - "github.com/gin-gonic/gin" - cairnapi "github.com/mattnite/cairn/internal/api" - "github.com/mattnite/cairn/internal/models" - "gorm.io/gorm" -) - -type CampaignHandler struct { - DB *gorm.DB -} - -func (h *CampaignHandler) List(c *gin.Context) { - limit, _ := strconv.Atoi(c.Query("limit")) - offset, _ := strconv.Atoi(c.Query("offset")) - if limit <= 0 { - limit = 50 - } - - repoID, err := parseOptionalUintID(c.Query("repository_id"), "repository_id") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - campaigns, total, err := models.ListCampaigns(c.Request.Context(), h.DB, repoID, limit, offset) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - if campaigns == nil { - campaigns = []cairnapi.Campaign{} - } - - c.JSON(http.StatusOK, gin.H{ - "campaigns": campaigns, - "total": total, - "limit": limit, - "offset": offset, - }) -} - -func (h *CampaignHandler) Detail(c *gin.Context) { - id, err := parseUintID(c.Param("id"), "campaign id") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - campaign, err := models.GetCampaign(c.Request.Context(), h.DB, id) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "campaign not found"}) - return - } - - c.JSON(http.StatusOK, campaign) -} - -type CreateCampaignRequest struct { - Repository string `json:"repository" binding:"required"` - Owner string `json:"owner" binding:"required"` - Name string `json:"name" binding:"required"` - Type string `json:"type" binding:"required"` -} - -func (h *CampaignHandler) Create(c *gin.Context) { - var req CreateCampaignRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - ctx := c.Request.Context() - - repo, err := models.GetOrCreateRepository(ctx, h.DB, req.Owner, req.Repository) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - campaign, err := models.CreateCampaign(ctx, h.DB, models.CreateCampaignParams{ - RepositoryID: repo.ID, - Name: req.Name, - Type: req.Type, - }) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusCreated, campaign) -} - -func (h *CampaignHandler) Finish(c *gin.Context) { - id, err := parseUintID(c.Param("id"), "campaign id") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := models.FinishCampaign(c.Request.Context(), h.DB, id); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"status": "finished"}) -} diff --git a/internal/handler/dashboard.go b/internal/handler/dashboard.go index 760e6e8..678753a 100644 --- a/internal/handler/dashboard.go +++ b/internal/handler/dashboard.go @@ -18,7 +18,7 @@ type DashboardStats struct { TotalRepos int64 `json:"total_repos"` TotalCrashGroups int64 `json:"total_crash_groups"` OpenCrashGroups int64 `json:"open_crash_groups"` - ActiveCampaigns int64 `json:"active_campaigns"` + TotalTargets int64 `json:"total_targets"` } type TrendPoint struct { @@ -47,7 +47,7 @@ func (h *DashboardHandler) Stats(c *gin.Context) { _ = h.DB.WithContext(ctx).Model(&models.Repository{}).Count(&stats.TotalRepos).Error _ = h.DB.WithContext(ctx).Model(&models.CrashGroup{}).Count(&stats.TotalCrashGroups).Error _ = h.DB.WithContext(ctx).Model(&models.CrashGroup{}).Where("status = ?", "open").Count(&stats.OpenCrashGroups).Error - _ = h.DB.WithContext(ctx).Model(&models.Campaign{}).Where("status = ?", "running").Count(&stats.ActiveCampaigns).Error + _ = h.DB.WithContext(ctx).Model(&models.Target{}).Count(&stats.TotalTargets).Error // Artifact trend for the last 30 days. var trend []TrendPoint diff --git a/internal/handler/ingest.go b/internal/handler/ingest.go index cc391f1..2992fc3 100644 --- a/internal/handler/ingest.go +++ b/internal/handler/ingest.go @@ -25,7 +25,7 @@ type IngestRequest struct { Repository string `json:"repository"` Owner string `json:"owner"` CommitSHA string `json:"commit_sha"` - CampaignID *uint `json:"campaign_id,omitempty"` + RunID *uint `json:"run_id,omitempty"` Type string `json:"type"` CrashMessage string `json:"crash_message,omitempty"` StackTrace string `json:"stack_trace,omitempty"` @@ -94,7 +94,7 @@ func (h *IngestHandler) Create(c *gin.Context) { artifact, err := models.CreateArtifact(ctx, h.DB, models.CreateArtifactParams{ RepositoryID: repo.ID, CommitID: commit.ID, - CampaignID: req.CampaignID, + RunID: req.RunID, Type: req.Type, BlobKey: blobKey, BlobSize: header.Size, diff --git a/internal/handler/targets.go b/internal/handler/targets.go new file mode 100644 index 0000000..7bd1dea --- /dev/null +++ b/internal/handler/targets.go @@ -0,0 +1,319 @@ +package handler + +import ( + "fmt" + "io" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + cairnapi "github.com/mattnite/cairn/internal/api" + "github.com/mattnite/cairn/internal/blob" + "github.com/mattnite/cairn/internal/models" + "gorm.io/gorm" +) + +type TargetHandler struct { + DB *gorm.DB + Store blob.Store +} + +func (h *TargetHandler) List(c *gin.Context) { + limit, _ := strconv.Atoi(c.Query("limit")) + offset, _ := strconv.Atoi(c.Query("offset")) + if limit <= 0 { + limit = 50 + } + + repoID, err := parseOptionalUintID(c.Query("repository_id"), "repository_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + targets, total, err := models.ListTargets(c.Request.Context(), h.DB, repoID, limit, offset) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if targets == nil { + targets = []cairnapi.Target{} + } + + c.JSON(http.StatusOK, gin.H{ + "targets": targets, + "total": total, + "limit": limit, + "offset": offset, + }) +} + +func (h *TargetHandler) Detail(c *gin.Context) { + id, err := parseUintID(c.Param("id"), "target id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + target, err := models.GetTarget(c.Request.Context(), h.DB, id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "target not found"}) + return + } + + c.JSON(http.StatusOK, target) +} + +type EnsureTargetRequest struct { + Repository string `json:"repository" binding:"required"` + Owner string `json:"owner" binding:"required"` + Name string `json:"name" binding:"required"` + Type string `json:"type" binding:"required"` +} + +func (h *TargetHandler) Ensure(c *gin.Context) { + var req EnsureTargetRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx := c.Request.Context() + + repo, err := models.GetOrCreateRepository(ctx, h.DB, req.Owner, req.Repository) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + target, err := models.GetOrCreateTarget(ctx, h.DB, models.CreateTargetParams{ + RepositoryID: repo.ID, + Name: req.Name, + Type: req.Type, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, target) +} + +// Run handlers + +type RunHandler struct { + DB *gorm.DB +} + +type StartRunRequest struct { + TargetID uint `json:"target_id" binding:"required"` + CommitSHA string `json:"commit_sha" binding:"required"` +} + +func (h *RunHandler) Start(c *gin.Context) { + var req StartRunRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx := c.Request.Context() + + // Look up the target to get the repository ID for the commit. + target, err := models.GetTarget(ctx, h.DB, req.TargetID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "target not found"}) + return + } + + commit := &models.Commit{RepositoryID: target.RepositoryID, SHA: req.CommitSHA} + if err := h.DB.WithContext(ctx).Where("repository_id = ? AND sha = ?", target.RepositoryID, req.CommitSHA).FirstOrCreate(commit).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + run, err := models.CreateRun(ctx, h.DB, req.TargetID, commit.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, run) +} + +func (h *RunHandler) Finish(c *gin.Context) { + id, err := parseUintID(c.Param("id"), "run id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := models.FinishRun(c.Request.Context(), h.DB, id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "finished"}) +} + +func (h *RunHandler) Detail(c *gin.Context) { + id, err := parseUintID(c.Param("id"), "run id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + run, err := models.GetRun(c.Request.Context(), h.DB, id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "run not found"}) + return + } + + c.JSON(http.StatusOK, run) +} + +func (h *RunHandler) List(c *gin.Context) { + limit, _ := strconv.Atoi(c.Query("limit")) + offset, _ := strconv.Atoi(c.Query("offset")) + if limit <= 0 { + limit = 50 + } + + targetID, err := parseOptionalUintID(c.Query("target_id"), "target_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + runs, total, err := models.ListRuns(c.Request.Context(), h.DB, targetID, limit, offset) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if runs == nil { + runs = []cairnapi.Run{} + } + + c.JSON(http.StatusOK, gin.H{ + "runs": runs, + "total": total, + "limit": limit, + "offset": offset, + }) +} + +// Corpus handlers + +type CorpusHandler struct { + DB *gorm.DB + Store blob.Store +} + +func (h *CorpusHandler) Upload(c *gin.Context) { + targetID, err := parseUintID(c.Param("id"), "target id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx := c.Request.Context() + + // Verify target exists. + target, err := models.GetTarget(ctx, h.DB, targetID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "target not found"}) + return + } + + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing 'file' form field: " + err.Error()}) + return + } + defer file.Close() + + var runID *uint + if rid := c.PostForm("run_id"); rid != "" { + id, err := strconv.ParseUint(rid, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid run_id"}) + return + } + uid := uint(id) + runID = &uid + } + + blobKey := fmt.Sprintf("corpus/%s/%s/%s", target.RepoName, target.Name, header.Filename) + + if err := h.Store.Put(ctx, blobKey, file, header.Size); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "storing blob: " + err.Error()}) + return + } + + entry, err := models.CreateCorpusEntry(ctx, h.DB, models.CreateCorpusEntryParams{ + TargetID: targetID, + RunID: runID, + BlobKey: blobKey, + BlobSize: header.Size, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, entry) +} + +func (h *CorpusHandler) List(c *gin.Context) { + targetID, err := parseUintID(c.Param("id"), "target id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + limit, _ := strconv.Atoi(c.Query("limit")) + offset, _ := strconv.Atoi(c.Query("offset")) + + entries, total, err := models.ListCorpusEntries(c.Request.Context(), h.DB, targetID, limit, offset) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if entries == nil { + entries = []cairnapi.CorpusEntry{} + } + + c.JSON(http.StatusOK, gin.H{ + "entries": entries, + "total": total, + "limit": limit, + "offset": offset, + }) +} + +func (h *CorpusHandler) Download(c *gin.Context) { + entryID, err := parseUintID(c.Param("entry_id"), "corpus entry id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx := c.Request.Context() + entry := &models.CorpusEntry{} + if err := h.DB.WithContext(ctx).First(entry, entryID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "corpus entry not found"}) + return + } + + reader, err := h.Store.Get(ctx, entry.BlobKey) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "reading blob: " + err.Error()}) + return + } + defer reader.Close() + + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", entry.BlobKey)) + c.Header("Content-Type", "application/octet-stream") + _, _ = io.Copy(c.Writer, reader) +} diff --git a/internal/models/artifact.go b/internal/models/artifact.go index 0583efe..dd07097 100644 --- a/internal/models/artifact.go +++ b/internal/models/artifact.go @@ -13,7 +13,7 @@ type CreateArtifactParams struct { RepositoryID uint CommitID uint BuildID *uint - CampaignID *uint + RunID *uint Type string BlobKey string BlobSize int64 @@ -35,7 +35,7 @@ func CreateArtifact(ctx context.Context, db *gorm.DB, p CreateArtifactParams) (* RepositoryID: p.RepositoryID, CommitID: p.CommitID, BuildID: p.BuildID, - CampaignID: p.CampaignID, + RunID: p.RunID, Type: p.Type, BlobKey: p.BlobKey, BlobSize: p.BlobSize, @@ -64,7 +64,7 @@ type ListArtifactsParams struct { CommitSHA string Type string SignatureID *uint - CampaignID *uint + RunID *uint Limit int Offset int } @@ -85,8 +85,8 @@ func ListArtifacts(ctx context.Context, db *gorm.DB, p ListArtifactsParams) ([]c if p.SignatureID != nil { query = query.Where("crash_signature_id = ?", *p.SignatureID) } - if p.CampaignID != nil { - query = query.Where("campaign_id = ?", *p.CampaignID) + if p.RunID != nil { + query = query.Where("run_id = ?", *p.RunID) } if p.CommitSHA != "" { query = query.Joins("JOIN commits ON commits.id = artifacts.commit_id").Where("commits.sha = ?", p.CommitSHA) @@ -167,7 +167,7 @@ func artifactFromModel(m Artifact) cairnapi.Artifact { RepositoryID: m.RepositoryID, CommitID: m.CommitID, BuildID: m.BuildID, - CampaignID: m.CampaignID, + RunID: m.RunID, CrashSignatureID: m.CrashSignatureID, Type: m.Type, BlobKey: m.BlobKey, diff --git a/internal/models/campaign.go b/internal/models/campaign.go deleted file mode 100644 index bbf5a9a..0000000 --- a/internal/models/campaign.go +++ /dev/null @@ -1,125 +0,0 @@ -package models - -import ( - "context" - "encoding/json" - "fmt" - "time" - - cairnapi "github.com/mattnite/cairn/internal/api" - "gorm.io/gorm" -) - -type CreateCampaignParams struct { - RepositoryID uint - Name string - Type string - Tags json.RawMessage - Metadata json.RawMessage -} - -func CreateCampaign(ctx context.Context, db *gorm.DB, p CreateCampaignParams) (*cairnapi.Campaign, error) { - if p.Tags == nil { - p.Tags = json.RawMessage("{}") - } - if p.Metadata == nil { - p.Metadata = json.RawMessage("{}") - } - - campaign := &Campaign{ - RepositoryID: p.RepositoryID, - Name: p.Name, - Type: p.Type, - Status: "running", - StartedAt: time.Now(), - Tags: p.Tags, - Metadata: p.Metadata, - } - - if err := db.WithContext(ctx).Create(campaign).Error; err != nil { - return nil, fmt.Errorf("creating campaign: %w", err) - } - return enrichCampaign(ctx, db, *campaign) -} - -func FinishCampaign(ctx context.Context, db *gorm.DB, id uint) error { - now := time.Now() - if err := db.WithContext(ctx).Model(&Campaign{}).Where("id = ?", id).Updates(map[string]any{ - "status": "finished", - "finished_at": now, - }).Error; err != nil { - return fmt.Errorf("finishing campaign: %w", err) - } - return nil -} - -func GetCampaign(ctx context.Context, db *gorm.DB, id uint) (*cairnapi.Campaign, error) { - campaign := &Campaign{} - if err := db.WithContext(ctx).First(campaign, id).Error; err != nil { - return nil, fmt.Errorf("getting campaign: %w", err) - } - return enrichCampaign(ctx, db, *campaign) -} - -func ListCampaigns(ctx context.Context, db *gorm.DB, repoID *uint, limit, offset int) ([]cairnapi.Campaign, int64, error) { - if limit <= 0 { - limit = 50 - } - - query := db.WithContext(ctx).Model(&Campaign{}) - if repoID != nil { - query = query.Where("repository_id = ?", *repoID) - } - - var total int64 - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("counting campaigns: %w", err) - } - - var dbCampaigns []Campaign - if err := query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&dbCampaigns).Error; err != nil { - return nil, 0, fmt.Errorf("listing campaigns: %w", err) - } - - campaigns := make([]cairnapi.Campaign, 0, len(dbCampaigns)) - for _, m := range dbCampaigns { - c, err := enrichCampaign(ctx, db, m) - if err != nil { - return nil, 0, err - } - campaigns = append(campaigns, *c) - } - - return campaigns, total, nil -} - -func enrichCampaign(ctx context.Context, db *gorm.DB, model Campaign) (*cairnapi.Campaign, error) { - repo := &Repository{} - if err := db.WithContext(ctx).First(repo, model.RepositoryID).Error; err != nil { - return nil, fmt.Errorf("loading campaign repository: %w", err) - } - - var count int64 - if err := db.WithContext(ctx).Model(&Artifact{}).Where("campaign_id = ?", model.ID).Count(&count).Error; err != nil { - return nil, fmt.Errorf("counting campaign artifacts: %w", err) - } - campaign := campaignFromModel(model) - campaign.RepoName = repo.Name - campaign.ArtifactCount = count - return &campaign, nil -} - -func campaignFromModel(m Campaign) cairnapi.Campaign { - return cairnapi.Campaign{ - ID: m.ID, - RepositoryID: m.RepositoryID, - Name: m.Name, - Type: m.Type, - Status: m.Status, - StartedAt: m.StartedAt, - FinishedAt: m.FinishedAt, - Tags: m.Tags, - Metadata: m.Metadata, - CreatedAt: m.CreatedAt, - } -} diff --git a/internal/models/models.go b/internal/models/models.go index 2ba1ecc..82cb668 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -50,7 +50,7 @@ type Artifact struct { RepositoryID uint `gorm:"not null;index"` CommitID uint `gorm:"not null;index"` BuildID *uint `gorm:"index"` - CampaignID *uint `gorm:"index"` + RunID *uint `gorm:"index"` CrashSignatureID *uint `gorm:"index"` Type string `gorm:"not null;index"` BlobKey string `gorm:"not null"` @@ -64,28 +64,58 @@ type Artifact struct { Repository Repository `gorm:"foreignKey:RepositoryID"` Commit Commit `gorm:"foreignKey:CommitID"` Build *Build `gorm:"foreignKey:BuildID"` - Campaign *Campaign `gorm:"foreignKey:CampaignID"` + Run *Run `gorm:"foreignKey:RunID"` Signature *CrashSignature `gorm:"foreignKey:CrashSignatureID"` } func (Artifact) TableName() string { return "artifacts" } -type Campaign struct { - ID uint `gorm:"primaryKey"` - RepositoryID uint `gorm:"not null;index"` - Name string `gorm:"not null"` - Type string `gorm:"not null"` - Status string `gorm:"not null;default:running;index"` - StartedAt time.Time `gorm:"not null;autoCreateTime"` - FinishedAt *time.Time +type Target struct { + ID uint `gorm:"primaryKey"` + RepositoryID uint `gorm:"not null;index:idx_targets_repo_name,unique"` + Name string `gorm:"not null;index:idx_targets_repo_name,unique"` + Type string `gorm:"not null"` Tags json.RawMessage `gorm:"type:jsonb;default:'{}'"` Metadata json.RawMessage `gorm:"type:jsonb;default:'{}'"` CreatedAt time.Time + UpdatedAt time.Time Repository Repository `gorm:"foreignKey:RepositoryID"` } -func (Campaign) TableName() string { return "campaigns" } +func (Target) TableName() string { return "targets" } + +type Run struct { + ID uint `gorm:"primaryKey"` + TargetID uint `gorm:"not null;index"` + CommitID uint `gorm:"not null;index"` + Status string `gorm:"not null;default:running;index"` + StartedAt time.Time `gorm:"not null;autoCreateTime"` + FinishedAt *time.Time + Tags json.RawMessage `gorm:"type:jsonb;default:'{}'"` + Metadata json.RawMessage `gorm:"type:jsonb;default:'{}'"` + CreatedAt time.Time + + Target Target `gorm:"foreignKey:TargetID"` + Commit Commit `gorm:"foreignKey:CommitID"` +} + +func (Run) TableName() string { return "runs" } + +type CorpusEntry struct { + ID uint `gorm:"primaryKey"` + TargetID uint `gorm:"not null;index"` + RunID *uint `gorm:"index"` + BlobKey string `gorm:"not null"` + BlobSize int64 `gorm:"not null"` + Fingerprint *string `gorm:"index"` + CreatedAt time.Time + + Target Target `gorm:"foreignKey:TargetID"` + Run *Run `gorm:"foreignKey:RunID"` +} + +func (CorpusEntry) TableName() string { return "corpus_entries" } type CrashSignature struct { ID uint `gorm:"primaryKey"` diff --git a/internal/models/target.go b/internal/models/target.go new file mode 100644 index 0000000..673ff49 --- /dev/null +++ b/internal/models/target.go @@ -0,0 +1,299 @@ +package models + +import ( + "context" + "encoding/json" + "fmt" + "time" + + cairnapi "github.com/mattnite/cairn/internal/api" + "gorm.io/gorm" +) + +type CreateTargetParams struct { + RepositoryID uint + Name string + Type string + Tags json.RawMessage + Metadata json.RawMessage +} + +func GetOrCreateTarget(ctx context.Context, db *gorm.DB, p CreateTargetParams) (*cairnapi.Target, error) { + if p.Tags == nil { + p.Tags = json.RawMessage("{}") + } + if p.Metadata == nil { + p.Metadata = json.RawMessage("{}") + } + + target := &Target{} + err := db.WithContext(ctx). + Where("repository_id = ? AND name = ?", p.RepositoryID, p.Name). + First(target).Error + + if err == gorm.ErrRecordNotFound { + target = &Target{ + RepositoryID: p.RepositoryID, + Name: p.Name, + Type: p.Type, + Tags: p.Tags, + Metadata: p.Metadata, + } + if err := db.WithContext(ctx).Create(target).Error; err != nil { + return nil, fmt.Errorf("creating target: %w", err) + } + } else if err != nil { + return nil, fmt.Errorf("querying target: %w", err) + } + + return enrichTarget(ctx, db, *target) +} + +func GetTarget(ctx context.Context, db *gorm.DB, id uint) (*cairnapi.Target, error) { + target := &Target{} + if err := db.WithContext(ctx).First(target, id).Error; err != nil { + return nil, fmt.Errorf("getting target: %w", err) + } + return enrichTarget(ctx, db, *target) +} + +func ListTargets(ctx context.Context, db *gorm.DB, repoID *uint, limit, offset int) ([]cairnapi.Target, int64, error) { + if limit <= 0 { + limit = 50 + } + + query := db.WithContext(ctx).Model(&Target{}) + if repoID != nil { + query = query.Where("repository_id = ?", *repoID) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("counting targets: %w", err) + } + + var dbTargets []Target + if err := query.Order("updated_at DESC").Limit(limit).Offset(offset).Find(&dbTargets).Error; err != nil { + return nil, 0, fmt.Errorf("listing targets: %w", err) + } + + targets := make([]cairnapi.Target, 0, len(dbTargets)) + for _, m := range dbTargets { + t, err := enrichTarget(ctx, db, m) + if err != nil { + return nil, 0, err + } + targets = append(targets, *t) + } + + return targets, total, nil +} + +func enrichTarget(ctx context.Context, db *gorm.DB, model Target) (*cairnapi.Target, error) { + repo := &Repository{} + if err := db.WithContext(ctx).First(repo, model.RepositoryID).Error; err != nil { + return nil, fmt.Errorf("loading target repository: %w", err) + } + + var runCount int64 + if err := db.WithContext(ctx).Model(&Run{}).Where("target_id = ?", model.ID).Count(&runCount).Error; err != nil { + return nil, fmt.Errorf("counting target runs: %w", err) + } + + var corpusCount int64 + if err := db.WithContext(ctx).Model(&CorpusEntry{}).Where("target_id = ?", model.ID).Count(&corpusCount).Error; err != nil { + return nil, fmt.Errorf("counting target corpus: %w", err) + } + + t := targetFromModel(model) + t.RepoName = repo.Name + t.RunCount = runCount + t.CorpusCount = corpusCount + return &t, nil +} + +func targetFromModel(m Target) cairnapi.Target { + return cairnapi.Target{ + ID: m.ID, + RepositoryID: m.RepositoryID, + Name: m.Name, + Type: m.Type, + Tags: m.Tags, + Metadata: m.Metadata, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + } +} + +// Run functions + +func CreateRun(ctx context.Context, db *gorm.DB, targetID, commitID uint) (*cairnapi.Run, error) { + run := &Run{ + TargetID: targetID, + CommitID: commitID, + Status: "running", + StartedAt: time.Now(), + Tags: json.RawMessage("{}"), + Metadata: json.RawMessage("{}"), + } + + if err := db.WithContext(ctx).Create(run).Error; err != nil { + return nil, fmt.Errorf("creating run: %w", err) + } + return enrichRun(ctx, db, *run) +} + +func FinishRun(ctx context.Context, db *gorm.DB, id uint) error { + now := time.Now() + if err := db.WithContext(ctx).Model(&Run{}).Where("id = ?", id).Updates(map[string]any{ + "status": "finished", + "finished_at": now, + }).Error; err != nil { + return fmt.Errorf("finishing run: %w", err) + } + return nil +} + +func GetRun(ctx context.Context, db *gorm.DB, id uint) (*cairnapi.Run, error) { + run := &Run{} + if err := db.WithContext(ctx).First(run, id).Error; err != nil { + return nil, fmt.Errorf("getting run: %w", err) + } + return enrichRun(ctx, db, *run) +} + +func ListRuns(ctx context.Context, db *gorm.DB, targetID *uint, limit, offset int) ([]cairnapi.Run, int64, error) { + if limit <= 0 { + limit = 50 + } + + query := db.WithContext(ctx).Model(&Run{}) + if targetID != nil { + query = query.Where("target_id = ?", *targetID) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("counting runs: %w", err) + } + + var dbRuns []Run + if err := query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&dbRuns).Error; err != nil { + return nil, 0, fmt.Errorf("listing runs: %w", err) + } + + runs := make([]cairnapi.Run, 0, len(dbRuns)) + for _, m := range dbRuns { + r, err := enrichRun(ctx, db, m) + if err != nil { + return nil, 0, err + } + runs = append(runs, *r) + } + + return runs, total, nil +} + +func enrichRun(ctx context.Context, db *gorm.DB, model Run) (*cairnapi.Run, error) { + target := &Target{} + if err := db.WithContext(ctx).First(target, model.TargetID).Error; err != nil { + return nil, fmt.Errorf("loading run target: %w", err) + } + + repo := &Repository{} + if err := db.WithContext(ctx).First(repo, target.RepositoryID).Error; err != nil { + return nil, fmt.Errorf("loading run repository: %w", err) + } + + commit := &Commit{} + if err := db.WithContext(ctx).First(commit, model.CommitID).Error; err != nil { + return nil, fmt.Errorf("loading run commit: %w", err) + } + + var artifactCount int64 + _ = db.WithContext(ctx).Model(&Artifact{}).Where("run_id = ?", model.ID).Count(&artifactCount).Error + + r := runFromModel(model) + r.TargetName = target.Name + r.RepoName = repo.Name + r.CommitSHA = commit.SHA + r.ArtifactCount = artifactCount + return &r, nil +} + +func runFromModel(m Run) cairnapi.Run { + return cairnapi.Run{ + ID: m.ID, + TargetID: m.TargetID, + CommitID: m.CommitID, + Status: m.Status, + StartedAt: m.StartedAt, + FinishedAt: m.FinishedAt, + Tags: m.Tags, + Metadata: m.Metadata, + CreatedAt: m.CreatedAt, + } +} + +// Corpus functions + +type CreateCorpusEntryParams struct { + TargetID uint + RunID *uint + BlobKey string + BlobSize int64 + Fingerprint *string +} + +func CreateCorpusEntry(ctx context.Context, db *gorm.DB, p CreateCorpusEntryParams) (*cairnapi.CorpusEntry, error) { + entry := &CorpusEntry{ + TargetID: p.TargetID, + RunID: p.RunID, + BlobKey: p.BlobKey, + BlobSize: p.BlobSize, + Fingerprint: p.Fingerprint, + } + + if err := db.WithContext(ctx).Create(entry).Error; err != nil { + return nil, fmt.Errorf("creating corpus entry: %w", err) + } + + return corpusEntryToAPI(*entry), nil +} + +func ListCorpusEntries(ctx context.Context, db *gorm.DB, targetID uint, limit, offset int) ([]cairnapi.CorpusEntry, int64, error) { + if limit <= 0 { + limit = 1000 + } + + query := db.WithContext(ctx).Model(&CorpusEntry{}).Where("target_id = ?", targetID) + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("counting corpus entries: %w", err) + } + + var dbEntries []CorpusEntry + if err := query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&dbEntries).Error; err != nil { + return nil, 0, fmt.Errorf("listing corpus entries: %w", err) + } + + entries := make([]cairnapi.CorpusEntry, 0, len(dbEntries)) + for _, e := range dbEntries { + entries = append(entries, *corpusEntryToAPI(e)) + } + + return entries, total, nil +} + +func corpusEntryToAPI(m CorpusEntry) *cairnapi.CorpusEntry { + return &cairnapi.CorpusEntry{ + ID: m.ID, + TargetID: m.TargetID, + RunID: m.RunID, + BlobKey: m.BlobKey, + BlobSize: m.BlobSize, + Fingerprint: m.Fingerprint, + CreatedAt: m.CreatedAt, + } +} diff --git a/internal/web/routes.go b/internal/web/routes.go index 0e1298c..dad129b 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -35,7 +35,9 @@ func NewRouter(cfg RouterConfig) (*gin.Engine, error) { crashGroupAPI := &handler.CrashGroupHandler{DB: cfg.DB} searchAPI := &handler.SearchHandler{DB: cfg.DB} regressionAPI := &handler.RegressionHandler{DB: cfg.DB, ForgejoSync: forgejoSync} - campaignAPI := &handler.CampaignHandler{DB: cfg.DB} + targetAPI := &handler.TargetHandler{DB: cfg.DB, Store: cfg.Store} + runAPI := &handler.RunHandler{DB: cfg.DB} + corpusAPI := &handler.CorpusHandler{DB: cfg.DB, Store: cfg.Store} dashboardAPI := &handler.DashboardHandler{DB: cfg.DB} webhookH := &handler.WebhookHandler{Sync: forgejoSync, Secret: cfg.WebhookSecret} @@ -57,8 +59,9 @@ func NewRouter(cfg RouterConfig) (*gin.Engine, error) { r.GET("/repos", pages.Repos) r.GET("/crashgroups", pages.CrashGroups) r.GET("/crashgroups/:id", pages.CrashGroupDetail) - r.GET("/campaigns", pages.Campaigns) - r.GET("/campaigns/:id", pages.CampaignDetail) + r.GET("/targets", pages.Targets) + r.GET("/targets/:id", pages.TargetDetail) + r.GET("/runs/:id", pages.RunDetail) r.GET("/search", pages.Search) r.GET("/regression", pages.Regression) @@ -72,10 +75,16 @@ func NewRouter(cfg RouterConfig) (*gin.Engine, error) { api.GET("/crashgroups/:id", crashGroupAPI.Detail) api.GET("/search", searchAPI.Search) api.POST("/regression/check", regressionAPI.Check) - api.POST("/campaigns", campaignAPI.Create) - api.GET("/campaigns", campaignAPI.List) - api.GET("/campaigns/:id", campaignAPI.Detail) - api.POST("/campaigns/:id/finish", campaignAPI.Finish) + api.POST("/targets", targetAPI.Ensure) + api.GET("/targets", targetAPI.List) + api.GET("/targets/:id", targetAPI.Detail) + api.POST("/targets/:id/corpus", corpusAPI.Upload) + api.GET("/targets/:id/corpus", corpusAPI.List) + api.GET("/targets/:id/corpus/:entry_id/download", corpusAPI.Download) + api.POST("/runs", runAPI.Start) + api.GET("/runs", runAPI.List) + api.GET("/runs/:id", runAPI.Detail) + api.POST("/runs/:id/finish", runAPI.Finish) api.GET("/dashboard", dashboardAPI.Stats) // Webhooks diff --git a/internal/web/templates.go b/internal/web/templates.go index 6ec7fac..8e550be 100644 --- a/internal/web/templates.go +++ b/internal/web/templates.go @@ -81,8 +81,9 @@ func LoadTemplates() (*Templates, error) { "templates/pages/crashgroup_detail.html", "templates/pages/search.html", "templates/pages/regression.html", - "templates/pages/campaigns.html", - "templates/pages/campaign_detail.html", + "templates/pages/targets.html", + "templates/pages/target_detail.html", + "templates/pages/run_detail.html", } pages := map[string]*template.Template{} diff --git a/internal/web/web.go b/internal/web/web.go index e59437d..17d0d5f 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -274,7 +274,7 @@ func (h *PageHandler) Regression(c *gin.Context) { _ = h.Templates.Render(c.Writer, "regression", data) } -func (h *PageHandler) Campaigns(c *gin.Context) { +func (h *PageHandler) Targets(c *gin.Context) { limit, _ := strconv.Atoi(c.Query("limit")) offset, _ := strconv.Atoi(c.Query("offset")) if limit <= 0 { @@ -287,51 +287,81 @@ func (h *PageHandler) Campaigns(c *gin.Context) { return } - campaigns, total, err := models.ListCampaigns(c.Request.Context(), h.DB, repoID, limit, offset) + targets, total, err := models.ListTargets(c.Request.Context(), h.DB, repoID, limit, offset) if err != nil { c.String(http.StatusInternalServerError, err.Error()) return } data := PageData{ - Title: "Campaigns", + Title: "Targets", Content: map[string]any{ - "Campaigns": campaigns, - "Total": int(total), + "Targets": targets, + "Total": int(total), }, } c.Header("Content-Type", "text/html; charset=utf-8") - _ = h.Templates.Render(c.Writer, "campaigns", data) + _ = h.Templates.Render(c.Writer, "targets", data) } -func (h *PageHandler) CampaignDetail(c *gin.Context) { - id, err := parseUintID(c.Param("id"), "campaign id") +func (h *PageHandler) TargetDetail(c *gin.Context) { + id, err := parseUintID(c.Param("id"), "target id") if err != nil { c.String(http.StatusBadRequest, err.Error()) return } - campaign, err := models.GetCampaign(c.Request.Context(), h.DB, id) + target, err := models.GetTarget(c.Request.Context(), h.DB, id) if err != nil { - c.String(http.StatusNotFound, "campaign not found") + c.String(http.StatusNotFound, "target not found") return } - campaignID := campaign.ID + targetID := target.ID + runs, _, _ := models.ListRuns(c.Request.Context(), h.DB, &targetID, 50, 0) + corpus, corpusTotal, _ := models.ListCorpusEntries(c.Request.Context(), h.DB, targetID, 50, 0) + + data := PageData{ + Title: "Target: " + target.Name, + Content: map[string]any{ + "Target": target, + "Runs": runs, + "Corpus": corpus, + "CorpusTotal": corpusTotal, + }, + } + c.Header("Content-Type", "text/html; charset=utf-8") + _ = h.Templates.Render(c.Writer, "target_detail", data) +} + +func (h *PageHandler) RunDetail(c *gin.Context) { + id, err := parseUintID(c.Param("id"), "run id") + if err != nil { + c.String(http.StatusBadRequest, err.Error()) + return + } + + run, err := models.GetRun(c.Request.Context(), h.DB, id) + if err != nil { + c.String(http.StatusNotFound, "run not found") + return + } + + runID := run.ID artifacts, _, _ := models.ListArtifacts(c.Request.Context(), h.DB, models.ListArtifactsParams{ - CampaignID: &campaignID, - Limit: 50, + RunID: &runID, + Limit: 50, }) data := PageData{ - Title: "Campaign: " + campaign.Name, + Title: fmt.Sprintf("Run #%d", run.ID), Content: map[string]any{ - "Campaign": campaign, + "Run": run, "Artifacts": artifacts, }, } c.Header("Content-Type", "text/html; charset=utf-8") - _ = h.Templates.Render(c.Writer, "campaign_detail", data) + _ = h.Templates.Render(c.Writer, "run_detail", data) } func parseUintID(raw string, field string) (uint, error) { diff --git a/web/static/css/cairn.css b/web/static/css/cairn.css index 9252f6b..7dd2d23 100644 --- a/web/static/css/cairn.css +++ b/web/static/css/cairn.css @@ -404,18 +404,23 @@ code { border-color: rgba(61, 122, 74, 0.3); } -/* ======= CAMPAIGN BADGES ======= */ -.badge-campaign-running { +/* ======= RUN BADGES ======= */ +.badge-run-running { background: rgba(224, 40, 72, 0.08); color: var(--accent-hover); border-color: rgba(224, 40, 72, 0.25); text-shadow: 0 0 6px var(--accent-glow); } -.badge-campaign-finished { +.badge-run-finished { background: rgba(61, 122, 74, 0.1); color: var(--success); border-color: rgba(61, 122, 74, 0.3); } +.badge-run-failed { + background: rgba(255, 34, 51, 0.1); + color: var(--danger); + border-color: rgba(255, 34, 51, 0.3); +} /* ======= SEARCH ======= */ .search-form { diff --git a/web/templates/layout.html b/web/templates/layout.html index bd8ab71..4fe746e 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -16,7 +16,7 @@
  • Dashboard
  • Artifacts
  • Crash Groups
  • -
  • Campaigns
  • +
  • Targets
  • Repositories
  • Regression
  • diff --git a/web/templates/pages/campaigns.html b/web/templates/pages/campaigns.html deleted file mode 100644 index e2a8197..0000000 --- a/web/templates/pages/campaigns.html +++ /dev/null @@ -1,38 +0,0 @@ -{{define "content"}} -
    -
    - {{.Total}} campaigns -
    - - {{if .Campaigns}} - - - - - - - - - - - - - - {{range .Campaigns}} - - - - - - - - - - {{end}} - -
    StatusNameTypeRepositoryArtifactsStarted
    {{.Status}}{{.Name}}{{.Type}}{{.RepoName}}{{.ArtifactCount}}{{timeAgo .StartedAt}}View
    - {{else}} -

    No campaigns yet. Use cairn campaign start to begin a campaign.

    - {{end}} -
    -{{end}} diff --git a/web/templates/pages/campaign_detail.html b/web/templates/pages/run_detail.html similarity index 69% rename from web/templates/pages/campaign_detail.html rename to web/templates/pages/run_detail.html index b015ee4..19b4afb 100644 --- a/web/templates/pages/campaign_detail.html +++ b/web/templates/pages/run_detail.html @@ -1,29 +1,31 @@ {{define "content"}} -
    +
    - {{.Campaign.Status}} - {{.Campaign.RepoName}} + {{.Run.Status}} + {{.Run.RepoName}} / {{.Run.TargetName}}
    -

    {{.Campaign.Name}}

    -
    - - {{.Campaign.Type}} + + {{.Run.TargetName}} +
    +
    + + {{shortSHA .Run.CommitSHA}}
    - {{.Campaign.ArtifactCount}} + {{.Run.ArtifactCount}}
    - {{timeAgo .Campaign.StartedAt}} + {{timeAgo .Run.StartedAt}}
    - {{if .Campaign.FinishedAt}} + {{if .Run.FinishedAt}}
    - {{timeAgo (derefTime .Campaign.FinishedAt)}} + {{timeAgo (derefTime .Run.FinishedAt)}}
    {{end}}
    @@ -56,7 +58,7 @@ {{else}} -

    No artifacts in this campaign yet.

    +

    No artifacts in this run.

    {{end}}
    diff --git a/web/templates/pages/target_detail.html b/web/templates/pages/target_detail.html new file mode 100644 index 0000000..dcc3ebf --- /dev/null +++ b/web/templates/pages/target_detail.html @@ -0,0 +1,87 @@ +{{define "content"}} +
    +
    + {{.Target.Type}} + {{.Target.RepoName}} +
    + +

    {{.Target.Name}}

    + +
    +
    + + {{.Target.Type}} +
    +
    + + {{.Target.RunCount}} +
    +
    + + {{.Target.CorpusCount}} +
    +
    + + {{timeAgo .Target.CreatedAt}} +
    +
    + +
    +

    Runs

    + {{if .Runs}} + + + + + + + + + + + + + {{range .Runs}} + + + + + + + + + {{end}} + +
    StatusCommitArtifactsStartedFinished
    {{.Status}}{{shortSHA .CommitSHA}}{{.ArtifactCount}}{{timeAgo .StartedAt}}{{if .FinishedAt}}{{timeAgo (derefTime .FinishedAt)}}{{else}}-{{end}}View
    + {{else}} +

    No runs yet.

    + {{end}} +
    + +
    +

    Corpus ({{.CorpusTotal}} entries)

    + {{if .Corpus}} + + + + + + + + + + {{range .Corpus}} + + + + + + {{end}} + +
    IDSizeAdded
    #{{.ID}}{{formatSize .BlobSize}}{{timeAgo .CreatedAt}}
    + {{else}} +

    No corpus entries yet.

    + {{end}} +
    +
    +{{end}} diff --git a/web/templates/pages/targets.html b/web/templates/pages/targets.html new file mode 100644 index 0000000..6e640e2 --- /dev/null +++ b/web/templates/pages/targets.html @@ -0,0 +1,38 @@ +{{define "content"}} +
    +
    + {{.Total}} targets +
    + + {{if .Targets}} + + + + + + + + + + + + + + {{range .Targets}} + + + + + + + + + + {{end}} + +
    NameTypeRepositoryRunsCorpusUpdated
    {{.Name}}{{.Type}}{{.RepoName}}{{.RunCount}}{{.CorpusCount}}{{timeAgo .UpdatedAt}}View
    + {{else}} +

    No targets yet. Use cairn target ensure to register a target.

    + {{end}} +
    +{{end}}