404 lines
9.0 KiB
Go
404 lines
9.0 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
switch os.Args[1] {
|
|
case "upload":
|
|
if err := cmdUpload(os.Args[2:]); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
case "check":
|
|
if err := cmdCheck(os.Args[2:]); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
case "campaign":
|
|
if len(os.Args) < 3 {
|
|
fmt.Fprintf(os.Stderr, "usage: cairn campaign <start|finish>\n")
|
|
os.Exit(1)
|
|
}
|
|
if err := cmdCampaign(os.Args[2], os.Args[3:]); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
case "download":
|
|
if err := cmdDownload(os.Args[2:]); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
|
|
usage()
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func usage() {
|
|
fmt.Fprintf(os.Stderr, `Usage: cairn <command> [args]
|
|
|
|
Commands:
|
|
upload Upload an artifact to Cairn
|
|
check Check for regressions between two commits
|
|
campaign start Start a new campaign
|
|
campaign finish Finish a running campaign
|
|
download Download an artifact
|
|
|
|
Upload flags:
|
|
-server URL Cairn server URL (default: http://localhost:8080, or CAIRN_SERVER_URL)
|
|
-repo NAME Repository name (required)
|
|
-owner OWNER Repository owner (required)
|
|
-commit SHA Commit SHA (required)
|
|
-type TYPE Artifact type: coredump, fuzz, sanitizer, simulation (required)
|
|
-file PATH Path to artifact file (required)
|
|
-crash-message MSG Crash message (optional)
|
|
-stack-trace TRACE Stack trace text (optional)
|
|
-seed VALUE Simulation seed for reproducibility (optional, stored in metadata)
|
|
`)
|
|
}
|
|
|
|
func cmdUpload(args []string) error {
|
|
var (
|
|
serverURL = envOr("CAIRN_SERVER_URL", "http://localhost:8080")
|
|
repo string
|
|
owner string
|
|
commitSHA string
|
|
artifactType string
|
|
filePath string
|
|
crashMessage string
|
|
stackTrace string
|
|
seed string
|
|
)
|
|
|
|
for i := 0; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "-server":
|
|
i++
|
|
serverURL = args[i]
|
|
case "-repo":
|
|
i++
|
|
repo = args[i]
|
|
case "-owner":
|
|
i++
|
|
owner = args[i]
|
|
case "-commit":
|
|
i++
|
|
commitSHA = args[i]
|
|
case "-type":
|
|
i++
|
|
artifactType = args[i]
|
|
case "-file":
|
|
i++
|
|
filePath = args[i]
|
|
case "-crash-message":
|
|
i++
|
|
crashMessage = args[i]
|
|
case "-stack-trace":
|
|
i++
|
|
stackTrace = args[i]
|
|
case "-seed":
|
|
i++
|
|
seed = args[i]
|
|
default:
|
|
return fmt.Errorf("unknown flag: %s", args[i])
|
|
}
|
|
}
|
|
|
|
if repo == "" || owner == "" || commitSHA == "" || artifactType == "" || filePath == "" {
|
|
return fmt.Errorf("required flags: -repo, -owner, -commit, -type, -file")
|
|
}
|
|
|
|
meta := map[string]any{
|
|
"repository": repo,
|
|
"owner": owner,
|
|
"commit_sha": commitSHA,
|
|
"type": artifactType,
|
|
}
|
|
if crashMessage != "" {
|
|
meta["crash_message"] = crashMessage
|
|
}
|
|
if stackTrace != "" {
|
|
meta["stack_trace"] = stackTrace
|
|
}
|
|
if seed != "" {
|
|
meta["metadata"] = map[string]any{"seed": seed}
|
|
}
|
|
|
|
metaJSON, err := json.Marshal(meta)
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling meta: %w", err)
|
|
}
|
|
|
|
f, err := os.Open(filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("opening file: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
var buf bytes.Buffer
|
|
w := multipart.NewWriter(&buf)
|
|
|
|
if err := w.WriteField("meta", string(metaJSON)); err != nil {
|
|
return fmt.Errorf("writing meta field: %w", err)
|
|
}
|
|
|
|
fw, err := w.CreateFormFile("file", filepath.Base(filePath))
|
|
if err != nil {
|
|
return fmt.Errorf("creating form file: %w", err)
|
|
}
|
|
|
|
if _, err := io.Copy(fw, f); err != nil {
|
|
return fmt.Errorf("copying file: %w", err)
|
|
}
|
|
|
|
if err := w.Close(); err != nil {
|
|
return fmt.Errorf("closing multipart writer: %w", err)
|
|
}
|
|
|
|
resp, err := http.Post(serverURL+"/api/v1/artifacts", w.FormDataContentType(), &buf)
|
|
if err != nil {
|
|
return fmt.Errorf("uploading: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
return fmt.Errorf("server returned %d: %s", resp.StatusCode, body)
|
|
}
|
|
|
|
var result map[string]any
|
|
json.Unmarshal(body, &result)
|
|
fmt.Printf("Artifact uploaded: %s\n", result["id"])
|
|
|
|
return nil
|
|
}
|
|
|
|
func cmdCheck(args []string) error {
|
|
var (
|
|
serverURL = envOr("CAIRN_SERVER_URL", "http://localhost:8080")
|
|
repo string
|
|
baseSHA string
|
|
headSHA string
|
|
)
|
|
|
|
for i := 0; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "-server":
|
|
i++
|
|
serverURL = args[i]
|
|
case "-repo":
|
|
i++
|
|
repo = args[i]
|
|
case "-base":
|
|
i++
|
|
baseSHA = args[i]
|
|
case "-head":
|
|
i++
|
|
headSHA = args[i]
|
|
default:
|
|
return fmt.Errorf("unknown flag: %s", args[i])
|
|
}
|
|
}
|
|
|
|
if repo == "" || baseSHA == "" || headSHA == "" {
|
|
return fmt.Errorf("required flags: -repo, -base, -head")
|
|
}
|
|
|
|
body := map[string]string{
|
|
"repository": repo,
|
|
"base_sha": baseSHA,
|
|
"head_sha": headSHA,
|
|
}
|
|
bodyJSON, _ := json.Marshal(body)
|
|
|
|
resp, err := http.Post(serverURL+"/api/v1/regression/check", "application/json", bytes.NewReader(bodyJSON))
|
|
if err != nil {
|
|
return fmt.Errorf("checking regression: %w", 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 struct {
|
|
IsRegression bool `json:"is_regression"`
|
|
New []string `json:"new"`
|
|
Fixed []string `json:"fixed"`
|
|
Recurring []string `json:"recurring"`
|
|
}
|
|
json.Unmarshal(respBody, &result)
|
|
|
|
if result.IsRegression {
|
|
fmt.Printf("REGRESSION: %d new crash signature(s)\n", len(result.New))
|
|
for _, fp := range result.New {
|
|
fmt.Printf(" new: %s\n", fp[:16])
|
|
}
|
|
os.Exit(1)
|
|
}
|
|
|
|
fmt.Printf("OK: %d recurring, %d fixed, 0 new\n", len(result.Recurring), len(result.Fixed))
|
|
return nil
|
|
}
|
|
|
|
func cmdCampaign(subcmd string, args []string) error {
|
|
serverURL := envOr("CAIRN_SERVER_URL", "http://localhost:8080")
|
|
|
|
switch subcmd {
|
|
case "start":
|
|
var repo, owner, name, ctype string
|
|
for i := 0; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "-server":
|
|
i++
|
|
serverURL = args[i]
|
|
case "-repo":
|
|
i++
|
|
repo = args[i]
|
|
case "-owner":
|
|
i++
|
|
owner = args[i]
|
|
case "-name":
|
|
i++
|
|
name = args[i]
|
|
case "-type":
|
|
i++
|
|
ctype = args[i]
|
|
default:
|
|
return fmt.Errorf("unknown flag: %s", args[i])
|
|
}
|
|
}
|
|
if repo == "" || owner == "" || name == "" || ctype == "" {
|
|
return fmt.Errorf("required flags: -repo, -owner, -name, -type")
|
|
}
|
|
body, _ := json.Marshal(map[string]string{
|
|
"repository": repo, "owner": owner, "name": name, "type": ctype,
|
|
})
|
|
resp, err := http.Post(serverURL+"/api/v1/campaigns", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
return fmt.Errorf("server returned %d: %s", resp.StatusCode, respBody)
|
|
}
|
|
var result map[string]any
|
|
json.Unmarshal(respBody, &result)
|
|
fmt.Printf("Campaign started: %s\n", result["id"])
|
|
|
|
case "finish":
|
|
var id string
|
|
for i := 0; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "-server":
|
|
i++
|
|
serverURL = args[i]
|
|
case "-id":
|
|
i++
|
|
id = args[i]
|
|
default:
|
|
return fmt.Errorf("unknown flag: %s", args[i])
|
|
}
|
|
}
|
|
if id == "" {
|
|
return fmt.Errorf("required flag: -id")
|
|
}
|
|
resp, err := http.Post(serverURL+"/api/v1/campaigns/"+id+"/finish", "application/json", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("server returned %d: %s", resp.StatusCode, body)
|
|
}
|
|
fmt.Println("Campaign finished")
|
|
|
|
default:
|
|
return fmt.Errorf("unknown campaign subcommand: %s (use start or finish)", subcmd)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func cmdDownload(args []string) error {
|
|
serverURL := envOr("CAIRN_SERVER_URL", "http://localhost:8080")
|
|
var id, output string
|
|
|
|
for i := 0; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "-server":
|
|
i++
|
|
serverURL = args[i]
|
|
case "-id":
|
|
i++
|
|
id = args[i]
|
|
case "-o":
|
|
i++
|
|
output = args[i]
|
|
default:
|
|
return fmt.Errorf("unknown flag: %s", args[i])
|
|
}
|
|
}
|
|
|
|
if id == "" {
|
|
return fmt.Errorf("required flag: -id")
|
|
}
|
|
|
|
resp, err := http.Get(serverURL + "/api/v1/artifacts/" + id + "/download")
|
|
if err != nil {
|
|
return fmt.Errorf("downloading: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("server returned %d: %s", resp.StatusCode, body)
|
|
}
|
|
|
|
var w *os.File
|
|
if output == "" || output == "-" {
|
|
w = os.Stdout
|
|
} else {
|
|
w, err = os.Create(output)
|
|
if err != nil {
|
|
return fmt.Errorf("creating output file: %w", err)
|
|
}
|
|
defer w.Close()
|
|
}
|
|
|
|
n, err := io.Copy(w, resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("writing: %w", err)
|
|
}
|
|
|
|
if output != "" && output != "-" {
|
|
fmt.Fprintf(os.Stderr, "Downloaded %d bytes to %s\n", n, output)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func envOr(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|