diff options
Diffstat
| -rw-r--r-- | cmd/mirumd/main.go | 266 | +52 −214 |
| -rw-r--r-- | internal/executor/executor.go | 109 | +109 −0 |
| -rw-r--r-- | internal/forges/forge.go | 47 | +47 −0 |
| -rw-r--r-- | internal/forges/github.go | 136 | +136 −0 |
| -rw-r--r-- | pkg/mirumd.service | 13 | +13 −0 |
| -rw-r--r-- | pkg/mirumd.yaml | 2 | +2 −0 |
6 files changed, 359 insertions, 214 deletions
diff --git a/cmd/mirumd/main.go b/cmd/mirumd/main.go index 1b0e3b3..a959661 100644 --- a/cmd/mirumd/main.go +++ b/cmd/mirumd/main.go @@ -4,31 +4,24 @@ package main import ( - "bytes" "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" + "errors" "flag" "fmt" "io" "log/slog" "net" "net/http" - "net/url" "os" - "os/exec" - "path/filepath" - "strings" "time" + "mrdimidium/mirum/internal/executor" + "mrdimidium/mirum/internal/forges" "mrdimidium/mirum/internal/protocol" "mrdimidium/mirum/internal/protocol/pb" "mrdimidium/mirum/internal/supervisor" "github.com/coreos/go-systemd/v22/activation" - "go.starlark.net/starlark" "google.golang.org/grpc" "google.golang.org/protobuf/types/known/timestamppb" "gopkg.in/yaml.v3" @@ -39,190 +32,33 @@ type config struct { WwwAddr string `yaml:"www_addr"` Secret string `yaml:"secret"` Token string `yaml:"token"` - Script string `yaml:"script"` } var cfg = config{ GrpcAddr: ":2026", WwwAddr: ":3000", - Script: ".mirum/main.star", } var configFile = flag.String("config", "", "path to config file") -type pushEvent struct { - Ref string `json:"ref"` - After string `json:"after"` - Repo struct { - FullName string `json:"full_name"` - CloneURL string `json:"clone_url"` - } `json:"repository"` -} - -func processPush(push pushEvent) { - owner, repo := splitFullName(push.Repo.FullName) - sha := push.After - log := slog.With("repo", push.Repo.FullName, "sha", sha[:8]) +func processPush(forge forges.Forge, ev *forges.PushEvent) { + ctx := context.Background() + log := slog.With("repo", ev.Owner+"/"+ev.Repo, "sha", ev.SHA[:8]) - if err := setStatus(owner, repo, sha, "pending", "Build started"); err != nil { + if err := forge.SetStatus(ctx, ev, forges.StatusPending, "Build started"); err != nil { log.Error("set pending status", "err", err) } - dir, err := os.MkdirTemp("", "mirum-*") - if err != nil { - log.Error("build failed", "err", err) - _ = setStatus(owner, repo, sha, "failure", "Build failed") - return - } - defer os.RemoveAll(dir) - - branch := strings.TrimPrefix(push.Ref, "refs/heads/") - cloneURL := authURL(push.Repo.CloneURL) - - if out, err := runCmd(dir, "git", "clone", "--depth=1", "--branch", branch, cloneURL, "."); err != nil { - log.Error("build failed", "err", err, "output", out) - _ = setStatus(owner, repo, sha, "failure", "Build failed") - return - } + cloneURL := forge.AuthURL(ev.CloneURL) - if err := runStarlark(dir); err != nil { + if err := executor.Run(cloneURL, ev.Branch); err != nil { log.Error("build failed", "err", err) - _ = setStatus(owner, repo, sha, "failure", "Build failed") + _ = forge.SetStatus(ctx, ev, forges.StatusFailure, "Build failed") return } log.Info("build passed") - _ = setStatus(owner, repo, sha, "success", "Build passed") -} - -func setStatus(owner, repo, sha, state, description string) error { - apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/statuses/%s", owner, repo, sha) - - body, _ := json.Marshal(map[string]string{ - "state": state, - "description": description, - "context": "mirum", - }) - - req, err := http.NewRequest("POST", apiURL, bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+cfg.Token) - req.Header.Set("Accept", "application/vnd.github+json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode >= 300 { - b, _ := io.ReadAll(resp.Body) - return fmt.Errorf("github api %d: %s", resp.StatusCode, b) - } - return nil -} - -func verifySignature(payload []byte, signature string) bool { - sig, ok := strings.CutPrefix(signature, "sha256=") - if !ok { - return false - } - decoded, err := hex.DecodeString(sig) - if err != nil { - return false - } - mac := hmac.New(sha256.New, []byte(cfg.Secret)) - mac.Write(payload) - return hmac.Equal(mac.Sum(nil), decoded) -} - -func authURL(cloneURL string) string { - if cfg.Token == "" { - return cloneURL - } - u, err := url.Parse(cloneURL) - if err != nil { - return cloneURL - } - u.User = url.UserPassword("x-access-token", cfg.Token) - return u.String() -} - -func splitFullName(fullName string) (string, string) { - parts := strings.SplitN(fullName, "/", 2) - if len(parts) != 2 { - return fullName, "" - } - return parts[0], parts[1] -} - -type taskCtx struct { - dir string -} - -var _ starlark.HasAttrs = (*taskCtx)(nil) - -func (c *taskCtx) String() string { return "ctx" } -func (c *taskCtx) Type() string { return "ctx" } -func (c *taskCtx) Freeze() {} -func (c *taskCtx) Truth() starlark.Bool { return true } -func (c *taskCtx) Hash() (uint32, error) { return 0, fmt.Errorf("unhashable: ctx") } -func (c *taskCtx) AttrNames() []string { return []string{"shell"} } - -func (c *taskCtx) Attr(name string) (starlark.Value, error) { - if name == "shell" { - return starlark.NewBuiltin("ctx.shell", c.shell), nil - } - return nil, nil -} - -func (c *taskCtx) shell(thread *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { - var cmd string - if err := starlark.UnpackPositionalArgs(fn.Name(), args, kwargs, 1, &cmd); err != nil { - return nil, err - } - proc := exec.Command("bash", "-c", cmd) - proc.Dir = c.dir - proc.Stdout = os.Stdout - proc.Stderr = os.Stderr - err := proc.Run() - if err != nil { - return nil, err - } - return starlark.None, nil -} - -func runStarlark(dir string) error { - thread := &starlark.Thread{Name: "mirum"} - globals, err := starlark.ExecFile(thread, filepath.Join(dir, cfg.Script), nil, nil) - if err != nil { - return err - } - - projectFn, ok := globals["project"] - if !ok { - return fmt.Errorf("%s: project() not defined", cfg.Script) - } - fn, ok := projectFn.(starlark.Callable) - if !ok { - return fmt.Errorf("%s: project is not a function", cfg.Script) - } - - ctx := &taskCtx{dir: dir} - _, err = starlark.Call(thread, fn, starlark.Tuple{ctx}, nil) - return err -} - -func runCmd(dir, name string, args ...string) (string, error) { - cmd := exec.Command(name, args...) - cmd.Dir = dir - var buf bytes.Buffer - cmd.Stdout = &buf - cmd.Stderr = &buf - err := cmd.Run() - return buf.String(), err + _ = forge.SetStatus(ctx, ev, forges.StatusSuccess, "Build passed") } func main() { @@ -245,6 +81,8 @@ func main() { os.Exit(1) } + forge := &forges.GitHub{Secret: cfg.Secret, Token: cfg.Token} + mux := http.NewServeMux() mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -257,59 +95,34 @@ func main() { return } - if len(cfg.Secret) > 0 && !verifySignature(body, r.Header.Get("X-Hub-Signature-256")) { + ev, err := forge.Webhook(r, body) + if errors.Is(err, forges.ErrInvalidSignature) { http.Error(w, "invalid signature", http.StatusUnauthorized) return } - - event := r.Header.Get("X-GitHub-Event") - if event == "ping" { - fmt.Fprintln(w, "pong") - return - } - - if event != "push" { - w.WriteHeader(http.StatusNoContent) - return - } - - var push pushEvent - if err := json.Unmarshal(body, &push); err != nil { - http.Error(w, "parse payload", http.StatusBadRequest) - return - } - - if push.After == "" || push.After == "0000000000000000000000000000000000000000" { - w.WriteHeader(http.StatusNoContent) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } - - if !strings.HasPrefix(push.Ref, "refs/heads/") { + if ev == nil { w.WriteHeader(http.StatusNoContent) return } - slog.Info("push", "repo", push.Repo.FullName, "ref", push.Ref, "sha", push.After[:8]) + slog.Info("push", "repo", ev.Owner+"/"+ev.Repo, "branch", ev.Branch, "sha", ev.SHA[:8]) w.WriteHeader(http.StatusAccepted) - go processPush(push) + go processPush(forge, ev) }) - // gRPC server - grpcLn, err := net.Listen("tcp", cfg.GrpcAddr) + grpcLn, httpLn, err := listeners() if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } + grpcSrv := grpc.NewServer(grpc.StreamInterceptor(streamTimeoutInterceptor)) pb.RegisterMirumServer(grpcSrv, &mirumServer{secret: []byte(cfg.Secret)}) - - // HTTP server - httpLn, err := socketActivationListener() - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } httpSrv := &http.Server{Handler: mux} slog.Info("listening", "grpc", grpcLn.Addr(), "http", httpLn.Addr()) @@ -448,10 +261,35 @@ func streamTimeoutInterceptor(srv any, ss grpc.ServerStream, info *grpc.StreamSe return handler(srv, ss) } -func socketActivationListener() (net.Listener, error) { - listeners, _ := activation.Listeners() - if len(listeners) > 0 { - return listeners[0], nil +// listeners returns gRPC and HTTP listeners. +// With systemd socket activation it expects two named fds: "grpc" and "http". +// Without socket activation it falls back to cfg.GrpcAddr and cfg.WwwAddr. +func listeners() (grpcLn, httpLn net.Listener, err error) { + named, err := activation.ListenersWithNames() + if err != nil { + return nil, nil, fmt.Errorf("socket activation: %w", err) } - return net.Listen("tcp", cfg.WwwAddr) + + if lns := named["grpc"]; len(lns) > 0 { + grpcLn = lns[0] + } + if lns := named["http"]; len(lns) > 0 { + httpLn = lns[0] + } + + if grpcLn == nil { + grpcLn, err = net.Listen("tcp", cfg.GrpcAddr) + if err != nil { + return nil, nil, err + } + } + if httpLn == nil { + httpLn, err = net.Listen("tcp", cfg.WwwAddr) + if err != nil { + grpcLn.Close() + return nil, nil, err + } + } + + return grpcLn, httpLn, nil } diff --git a/internal/executor/executor.go b/internal/executor/executor.go new file mode 100644 --- /dev/null +++ b/internal/executor/executor.go @@ -0,0 +1,109 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package executor + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + + "go.starlark.net/starlark" +) + +const entry = ".mirum/project.star" + +func RunCmd(dir, name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + cmd.Dir = dir + + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + + err := cmd.Run() + return buf.String(), err +} + +type SOTaskCtx struct { + dir string +} + +var _ starlark.HasAttrs = (*SOTaskCtx)(nil) + +func (c *SOTaskCtx) String() string { return "ctx" } +func (c *SOTaskCtx) Type() string { return "ctx" } +func (c *SOTaskCtx) Freeze() {} +func (c *SOTaskCtx) Truth() starlark.Bool { return true } +func (c *SOTaskCtx) Hash() (uint32, error) { return 0, fmt.Errorf("unhashable: ctx") } +func (c *SOTaskCtx) AttrNames() []string { return []string{"shell"} } + +func (c *SOTaskCtx) Attr(name string) (starlark.Value, error) { + switch name { + case "shell": + return starlark.NewBuiltin("ctx.shell", c.shell), nil + } + return nil, nil +} + +func (c *SOTaskCtx) shell( + thread *starlark.Thread, + fn *starlark.Builtin, + args starlark.Tuple, + kwargs []starlark.Tuple, +) (starlark.Value, error) { + var cmd string + if err := starlark.UnpackPositionalArgs(fn.Name(), args, kwargs, 1, &cmd); err != nil { + return nil, err + } + + proc := exec.Command("bash", "-c", cmd) + proc.Dir = c.dir + proc.Stdout = os.Stdout + proc.Stderr = os.Stderr + err := proc.Run() + if err != nil { + return nil, err + } + + return starlark.None, nil +} + +// Run clones the repository into a temporary directory, runs the Starlark +// build script, and cleans up afterwards. +func Run(cloneURL, branch string) error { + dir, err := os.MkdirTemp("", "mirum-*") + if err != nil { + return fmt.Errorf("create workdir: %w", err) + } + defer os.RemoveAll(dir) + + if out, err := RunCmd(dir, "git", "clone", "--depth=1", "--branch", branch, cloneURL, "."); err != nil { + return fmt.Errorf("git clone: %s: %w", out, err) + } + + return runStarlark(dir) +} + +func runStarlark(dir string) error { + thread := &starlark.Thread{Name: "mirum"} + globals, err := starlark.ExecFile(thread, filepath.Join(dir, entry), nil, nil) + if err != nil { + return err + } + + projectFn, ok := globals["project"] + if !ok { + return fmt.Errorf("%s: project() not defined", entry) + } + fn, ok := projectFn.(starlark.Callable) + if !ok { + return fmt.Errorf("%s: project is not a function", entry) + } + + ctx := &SOTaskCtx{dir: dir} + _, err = starlark.Call(thread, fn, starlark.Tuple{ctx}, nil) + return err +} diff --git a/internal/forges/forge.go b/internal/forges/forge.go new file mode 100644 --- /dev/null +++ b/internal/forges/forge.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package forges + +import ( + "context" + "errors" + "net/http" +) + +// ErrInvalidSignature is returned when webhook signature verification fails. +var ErrInvalidSignature = errors.New("invalid webhook signature") + +// Status represents a normalized build status. +// Each forge maps these to its native values. +type Status string + +const ( + StatusPending Status = "pending" + StatusRunning Status = "running" + StatusSuccess Status = "success" + StatusFailure Status = "failure" +) + +// PushEvent is a forge-agnostic push event. +type PushEvent struct { + Owner string // repository owner or namespace (e.g. "group/subgroup" for GitLab) + Repo string // repository name + Branch string + SHA string + CloneURL string +} + +// Forge abstracts a Git hosting platform. +type Forge interface { + // Webhook validates and parses an incoming webhook request. + // Returns (nil, nil) for events that should be silently ignored. + // Returns ErrInvalidSignature if signature verification fails. + Webhook(r *http.Request, body []byte) (*PushEvent, error) + + // SetStatus reports build status for a commit. + SetStatus(ctx context.Context, ev *PushEvent, status Status, desc string) error + + // AuthURL returns a clone URL with embedded credentials. + AuthURL(cloneURL string) string +} diff --git a/internal/forges/github.go b/internal/forges/github.go new file mode 100644 --- /dev/null +++ b/internal/forges/github.go @@ -0,0 +1,136 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package forges + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// GitHub implements the Forge interface for GitHub and compatible APIs. +type GitHub struct { + Secret string + Token string +} + +var githubStatusMap = map[Status]string{ + StatusPending: "pending", + StatusRunning: "pending", // GitHub has no "running" state + StatusSuccess: "success", + StatusFailure: "failure", +} + +type githubPush struct { + Ref string `json:"ref"` + After string `json:"after"` + Repo struct { + FullName string `json:"full_name"` + CloneURL string `json:"clone_url"` + } `json:"repository"` +} + +func (g *GitHub) Webhook(r *http.Request, body []byte) (*PushEvent, error) { + if g.Secret != "" && !g.verifySignature(body, r.Header.Get("X-Hub-Signature-256")) { + return nil, ErrInvalidSignature + } + + event := r.Header.Get("X-GitHub-Event") + if event != "push" { + return nil, nil + } + + var push githubPush + if err := json.Unmarshal(body, &push); err != nil { + return nil, fmt.Errorf("parse payload: %w", err) + } + + if push.After == "" || push.After == "0000000000000000000000000000000000000000" { + return nil, nil + } + + if !strings.HasPrefix(push.Ref, "refs/heads/") { + return nil, nil + } + + owner, repo := splitFullName(push.Repo.FullName) + return &PushEvent{ + Owner: owner, + Repo: repo, + Branch: strings.TrimPrefix(push.Ref, "refs/heads/"), + SHA: push.After, + CloneURL: push.Repo.CloneURL, + }, nil +} + +func (g *GitHub) SetStatus(ctx context.Context, ev *PushEvent, status Status, desc string) error { + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/statuses/%s", ev.Owner, ev.Repo, ev.SHA) + + body, _ := json.Marshal(map[string]string{ + "state": githubStatusMap[status], + "description": desc, + "context": "mirum", + }) + + req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+g.Token) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("github api %d: %s", resp.StatusCode, b) + } + return nil +} + +func (g *GitHub) AuthURL(cloneURL string) string { + if g.Token == "" { + return cloneURL + } + u, err := url.Parse(cloneURL) + if err != nil { + return cloneURL + } + u.User = url.UserPassword("x-access-token", g.Token) + return u.String() +} + +func (g *GitHub) verifySignature(payload []byte, signature string) bool { + sig, ok := strings.CutPrefix(signature, "sha256=") + if !ok { + return false + } + decoded, err := hex.DecodeString(sig) + if err != nil { + return false + } + mac := hmac.New(sha256.New, []byte(g.Secret)) + mac.Write(payload) + return hmac.Equal(mac.Sum(nil), decoded) +} + +func splitFullName(fullName string) (string, string) { + parts := strings.SplitN(fullName, "/", 2) + if len(parts) != 2 { + return fullName, "" + } + return parts[0], parts[1] +} diff --git a/pkg/mirumd.service b/pkg/mirumd.service index 5b1b78c..89051ba 100644 --- a/pkg/mirumd.service +++ b/pkg/mirumd.service @@ -7,6 +7,19 @@ Requires=network-online.target After=time-sync.target network-online.target remote-fs.target nss-lookup.target Wants=time-sync.target +# Socket activation (optional): +# Create mirumd.socket with named file descriptors "http" and "grpc": +# +# [Socket] +# ListenStream=0.0.0.0:3000 +# FileDescriptorName=http +# +# [Socket] +# ListenStream=0.0.0.0:2026 +# FileDescriptorName=grpc +# +# Without socket activation the daemon binds www_addr and grpc_addr from config. + [Service] Type=notify User=mirumd diff --git a/pkg/mirumd.yaml b/pkg/mirumd.yaml index 93afcca..c2cd3ea 100644 --- a/pkg/mirumd.yaml +++ b/pkg/mirumd.yaml @@ -1,6 +1,8 @@ # Copyright (c) 2026 Nikolay Govorov # SPDX-License-Identifier: AGPL-3.0-or-later +# Ignored when the corresponding systemd socket activation fd is present. +# See mirumd.socket for details (FileDescriptorName=grpc / http). grpc_addr: :2026 www_addr: :3000 secret: "" |
