Break campaign into targets and runs

This commit is contained in:
Matthew Knight 2026-03-05 23:46:15 -08:00
parent 90d61f1a33
commit edff8885f0
No known key found for this signature in database
22 changed files with 1315 additions and 492 deletions

View File

@ -1,5 +1,5 @@
name: 'Cairn Zig Fuzz (AFL++)' 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=<name>`.'
inputs: inputs:
cairn_server: cairn_server:
@ -15,22 +15,18 @@ inputs:
description: 'Commit SHA' description: 'Commit SHA'
required: false required: false
default: '${{ github.sha }}' default: '${{ github.sha }}'
zig_build_args: targets:
description: | 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=<name>.
Example: Example:
fuzz -Dfuzz-target=lexer lexer
fuzz -Dfuzz-target=parser parser
fuzz -Dfuzz-target=varint_decode varint_decode
required: true required: true
fuzz_binary: fuzz_binary:
description: 'Binary name in zig-out/bin/ (auto-detected if only one)' description: 'Binary name in zig-out/bin/ (auto-detected if only one)'
required: false required: false
default: '' default: ''
corpus_dir:
description: 'Seed corpus directory (minimal seed created if empty)'
required: false
default: ''
duration: duration:
description: 'Fuzz duration per target in seconds' description: 'Fuzz duration per target in seconds'
required: false required: false
@ -60,15 +56,6 @@ runs:
set -eu 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 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; } 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" echo "zig $(zig version), afl-cc found"
- name: Setup Cairn CLI - name: Setup Cairn CLI
@ -84,62 +71,65 @@ runs:
REPO: ${{ inputs.repo }} REPO: ${{ inputs.repo }}
OWNER: ${{ inputs.owner }} OWNER: ${{ inputs.owner }}
COMMIT: ${{ inputs.commit }} COMMIT: ${{ inputs.commit }}
ZIG_BUILD_ARGS: ${{ inputs.zig_build_args }} TARGETS: ${{ inputs.targets }}
FUZZ_BINARY: ${{ inputs.fuzz_binary }} FUZZ_BINARY: ${{ inputs.fuzz_binary }}
CORPUS_DIR: ${{ inputs.corpus_dir }}
DURATION: ${{ inputs.duration }} DURATION: ${{ inputs.duration }}
EXTRA_AFL_ARGS: ${{ inputs.afl_args }} EXTRA_AFL_ARGS: ${{ inputs.afl_args }}
TARGET: ${{ inputs.target }} TARGET_PLATFORM: ${{ inputs.target }}
run: | run: |
set -eu 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 TOTAL_CRASHES=0
TARGET_NUM=0 TARGET_NUM=0
# ── Iterate over each line of zig_build_args ── # ── Iterate over each target name ──
while IFS= read -r BUILD_ARGS; do while IFS= read -r FUZZ_TARGET; do
# Skip empty lines and comments # Skip empty lines and comments
BUILD_ARGS=$(echo "${BUILD_ARGS}" | sed 's/#.*//' | xargs) FUZZ_TARGET=$(echo "${FUZZ_TARGET}" | sed 's/#.*//' | xargs)
[ -z "${BUILD_ARGS}" ] && continue [ -z "${FUZZ_TARGET}" ] && continue
TARGET_NUM=$((TARGET_NUM + 1)) TARGET_NUM=$((TARGET_NUM + 1))
BUILD_ARGS="fuzz -Dfuzz-target=${FUZZ_TARGET}"
echo "" echo ""
echo "==========================================" echo "=========================================="
echo "Target ${TARGET_NUM}: zig build ${BUILD_ARGS}" echo "Target ${TARGET_NUM}: ${FUZZ_TARGET} (zig build ${BUILD_ARGS})"
echo "==========================================" echo "=========================================="
# Special-case Zig fuzz targets: if build args contain -Dfuzz-target=<name>, # ── Ensure Cairn target ──
# use that as the effective Cairn target for metadata/track keying. CAIRN_TARGET_ID=$(cairn target ensure \
LINE_FUZZ_TARGET=$(printf '%s' "${BUILD_ARGS}" | sed -n 's/.*-Dfuzz-target=\([^[:space:]]*\).*/\1/p') -server "${CAIRN_SERVER}" \
EFFECTIVE_TARGET="${TARGET}" -repo "${REPO}" \
if [ -n "${LINE_FUZZ_TARGET}" ]; then -owner "${OWNER}" \
EFFECTIVE_TARGET="${LINE_FUZZ_TARGET}" -name "${FUZZ_TARGET}" \
fi -type fuzz)
echo "Cairn target ID: ${CAIRN_TARGET_ID}"
TRACK_SOURCE="${OWNER}/${REPO}|${EFFECTIVE_TARGET}|${BUILD_ARGS}" # ── Start a run ──
TRACK_KEY=$(printf '%s' "${TRACK_SOURCE}" | sha256sum | awk '{print $1}') RUN_ID=$(cairn run start \
echo "Track key: ${TRACK_KEY}" -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 ── # ── Build ──
rm -rf zig-out rm -rf zig-out
@ -167,31 +157,6 @@ runs:
fi fi
echo "Fuzz binary: ${FUZZ_BIN}" 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++ ── # ── Run AFL++ ──
FINDINGS="findings-${TARGET_NUM}" FINDINGS="findings-${TARGET_NUM}"
rm -rf "${FINDINGS}" rm -rf "${FINDINGS}"
@ -234,12 +199,12 @@ runs:
echo "Uploading crash: ${CRASH_NAME}" echo "Uploading crash: ${CRASH_NAME}"
set -- -server "${CAIRN_SERVER}" -repo "${REPO}" -owner "${OWNER}" \ set -- -server "${CAIRN_SERVER}" -repo "${REPO}" -owner "${OWNER}" \
-commit "${COMMIT}" -campaign-id "${CAMPAIGN_ID}" -type fuzz -file "${crash_file}" \ -commit "${COMMIT}" -run-id "${RUN_ID}" -type fuzz -file "${crash_file}" \
-kind crash -track-key "${TRACK_KEY}" \ -kind crash \
-crash-message "AFL++ crash (${BUILD_ARGS}): ${CRASH_NAME}" -crash-message "AFL++ crash (${FUZZ_TARGET}): ${CRASH_NAME}"
if [ -n "${EFFECTIVE_TARGET}" ]; then if [ -n "${TARGET_PLATFORM}" ]; then
set -- "$@" -target "${EFFECTIVE_TARGET}" set -- "$@" -target "${TARGET_PLATFORM}"
fi fi
if [ -n "${SIG}" ]; then if [ -n "${SIG}" ]; then
set -- "$@" -signal "${SIG}" set -- "$@" -signal "${SIG}"
@ -250,29 +215,26 @@ runs:
done done
fi fi
# ── Upload corpus ── # ── Upload new corpus entries ──
QUEUE_DIR="${FINDINGS}/default/queue" QUEUE_DIR="${FINDINGS}/default/queue"
if [ -d "${QUEUE_DIR}" ]; then if [ -d "${QUEUE_DIR}" ]; then
QUEUE_COUNT=$(find "${QUEUE_DIR}" -maxdepth 1 -type f -name 'id:*' | wc -l) QUEUE_COUNT=$(find "${QUEUE_DIR}" -maxdepth 1 -type f -name 'id:*' | wc -l)
if [ "${QUEUE_COUNT}" -gt 0 ]; then if [ "${QUEUE_COUNT}" -gt 0 ]; then
echo "Uploading corpus (${QUEUE_COUNT} entries)..." echo "Uploading corpus (${QUEUE_COUNT} entries)..."
tar czf "corpus-${TARGET_NUM}.tar.gz" -C "${QUEUE_DIR}" . cairn corpus upload \
-server "${CAIRN_SERVER}" \
set -- -server "${CAIRN_SERVER}" -repo "${REPO}" -owner "${OWNER}" \ -target-id "${CAIRN_TARGET_ID}" \
-commit "${COMMIT}" -campaign-id "${CAMPAIGN_ID}" -type fuzz \ -run-id "${RUN_ID}" \
-kind corpus -track-key "${TRACK_KEY}" \ -dir "${QUEUE_DIR}"
-file "corpus-${TARGET_NUM}.tar.gz" fi
if [ -n "${EFFECTIVE_TARGET}" ]; then
set -- "$@" -target "${EFFECTIVE_TARGET}"
fi fi
cairn upload "$@" # ── Finish run ──
rm -f "corpus-${TARGET_NUM}.tar.gz" finish_run
fi trap - EXIT
fi
done <<EOF done <<EOF
${ZIG_BUILD_ARGS} ${TARGETS}
EOF EOF
# ── Final report ── # ── Final report ──

View File

@ -133,18 +133,32 @@ func TestCLIUploadAndDownloadRoundTrip(t *testing.T) {
serverURL, db, cleanup, _ := setupCLIServer(t, false) serverURL, db, cleanup, _ := setupCLIServer(t, false)
defer cleanup() defer cleanup()
if err := cmdCampaign("start", []string{ // Create a target first.
if err := cmdTarget("ensure", []string{
"-server", serverURL, "-server", serverURL,
"-repo", "demo", "-repo", "demo",
"-owner", "acme", "-owner", "acme",
"-name", "seed-campaign", "-name", "seed-target",
"-type", "fuzz", "-type", "fuzz",
}); err != nil { }); err != nil {
t.Fatalf("cmdCampaign start failed: %v", err) t.Fatalf("cmdTarget ensure failed: %v", err)
} }
var campaign models.Campaign var target models.Target
if err := db.First(&campaign).Error; err != nil { if err := db.First(&target).Error; err != nil {
t.Fatalf("querying campaign: %v", err) t.Fatalf("querying target: %v", err)
}
// Start a run.
if err := cmdRun("start", []string{
"-server", serverURL,
"-target-id", strconv.FormatUint(uint64(target.ID), 10),
"-commit", "abcdef1234567890",
}); err != nil {
t.Fatalf("cmdRun start failed: %v", err)
}
var run models.Run
if err := db.First(&run).Error; err != nil {
t.Fatalf("querying run: %v", err)
} }
artifactFile := filepath.Join(t.TempDir(), "artifact.bin") artifactFile := filepath.Join(t.TempDir(), "artifact.bin")
@ -158,7 +172,7 @@ func TestCLIUploadAndDownloadRoundTrip(t *testing.T) {
"-repo", "demo", "-repo", "demo",
"-owner", "acme", "-owner", "acme",
"-commit", "abcdef1234567890", "-commit", "abcdef1234567890",
"-campaign-id", strconv.FormatUint(uint64(campaign.ID), 10), "-run-id", strconv.FormatUint(uint64(run.ID), 10),
"-type", "fuzz", "-type", "fuzz",
"-file", artifactFile, "-file", artifactFile,
"-crash-message", "boom", "-crash-message", "boom",
@ -188,8 +202,8 @@ func TestCLIUploadAndDownloadRoundTrip(t *testing.T) {
if got := md["track_key"]; got != "track-123" { if got := md["track_key"]; got != "track-123" {
t.Fatalf("expected metadata.track_key=track-123, got %#v", got) t.Fatalf("expected metadata.track_key=track-123, got %#v", got)
} }
if a.CampaignID == nil || *a.CampaignID != campaign.ID { if a.RunID == nil || *a.RunID != run.ID {
t.Fatalf("expected campaign_id=%d, got %#v", campaign.ID, a.CampaignID) t.Fatalf("expected run_id=%d, got %#v", run.ID, a.RunID)
} }
outFile := filepath.Join(t.TempDir(), "downloaded.bin") outFile := filepath.Join(t.TempDir(), "downloaded.bin")
@ -206,40 +220,73 @@ func TestCLIUploadAndDownloadRoundTrip(t *testing.T) {
} }
} }
func TestCLICampaignStartAndFinish(t *testing.T) { func TestCLITargetEnsureAndRunStartFinish(t *testing.T) {
serverURL, db, cleanup, _ := setupCLIServer(t, false) serverURL, db, cleanup, _ := setupCLIServer(t, false)
defer cleanup() defer cleanup()
if err := cmdCampaign("start", []string{ if err := cmdTarget("ensure", []string{
"-server", serverURL, "-server", serverURL,
"-repo", "demo", "-repo", "demo",
"-owner", "acme", "-owner", "acme",
"-name", "nightly", "-name", "nightly",
"-type", "fuzz", "-type", "fuzz",
}); err != nil { }); err != nil {
t.Fatalf("cmdCampaign start failed: %v", err) t.Fatalf("cmdTarget ensure failed: %v", err)
} }
var campaign models.Campaign var target models.Target
if err := db.First(&campaign).Error; err != nil { if err := db.First(&target).Error; err != nil {
t.Fatalf("querying campaign: %v", err) t.Fatalf("querying target: %v", err)
} }
if campaign.Status != "running" { if target.Name != "nightly" {
t.Fatalf("expected running campaign, got %q", campaign.Status) t.Fatalf("expected target name=nightly, got %q", target.Name)
} }
if err := cmdCampaign("finish", []string{ // Ensure is idempotent.
if err := cmdTarget("ensure", []string{
"-server", serverURL, "-server", serverURL,
"-id", strconv.FormatUint(uint64(campaign.ID), 10), "-repo", "demo",
"-owner", "acme",
"-name", "nightly",
"-type", "fuzz",
}); err != nil { }); err != nil {
t.Fatalf("cmdCampaign finish failed: %v", err) t.Fatalf("cmdTarget ensure (idempotent) failed: %v", err)
}
var count int64
db.Model(&models.Target{}).Count(&count)
if count != 1 {
t.Fatalf("expected 1 target after idempotent ensure, got %d", count)
} }
if err := db.First(&campaign, campaign.ID).Error; err != nil { // Start a run.
t.Fatalf("re-querying campaign: %v", err) if err := cmdRun("start", []string{
"-server", serverURL,
"-target-id", strconv.FormatUint(uint64(target.ID), 10),
"-commit", "abc123",
}); err != nil {
t.Fatalf("cmdRun start failed: %v", err)
} }
if campaign.Status != "finished" {
t.Fatalf("expected finished campaign, got %q", campaign.Status) var run models.Run
if err := db.First(&run).Error; err != nil {
t.Fatalf("querying run: %v", err)
}
if run.Status != "running" {
t.Fatalf("expected running run, got %q", run.Status)
}
if err := cmdRun("finish", []string{
"-server", serverURL,
"-id", strconv.FormatUint(uint64(run.ID), 10),
}); err != nil {
t.Fatalf("cmdRun finish failed: %v", err)
}
if err := db.First(&run, run.ID).Error; err != nil {
t.Fatalf("re-querying run: %v", err)
}
if run.Status != "finished" {
t.Fatalf("expected finished run, got %q", run.Status)
} }
} }

View File

@ -34,12 +34,30 @@ func main() {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
case "campaign": case "target":
if len(os.Args) < 3 { if len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "usage: cairn campaign <start|finish>\n") fmt.Fprintf(os.Stderr, "usage: cairn target <ensure>\n")
os.Exit(1) 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 <start|finish>\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 <upload|download>\n")
os.Exit(1)
}
if err := cmdCorpus(os.Args[2], os.Args[3:]); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@ -59,10 +77,13 @@ func usage() {
fmt.Fprintf(os.Stderr, `Usage: cairn <command> [args] fmt.Fprintf(os.Stderr, `Usage: cairn <command> [args]
Commands: Commands:
upload Upload an artifact to Cairn upload Upload a crash artifact
check Check for regressions between two commits check Check for regressions between two commits
campaign start Start a new campaign target ensure Get or create a target (idempotent)
campaign finish Finish a running campaign 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 download Download an artifact
Upload flags: Upload flags:
@ -70,7 +91,7 @@ Upload flags:
-repo NAME Repository name (required) -repo NAME Repository name (required)
-owner OWNER Repository owner (required) -owner OWNER Repository owner (required)
-commit SHA Commit SHA (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) -type TYPE Artifact type: coredump, fuzz, sanitizer, simulation (required)
-file PATH Path to artifact file (required) -file PATH Path to artifact file (required)
-crash-message MSG Crash message (optional) -crash-message MSG Crash message (optional)
@ -89,7 +110,7 @@ func cmdUpload(args []string) error {
repo string repo string
owner string owner string
commitSHA string commitSHA string
campaignID string runID string
artifactType string artifactType string
filePath string filePath string
crashMessage string crashMessage string
@ -115,9 +136,9 @@ func cmdUpload(args []string) error {
case "-commit": case "-commit":
i++ i++
commitSHA = args[i] commitSHA = args[i]
case "-campaign-id": case "-run-id":
i++ i++
campaignID = args[i] runID = args[i]
case "-type": case "-type":
i++ i++
artifactType = args[i] artifactType = args[i]
@ -160,12 +181,12 @@ func cmdUpload(args []string) error {
"commit_sha": commitSHA, "commit_sha": commitSHA,
"type": artifactType, "type": artifactType,
} }
if campaignID != "" { if runID != "" {
id, err := strconv.ParseUint(campaignID, 10, 64) id, err := strconv.ParseUint(runID, 10, 64)
if err != nil { 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 != "" { if crashMessage != "" {
meta["crash_message"] = crashMessage meta["crash_message"] = crashMessage
@ -316,12 +337,12 @@ func cmdCheck(args []string) error {
return nil return nil
} }
func cmdCampaign(subcmd string, args []string) error { func cmdTarget(subcmd string, args []string) error {
serverURL := envOr("CAIRN_SERVER_URL", "http://localhost:8080") serverURL := envOr("CAIRN_SERVER_URL", "http://localhost:8080")
switch subcmd { switch subcmd {
case "start": case "ensure":
var repo, owner, name, ctype string var repo, owner, name, ttype string
for i := 0; i < len(args); i++ { for i := 0; i < len(args); i++ {
switch args[i] { switch args[i] {
case "-server": case "-server":
@ -338,18 +359,71 @@ func cmdCampaign(subcmd string, args []string) error {
name = args[i] name = args[i]
case "-type": case "-type":
i++ i++
ctype = args[i] ttype = args[i]
default: default:
return fmt.Errorf("unknown flag: %s", args[i]) 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") return fmt.Errorf("required flags: -repo, -owner, -name, -type")
} }
body, _ := json.Marshal(map[string]string{ 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 { if err != nil {
return err return err
} }
@ -362,7 +436,8 @@ func cmdCampaign(subcmd string, args []string) error {
if err := json.Unmarshal(respBody, &result); err != nil { if err := json.Unmarshal(respBody, &result); err != nil {
return fmt.Errorf("parsing response: %w", err) 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": case "finish":
var id string var id string
@ -381,7 +456,7 @@ func cmdCampaign(subcmd string, args []string) error {
if id == "" { if id == "" {
return fmt.Errorf("required flag: -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 { if err != nil {
return err return err
} }
@ -390,10 +465,174 @@ func cmdCampaign(subcmd string, args []string) error {
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("server returned %d: %s", resp.StatusCode, body) return fmt.Errorf("server returned %d: %s", resp.StatusCode, body)
} }
fmt.Println("Campaign finished") fmt.Println("Run finished")
default: 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 return nil
} }

View File

@ -29,7 +29,7 @@ type Artifact struct {
RepositoryID uint `json:"repository_id"` RepositoryID uint `json:"repository_id"`
CommitID uint `json:"commit_id"` CommitID uint `json:"commit_id"`
BuildID *uint `json:"build_id,omitempty"` 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"` CrashSignatureID *uint `json:"crash_signature_id,omitempty"`
Type string `json:"type"` Type string `json:"type"`
BlobKey string `json:"blob_key"` BlobKey string `json:"blob_key"`
@ -44,11 +44,25 @@ type Artifact struct {
CommitSHA string `json:"commit_sha,omitempty"` CommitSHA string `json:"commit_sha,omitempty"`
} }
type Campaign struct { type Target struct {
ID uint `json:"id"` ID uint `json:"id"`
RepositoryID uint `json:"repository_id"` RepositoryID uint `json:"repository_id"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
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"` Status string `json:"status"`
StartedAt time.Time `json:"started_at"` StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"` FinishedAt *time.Time `json:"finished_at,omitempty"`
@ -56,10 +70,22 @@ type Campaign struct {
Metadata json.RawMessage `json:"metadata,omitempty"` Metadata json.RawMessage `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
TargetName string `json:"target_name,omitempty"`
RepoName string `json:"repo_name,omitempty"` RepoName string `json:"repo_name,omitempty"`
CommitSHA string `json:"commit_sha,omitempty"`
ArtifactCount int64 `json:"artifact_count,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 { type CrashSignature struct {
ID uint `json:"id"` ID uint `json:"id"`
RepositoryID uint `json:"repository_id"` RepositoryID uint `json:"repository_id"`

View File

@ -12,7 +12,9 @@ func Migrate(db *gorm.DB) error {
&models.Repository{}, &models.Repository{},
&models.Commit{}, &models.Commit{},
&models.Build{}, &models.Build{},
&models.Campaign{}, &models.Target{},
&models.Run{},
&models.CorpusEntry{},
&models.CrashSignature{}, &models.CrashSignature{},
&models.CrashGroup{}, &models.CrashGroup{},
&models.Artifact{}, &models.Artifact{},

View File

@ -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"})
}

View File

@ -18,7 +18,7 @@ type DashboardStats struct {
TotalRepos int64 `json:"total_repos"` TotalRepos int64 `json:"total_repos"`
TotalCrashGroups int64 `json:"total_crash_groups"` TotalCrashGroups int64 `json:"total_crash_groups"`
OpenCrashGroups int64 `json:"open_crash_groups"` OpenCrashGroups int64 `json:"open_crash_groups"`
ActiveCampaigns int64 `json:"active_campaigns"` TotalTargets int64 `json:"total_targets"`
} }
type TrendPoint struct { 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.Repository{}).Count(&stats.TotalRepos).Error
_ = h.DB.WithContext(ctx).Model(&models.CrashGroup{}).Count(&stats.TotalCrashGroups).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.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. // Artifact trend for the last 30 days.
var trend []TrendPoint var trend []TrendPoint

View File

@ -25,7 +25,7 @@ type IngestRequest struct {
Repository string `json:"repository"` Repository string `json:"repository"`
Owner string `json:"owner"` Owner string `json:"owner"`
CommitSHA string `json:"commit_sha"` CommitSHA string `json:"commit_sha"`
CampaignID *uint `json:"campaign_id,omitempty"` RunID *uint `json:"run_id,omitempty"`
Type string `json:"type"` Type string `json:"type"`
CrashMessage string `json:"crash_message,omitempty"` CrashMessage string `json:"crash_message,omitempty"`
StackTrace string `json:"stack_trace,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{ artifact, err := models.CreateArtifact(ctx, h.DB, models.CreateArtifactParams{
RepositoryID: repo.ID, RepositoryID: repo.ID,
CommitID: commit.ID, CommitID: commit.ID,
CampaignID: req.CampaignID, RunID: req.RunID,
Type: req.Type, Type: req.Type,
BlobKey: blobKey, BlobKey: blobKey,
BlobSize: header.Size, BlobSize: header.Size,

319
internal/handler/targets.go Normal file
View File

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

View File

@ -13,7 +13,7 @@ type CreateArtifactParams struct {
RepositoryID uint RepositoryID uint
CommitID uint CommitID uint
BuildID *uint BuildID *uint
CampaignID *uint RunID *uint
Type string Type string
BlobKey string BlobKey string
BlobSize int64 BlobSize int64
@ -35,7 +35,7 @@ func CreateArtifact(ctx context.Context, db *gorm.DB, p CreateArtifactParams) (*
RepositoryID: p.RepositoryID, RepositoryID: p.RepositoryID,
CommitID: p.CommitID, CommitID: p.CommitID,
BuildID: p.BuildID, BuildID: p.BuildID,
CampaignID: p.CampaignID, RunID: p.RunID,
Type: p.Type, Type: p.Type,
BlobKey: p.BlobKey, BlobKey: p.BlobKey,
BlobSize: p.BlobSize, BlobSize: p.BlobSize,
@ -64,7 +64,7 @@ type ListArtifactsParams struct {
CommitSHA string CommitSHA string
Type string Type string
SignatureID *uint SignatureID *uint
CampaignID *uint RunID *uint
Limit int Limit int
Offset int Offset int
} }
@ -85,8 +85,8 @@ func ListArtifacts(ctx context.Context, db *gorm.DB, p ListArtifactsParams) ([]c
if p.SignatureID != nil { if p.SignatureID != nil {
query = query.Where("crash_signature_id = ?", *p.SignatureID) query = query.Where("crash_signature_id = ?", *p.SignatureID)
} }
if p.CampaignID != nil { if p.RunID != nil {
query = query.Where("campaign_id = ?", *p.CampaignID) query = query.Where("run_id = ?", *p.RunID)
} }
if p.CommitSHA != "" { if p.CommitSHA != "" {
query = query.Joins("JOIN commits ON commits.id = artifacts.commit_id").Where("commits.sha = ?", 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, RepositoryID: m.RepositoryID,
CommitID: m.CommitID, CommitID: m.CommitID,
BuildID: m.BuildID, BuildID: m.BuildID,
CampaignID: m.CampaignID, RunID: m.RunID,
CrashSignatureID: m.CrashSignatureID, CrashSignatureID: m.CrashSignatureID,
Type: m.Type, Type: m.Type,
BlobKey: m.BlobKey, BlobKey: m.BlobKey,

View File

@ -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,
}
}

View File

@ -50,7 +50,7 @@ type Artifact struct {
RepositoryID uint `gorm:"not null;index"` RepositoryID uint `gorm:"not null;index"`
CommitID uint `gorm:"not null;index"` CommitID uint `gorm:"not null;index"`
BuildID *uint `gorm:"index"` BuildID *uint `gorm:"index"`
CampaignID *uint `gorm:"index"` RunID *uint `gorm:"index"`
CrashSignatureID *uint `gorm:"index"` CrashSignatureID *uint `gorm:"index"`
Type string `gorm:"not null;index"` Type string `gorm:"not null;index"`
BlobKey string `gorm:"not null"` BlobKey string `gorm:"not null"`
@ -64,17 +64,31 @@ type Artifact struct {
Repository Repository `gorm:"foreignKey:RepositoryID"` Repository Repository `gorm:"foreignKey:RepositoryID"`
Commit Commit `gorm:"foreignKey:CommitID"` Commit Commit `gorm:"foreignKey:CommitID"`
Build *Build `gorm:"foreignKey:BuildID"` Build *Build `gorm:"foreignKey:BuildID"`
Campaign *Campaign `gorm:"foreignKey:CampaignID"` Run *Run `gorm:"foreignKey:RunID"`
Signature *CrashSignature `gorm:"foreignKey:CrashSignatureID"` Signature *CrashSignature `gorm:"foreignKey:CrashSignatureID"`
} }
func (Artifact) TableName() string { return "artifacts" } func (Artifact) TableName() string { return "artifacts" }
type Campaign struct { type Target struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
RepositoryID uint `gorm:"not null;index"` RepositoryID uint `gorm:"not null;index:idx_targets_repo_name,unique"`
Name string `gorm:"not null"` Name string `gorm:"not null;index:idx_targets_repo_name,unique"`
Type string `gorm:"not null"` 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 (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"` Status string `gorm:"not null;default:running;index"`
StartedAt time.Time `gorm:"not null;autoCreateTime"` StartedAt time.Time `gorm:"not null;autoCreateTime"`
FinishedAt *time.Time FinishedAt *time.Time
@ -82,10 +96,26 @@ type Campaign struct {
Metadata json.RawMessage `gorm:"type:jsonb;default:'{}'"` Metadata json.RawMessage `gorm:"type:jsonb;default:'{}'"`
CreatedAt time.Time CreatedAt time.Time
Repository Repository `gorm:"foreignKey:RepositoryID"` Target Target `gorm:"foreignKey:TargetID"`
Commit Commit `gorm:"foreignKey:CommitID"`
} }
func (Campaign) TableName() string { return "campaigns" } 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 { type CrashSignature struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`

299
internal/models/target.go Normal file
View File

@ -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,
}
}

View File

@ -35,7 +35,9 @@ func NewRouter(cfg RouterConfig) (*gin.Engine, error) {
crashGroupAPI := &handler.CrashGroupHandler{DB: cfg.DB} crashGroupAPI := &handler.CrashGroupHandler{DB: cfg.DB}
searchAPI := &handler.SearchHandler{DB: cfg.DB} searchAPI := &handler.SearchHandler{DB: cfg.DB}
regressionAPI := &handler.RegressionHandler{DB: cfg.DB, ForgejoSync: forgejoSync} 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} dashboardAPI := &handler.DashboardHandler{DB: cfg.DB}
webhookH := &handler.WebhookHandler{Sync: forgejoSync, Secret: cfg.WebhookSecret} 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("/repos", pages.Repos)
r.GET("/crashgroups", pages.CrashGroups) r.GET("/crashgroups", pages.CrashGroups)
r.GET("/crashgroups/:id", pages.CrashGroupDetail) r.GET("/crashgroups/:id", pages.CrashGroupDetail)
r.GET("/campaigns", pages.Campaigns) r.GET("/targets", pages.Targets)
r.GET("/campaigns/:id", pages.CampaignDetail) r.GET("/targets/:id", pages.TargetDetail)
r.GET("/runs/:id", pages.RunDetail)
r.GET("/search", pages.Search) r.GET("/search", pages.Search)
r.GET("/regression", pages.Regression) r.GET("/regression", pages.Regression)
@ -72,10 +75,16 @@ func NewRouter(cfg RouterConfig) (*gin.Engine, error) {
api.GET("/crashgroups/:id", crashGroupAPI.Detail) api.GET("/crashgroups/:id", crashGroupAPI.Detail)
api.GET("/search", searchAPI.Search) api.GET("/search", searchAPI.Search)
api.POST("/regression/check", regressionAPI.Check) api.POST("/regression/check", regressionAPI.Check)
api.POST("/campaigns", campaignAPI.Create) api.POST("/targets", targetAPI.Ensure)
api.GET("/campaigns", campaignAPI.List) api.GET("/targets", targetAPI.List)
api.GET("/campaigns/:id", campaignAPI.Detail) api.GET("/targets/:id", targetAPI.Detail)
api.POST("/campaigns/:id/finish", campaignAPI.Finish) 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) api.GET("/dashboard", dashboardAPI.Stats)
// Webhooks // Webhooks

View File

@ -81,8 +81,9 @@ func LoadTemplates() (*Templates, error) {
"templates/pages/crashgroup_detail.html", "templates/pages/crashgroup_detail.html",
"templates/pages/search.html", "templates/pages/search.html",
"templates/pages/regression.html", "templates/pages/regression.html",
"templates/pages/campaigns.html", "templates/pages/targets.html",
"templates/pages/campaign_detail.html", "templates/pages/target_detail.html",
"templates/pages/run_detail.html",
} }
pages := map[string]*template.Template{} pages := map[string]*template.Template{}

View File

@ -274,7 +274,7 @@ func (h *PageHandler) Regression(c *gin.Context) {
_ = h.Templates.Render(c.Writer, "regression", data) _ = 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")) limit, _ := strconv.Atoi(c.Query("limit"))
offset, _ := strconv.Atoi(c.Query("offset")) offset, _ := strconv.Atoi(c.Query("offset"))
if limit <= 0 { if limit <= 0 {
@ -287,51 +287,81 @@ func (h *PageHandler) Campaigns(c *gin.Context) {
return 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 { if err != nil {
c.String(http.StatusInternalServerError, err.Error()) c.String(http.StatusInternalServerError, err.Error())
return return
} }
data := PageData{ data := PageData{
Title: "Campaigns", Title: "Targets",
Content: map[string]any{ Content: map[string]any{
"Campaigns": campaigns, "Targets": targets,
"Total": int(total), "Total": int(total),
}, },
} }
c.Header("Content-Type", "text/html; charset=utf-8") 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) { func (h *PageHandler) TargetDetail(c *gin.Context) {
id, err := parseUintID(c.Param("id"), "campaign id") id, err := parseUintID(c.Param("id"), "target id")
if err != nil { if err != nil {
c.String(http.StatusBadRequest, err.Error()) c.String(http.StatusBadRequest, err.Error())
return return
} }
campaign, err := models.GetCampaign(c.Request.Context(), h.DB, id) target, err := models.GetTarget(c.Request.Context(), h.DB, id)
if err != nil { if err != nil {
c.String(http.StatusNotFound, "campaign not found") c.String(http.StatusNotFound, "target not found")
return 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{ artifacts, _, _ := models.ListArtifacts(c.Request.Context(), h.DB, models.ListArtifactsParams{
CampaignID: &campaignID, RunID: &runID,
Limit: 50, Limit: 50,
}) })
data := PageData{ data := PageData{
Title: "Campaign: " + campaign.Name, Title: fmt.Sprintf("Run #%d", run.ID),
Content: map[string]any{ Content: map[string]any{
"Campaign": campaign, "Run": run,
"Artifacts": artifacts, "Artifacts": artifacts,
}, },
} }
c.Header("Content-Type", "text/html; charset=utf-8") 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) { func parseUintID(raw string, field string) (uint, error) {

View File

@ -404,18 +404,23 @@ code {
border-color: rgba(61, 122, 74, 0.3); border-color: rgba(61, 122, 74, 0.3);
} }
/* ======= CAMPAIGN BADGES ======= */ /* ======= RUN BADGES ======= */
.badge-campaign-running { .badge-run-running {
background: rgba(224, 40, 72, 0.08); background: rgba(224, 40, 72, 0.08);
color: var(--accent-hover); color: var(--accent-hover);
border-color: rgba(224, 40, 72, 0.25); border-color: rgba(224, 40, 72, 0.25);
text-shadow: 0 0 6px var(--accent-glow); text-shadow: 0 0 6px var(--accent-glow);
} }
.badge-campaign-finished { .badge-run-finished {
background: rgba(61, 122, 74, 0.1); background: rgba(61, 122, 74, 0.1);
color: var(--success); color: var(--success);
border-color: rgba(61, 122, 74, 0.3); 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 ======= */
.search-form { .search-form {

View File

@ -16,7 +16,7 @@
<li><a href="/"><span class="nav-icon">&#5855;</span> Dashboard</a></li> <li><a href="/"><span class="nav-icon">&#5855;</span> Dashboard</a></li>
<li><a href="/artifacts"><span class="nav-icon">&#5833;</span> Artifacts</a></li> <li><a href="/artifacts"><span class="nav-icon">&#5833;</span> Artifacts</a></li>
<li><a href="/crashgroups"><span class="nav-icon">&#5854;</span> Crash Groups</a></li> <li><a href="/crashgroups"><span class="nav-icon">&#5854;</span> Crash Groups</a></li>
<li><a href="/campaigns"><span class="nav-icon">&#5839;</span> Campaigns</a></li> <li><a href="/targets"><span class="nav-icon">&#5839;</span> Targets</a></li>
<hr class="sidebar-divider"> <hr class="sidebar-divider">
<li><a href="/repos"><span class="nav-icon">&#5798;</span> Repositories</a></li> <li><a href="/repos"><span class="nav-icon">&#5798;</span> Repositories</a></li>
<li><a href="/regression"><span class="nav-icon">&#5834;</span> Regression</a></li> <li><a href="/regression"><span class="nav-icon">&#5834;</span> Regression</a></li>

View File

@ -1,38 +0,0 @@
{{define "content"}}
<div class="campaigns-page">
<div class="toolbar">
<span class="result-count">{{.Total}} campaigns</span>
</div>
{{if .Campaigns}}
<table class="table">
<thead>
<tr>
<th>Status</th>
<th>Name</th>
<th>Type</th>
<th>Repository</th>
<th>Artifacts</th>
<th>Started</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Campaigns}}
<tr>
<td><span class="badge badge-campaign-{{.Status}}">{{.Status}}</span></td>
<td>{{.Name}}</td>
<td>{{.Type}}</td>
<td>{{.RepoName}}</td>
<td>{{.ArtifactCount}}</td>
<td>{{timeAgo .StartedAt}}</td>
<td><a href="/campaigns/{{.ID}}" class="btn btn-sm">View</a></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="empty-state">No campaigns yet. Use <code>cairn campaign start</code> to begin a campaign.</p>
{{end}}
</div>
{{end}}

View File

@ -1,29 +1,31 @@
{{define "content"}} {{define "content"}}
<div class="campaign-detail"> <div class="run-detail">
<div class="detail-header"> <div class="detail-header">
<span class="badge badge-campaign-{{.Campaign.Status}}">{{.Campaign.Status}}</span> <span class="badge badge-run-{{.Run.Status}}">{{.Run.Status}}</span>
<span class="detail-repo">{{.Campaign.RepoName}}</span> <span class="detail-repo">{{.Run.RepoName}} / {{.Run.TargetName}}</span>
</div> </div>
<h3>{{.Campaign.Name}}</h3>
<div class="detail-grid"> <div class="detail-grid">
<div class="detail-item"> <div class="detail-item">
<label>Type</label> <label>Target</label>
<span>{{.Campaign.Type}}</span> <span>{{.Run.TargetName}}</span>
</div>
<div class="detail-item">
<label>Commit</label>
<span><code>{{shortSHA .Run.CommitSHA}}</code></span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<label>Artifacts</label> <label>Artifacts</label>
<span>{{.Campaign.ArtifactCount}}</span> <span>{{.Run.ArtifactCount}}</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<label>Started</label> <label>Started</label>
<span>{{timeAgo .Campaign.StartedAt}}</span> <span>{{timeAgo .Run.StartedAt}}</span>
</div> </div>
{{if .Campaign.FinishedAt}} {{if .Run.FinishedAt}}
<div class="detail-item"> <div class="detail-item">
<label>Finished</label> <label>Finished</label>
<span>{{timeAgo (derefTime .Campaign.FinishedAt)}}</span> <span>{{timeAgo (derefTime .Run.FinishedAt)}}</span>
</div> </div>
{{end}} {{end}}
</div> </div>
@ -56,7 +58,7 @@
</tbody> </tbody>
</table> </table>
{{else}} {{else}}
<p class="empty-state">No artifacts in this campaign yet.</p> <p class="empty-state">No artifacts in this run.</p>
{{end}} {{end}}
</section> </section>
</div> </div>

View File

@ -0,0 +1,87 @@
{{define "content"}}
<div class="target-detail">
<div class="detail-header">
<span class="badge badge-{{.Target.Type}}">{{.Target.Type}}</span>
<span class="detail-repo">{{.Target.RepoName}}</span>
</div>
<h3>{{.Target.Name}}</h3>
<div class="detail-grid">
<div class="detail-item">
<label>Type</label>
<span>{{.Target.Type}}</span>
</div>
<div class="detail-item">
<label>Runs</label>
<span>{{.Target.RunCount}}</span>
</div>
<div class="detail-item">
<label>Corpus Entries</label>
<span>{{.Target.CorpusCount}}</span>
</div>
<div class="detail-item">
<label>Created</label>
<span>{{timeAgo .Target.CreatedAt}}</span>
</div>
</div>
<section class="section">
<h3>Runs</h3>
{{if .Runs}}
<table class="table">
<thead>
<tr>
<th>Status</th>
<th>Commit</th>
<th>Artifacts</th>
<th>Started</th>
<th>Finished</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Runs}}
<tr>
<td><span class="badge badge-run-{{.Status}}">{{.Status}}</span></td>
<td><code>{{shortSHA .CommitSHA}}</code></td>
<td>{{.ArtifactCount}}</td>
<td>{{timeAgo .StartedAt}}</td>
<td>{{if .FinishedAt}}{{timeAgo (derefTime .FinishedAt)}}{{else}}-{{end}}</td>
<td><a href="/runs/{{.ID}}" class="btn btn-sm">View</a></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="empty-state">No runs yet.</p>
{{end}}
</section>
<section class="section">
<h3>Corpus ({{.CorpusTotal}} entries)</h3>
{{if .Corpus}}
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Size</th>
<th>Added</th>
</tr>
</thead>
<tbody>
{{range .Corpus}}
<tr>
<td>#{{.ID}}</td>
<td>{{formatSize .BlobSize}}</td>
<td>{{timeAgo .CreatedAt}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="empty-state">No corpus entries yet.</p>
{{end}}
</section>
</div>
{{end}}

View File

@ -0,0 +1,38 @@
{{define "content"}}
<div class="targets-page">
<div class="toolbar">
<span class="result-count">{{.Total}} targets</span>
</div>
{{if .Targets}}
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Repository</th>
<th>Runs</th>
<th>Corpus</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Targets}}
<tr>
<td>{{.Name}}</td>
<td><span class="badge badge-{{.Type}}">{{.Type}}</span></td>
<td>{{.RepoName}}</td>
<td>{{.RunCount}}</td>
<td>{{.CorpusCount}}</td>
<td>{{timeAgo .UpdatedAt}}</td>
<td><a href="/targets/{{.ID}}" class="btn btn-sm">View</a></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="empty-state">No targets yet. Use <code>cairn target ensure</code> to register a target.</p>
{{end}}
</div>
{{end}}