From 8d2c7bb8b69bfaeff6d448e20e5eb3ba9e9e408f Mon Sep 17 00:00:00 2001 From: Matthew Knight Date: Mon, 2 Mar 2026 15:04:40 -0800 Subject: [PATCH] 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 --- .gitignore | 4 +- cmd/cairn-server/main.go | 85 +++++++++ cmd/cairn/main.go | 395 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 482 insertions(+), 2 deletions(-) create mode 100644 cmd/cairn-server/main.go create mode 100644 cmd/cairn/main.go diff --git a/.gitignore b/.gitignore index ae5a6b9..2bb027a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -cairn-server -cairn +/cairn-server +/cairn *.exe *.test *.out diff --git a/cmd/cairn-server/main.go b/cmd/cairn-server/main.go new file mode 100644 index 0000000..0ad14e6 --- /dev/null +++ b/cmd/cairn-server/main.go @@ -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) + } +} diff --git a/cmd/cairn/main.go b/cmd/cairn/main.go new file mode 100644 index 0000000..9b7b378 --- /dev/null +++ b/cmd/cairn/main.go @@ -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 \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 [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 +}