cairn/cmd/cairn/main.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
}