aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat
-rw-r--r--cmd/mirumd/main.go266+52 −214
-rw-r--r--internal/executor/executor.go109+109 −0
-rw-r--r--internal/forges/forge.go47+47 −0
-rw-r--r--internal/forges/github.go136+136 −0
-rw-r--r--pkg/mirumd.service13+13 −0
-rw-r--r--pkg/mirumd.yaml2+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: ""