Fix .gitignore excluding cmd/cairn-server and cmd/cairn directories
The bare patterns "cairn-server" and "cairn" matched the directories under cmd/, preventing them from being tracked. Anchor the patterns to the repo root with a leading "/" so they only ignore the compiled binaries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b06823e03e
commit
8d2c7bb8b6
|
|
@ -1,5 +1,5 @@
|
||||||
cairn-server
|
/cairn-server
|
||||||
cairn
|
/cairn
|
||||||
*.exe
|
*.exe
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mattnite/cairn/internal/blob"
|
||||||
|
"github.com/mattnite/cairn/internal/config"
|
||||||
|
"github.com/mattnite/cairn/internal/database"
|
||||||
|
"github.com/mattnite/cairn/internal/forgejo"
|
||||||
|
"github.com/mattnite/cairn/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Loading config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
pool, err := database.Connect(ctx, cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Connecting to database: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
if err := database.Migrate(ctx, pool); err != nil {
|
||||||
|
log.Fatalf("Running migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := blob.NewS3Store(cfg.S3Endpoint, cfg.S3AccessKey, cfg.S3SecretKey, cfg.S3Bucket, cfg.S3UseSSL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Creating blob store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.EnsureBucket(ctx); err != nil {
|
||||||
|
log.Fatalf("Ensuring bucket: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var forgejoClient *forgejo.Client
|
||||||
|
if cfg.ForgejoURL != "" && cfg.ForgejoToken != "" {
|
||||||
|
forgejoClient = forgejo.NewClient(cfg.ForgejoURL, cfg.ForgejoToken)
|
||||||
|
log.Printf("Forgejo integration enabled: %s", cfg.ForgejoURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
router, err := web.NewRouter(web.RouterConfig{
|
||||||
|
Pool: pool,
|
||||||
|
Store: store,
|
||||||
|
ForgejoClient: forgejoClient,
|
||||||
|
WebhookSecret: cfg.ForgejoWebhookSecret,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Creating router: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: cfg.ListenAddr,
|
||||||
|
Handler: router,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 60 * time.Second,
|
||||||
|
IdleTimeout: 120 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-sigCh
|
||||||
|
log.Println("Shutting down...")
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
srv.Shutdown(shutdownCtx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("Cairn server listening on %s", cfg.ListenAddr)
|
||||||
|
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("Server error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,395 @@
|
||||||
|
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)
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
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]
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue