Merge pull request 'target-run-model' (#8) from target-run-model into main
Reviewed-on: https://git.ts.mattnite.net/mattnite/cairn/pulls/8
This commit is contained in:
commit
b556360cdf
|
|
@ -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,72 @@ 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 ──
|
TOTAL_CRASHES=0
|
||||||
SHORT_SHA=$(printf '%.8s' "${COMMIT}")
|
TARGET_NUM=0
|
||||||
if [ -n "${TARGET}" ]; then
|
ACTIVE_RUN_ID=""
|
||||||
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"
|
|
||||||
|
|
||||||
|
# Single EXIT trap that finishes whatever run is active.
|
||||||
cleanup() {
|
cleanup() {
|
||||||
cairn campaign finish -server "${CAIRN_SERVER}" -id "${CAMPAIGN_ID}" || true
|
if [ -n "${ACTIVE_RUN_ID}" ]; then
|
||||||
|
echo "Finishing run ${ACTIVE_RUN_ID} (cleanup)..."
|
||||||
|
cairn run finish -server "${CAIRN_SERVER}" -id "${ACTIVE_RUN_ID}" || true
|
||||||
|
ACTIVE_RUN_ID=""
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
TOTAL_CRASHES=0
|
# ── Iterate over each target name ──
|
||||||
TARGET_NUM=0
|
while IFS= read -r FUZZ_TARGET; do
|
||||||
|
|
||||||
# ── Iterate over each line of zig_build_args ──
|
|
||||||
while IFS= read -r BUILD_ARGS; 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}")
|
||||||
|
ACTIVE_RUN_ID="${RUN_ID}"
|
||||||
|
echo "Run ID: ${RUN_ID}"
|
||||||
|
|
||||||
|
# ── 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 +164,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 +206,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 +222,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"
|
|
||||||
if [ -n "${EFFECTIVE_TARGET}" ]; then
|
|
||||||
set -- "$@" -target "${EFFECTIVE_TARGET}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cairn upload "$@"
|
|
||||||
rm -f "corpus-${TARGET_NUM}.tar.gz"
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Finish run ──
|
||||||
|
cairn run finish -server "${CAIRN_SERVER}" -id "${RUN_ID}" || true
|
||||||
|
ACTIVE_RUN_ID=""
|
||||||
|
|
||||||
done <<EOF
|
done <<EOF
|
||||||
${ZIG_BUILD_ARGS}
|
${TARGETS}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# ── Final report ──
|
# ── Final report ──
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,22 +44,48 @@ 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"`
|
||||||
Status string `json:"status"`
|
|
||||||
StartedAt time.Time `json:"started_at"`
|
|
||||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
|
||||||
Tags json.RawMessage `json:"tags,omitempty"`
|
Tags json.RawMessage `json:"tags,omitempty"`
|
||||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
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"`
|
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"`
|
||||||
|
|
|
||||||
|
|
@ -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{},
|
||||||
|
|
|
||||||
|
|
@ -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"})
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,28 +64,58 @@ 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"`
|
||||||
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:'{}'"`
|
Tags json.RawMessage `gorm:"type:jsonb;default:'{}'"`
|
||||||
Metadata json.RawMessage `gorm:"type:jsonb;default:'{}'"`
|
Metadata json.RawMessage `gorm:"type:jsonb;default:'{}'"`
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
|
||||||
Repository Repository `gorm:"foreignKey:RepositoryID"`
|
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 {
|
type CrashSignature struct {
|
||||||
ID uint `gorm:"primaryKey"`
|
ID uint `gorm:"primaryKey"`
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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{}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
<li><a href="/"><span class="nav-icon">ᛟ</span> Dashboard</a></li>
|
<li><a href="/"><span class="nav-icon">ᛟ</span> Dashboard</a></li>
|
||||||
<li><a href="/artifacts"><span class="nav-icon">ᛉ</span> Artifacts</a></li>
|
<li><a href="/artifacts"><span class="nav-icon">ᛉ</span> Artifacts</a></li>
|
||||||
<li><a href="/crashgroups"><span class="nav-icon">ᛞ</span> Crash Groups</a></li>
|
<li><a href="/crashgroups"><span class="nav-icon">ᛞ</span> Crash Groups</a></li>
|
||||||
<li><a href="/campaigns"><span class="nav-icon">ᛏ</span> Campaigns</a></li>
|
<li><a href="/targets"><span class="nav-icon">ᛏ</span> Targets</a></li>
|
||||||
<hr class="sidebar-divider">
|
<hr class="sidebar-divider">
|
||||||
<li><a href="/repos"><span class="nav-icon">ᚦ</span> Repositories</a></li>
|
<li><a href="/repos"><span class="nav-icon">ᚦ</span> Repositories</a></li>
|
||||||
<li><a href="/regression"><span class="nav-icon">ᛊ</span> Regression</a></li>
|
<li><a href="/regression"><span class="nav-icon">ᛊ</span> Regression</a></li>
|
||||||
|
|
|
||||||
|
|
@ -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}}
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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}}
|
||||||
|
|
@ -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}}
|
||||||
Loading…
Reference in New Issue