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:
Matthew Knight 2026-03-02 15:04:40 -08:00
parent b06823e03e
commit 8d2c7bb8b6
No known key found for this signature in database
3 changed files with 482 additions and 2 deletions

4
.gitignore vendored
View File

@ -1,5 +1,5 @@
cairn-server
cairn
/cairn-server
/cairn
*.exe
*.test
*.out

85
cmd/cairn-server/main.go Normal file
View File

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

395
cmd/cairn/main.go Normal file
View File

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