gitbayd: warn at startup about a build that cannot be vouched for !118
6 files changed, +209 −3
Layout: unified · split
cmd/gitbayd/main.go +2 −2
| @@ -18,7 +18,6 @@ import ( | |||
| 18 | "golang.org/x/crypto/acme/autocert" | 18 | "golang.org/x/crypto/acme/autocert" |
| 19 | "golang.org/x/crypto/ssh" | 19 | "golang.org/x/crypto/ssh" |
| 20 | 20 | ||
| 21 | "gitbay.org/gitbay/internal/buildinfo" | ||
| 22 | "gitbay.org/gitbay/internal/ci" | 21 | "gitbay.org/gitbay/internal/ci" |
| 23 | "gitbay.org/gitbay/internal/config" | 22 | "gitbay.org/gitbay/internal/config" |
| 24 | "gitbay.org/gitbay/internal/control" | 23 | "gitbay.org/gitbay/internal/control" |
| @@ -120,11 +119,12 @@ func serveCmd() *cobra.Command { | |||
| 120 | RunE: func(cmd *cobra.Command, args []string) error { | 119 | RunE: func(cmd *cobra.Command, args []string) error { |
| 121 | // First line of every run: the journal then says which commit is | 120 | // First line of every run: the journal then says which commit is |
| 122 | // serving, without rebuilding the binary to find out. | 121 | // serving, without rebuilding the binary to find out. |
| 123 | slog.Info("gitbayd starting", "commit", buildinfo.String()) | 122 | logBuild() |
| 124 | cfg, err := config.Load(configPath) | 123 | cfg, err := config.Load(configPath) |
| 125 | if err != nil { | 124 | if err != nil { |
| 126 | return err | 125 | return err |
| 127 | } | 126 | } |
| 127 | warnIfUnmerged(cfg) | ||
| 128 | st, err := openStore(cfg) | 128 | st, err := openStore(cfg) |
| 129 | if err != nil { | 129 | if err != nil { |
| 130 | return err | 130 | return err |
cmd/gitbayd/version.go +49
| @@ -2,10 +2,15 @@ package main | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "fmt" | 4 | "fmt" |
| 5 | "log/slog" | ||
| 6 | "strings" | ||
| 5 | 7 | ||
| 6 | "github.com/spf13/cobra" | 8 | "github.com/spf13/cobra" |
| 7 | 9 | ||
| 8 | "gitbay.org/gitbay/internal/buildinfo" | 10 | "gitbay.org/gitbay/internal/buildinfo" |
| 11 | "gitbay.org/gitbay/internal/config" | ||
| 12 | "gitbay.org/gitbay/internal/control" | ||
| 13 | "gitbay.org/gitbay/internal/gitutil" | ||
| 9 | ) | 14 | ) |
| 10 | 15 | ||
| 11 | func versionCmd() *cobra.Command { | 16 | func versionCmd() *cobra.Command { |
| @@ -19,3 +24,47 @@ func versionCmd() *cobra.Command { | |||
| 19 | }, | 24 | }, |
| 20 | } | 25 | } |
| 21 | } | 26 | } |
| 27 | |||
| 28 | // logBuild announces the running build. An unidentified one is a warning | ||
| 29 | // rather than a fact: it was built from a tree that was never committed, so | ||
| 30 | // the source it came from no longer exists anywhere. | ||
| 31 | func logBuild() { | ||
| 32 | if !buildinfo.Identified() { | ||
| 33 | slog.Warn("gitbayd starting from an uncommitted build", "commit", buildinfo.String()) | ||
| 34 | return | ||
| 35 | } | ||
| 36 | slog.Info("gitbayd starting", "commit", buildinfo.String()) | ||
| 37 | } | ||
| 38 | |||
| 39 | // warnIfUnmerged says so when the running build is not on the default branch | ||
| 40 | // of the repository this instance develops itself in. Such a build serves | ||
| 41 | // perfectly well, which is exactly why it can sit unnoticed for days. | ||
| 42 | // | ||
| 43 | // Silent unless server.source_repo is set, since an instance that does not | ||
| 44 | // host its own source has nothing to check against. | ||
| 45 | func warnIfUnmerged(cfg config.Config) { | ||
| 46 | repo := cfg.Server.SourceRepo | ||
| 47 | if repo == "" || !buildinfo.Identified() { | ||
| 48 | return | ||
| 49 | } | ||
| 50 | owner, name, ok := strings.Cut(repo, "/") | ||
| 51 | if !ok || owner == "" || name == "" { | ||
| 52 | slog.Warn("server.source_repo is not owner/name; skipping the build check", "source_repo", repo) | ||
| 53 | return | ||
| 54 | } | ||
| 55 | // HEAD in a bare repository is the default branch, so there is no branch | ||
| 56 | // name to resolve or configure. | ||
| 57 | dir := control.RepoDir(cfg.Server.Root, owner, name) | ||
| 58 | onBranch, err := gitutil.IsAncestor(dir, buildinfo.String(), "HEAD") | ||
| 59 | if err != nil { | ||
| 60 | // An unpushed commit lands here too, and is worth the same warning: | ||
| 61 | // it cannot be checked, so it cannot be vouched for. | ||
| 62 | slog.Warn("cannot check this build against the source repository", | ||
| 63 | "commit", buildinfo.String(), "repo", repo, "err", err) | ||
| 64 | return | ||
| 65 | } | ||
| 66 | if !onBranch { | ||
| 67 | slog.Warn("this build is not on the source repository's default branch", | ||
| 68 | "commit", buildinfo.String(), "repo", repo) | ||
| 69 | } | ||
| 70 | } | ||
cmd/gitbayd/version_test.go added +121
| @@ -0,0 +1,121 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "log/slog" | ||
| 6 | "os/exec" | ||
| 7 | "path/filepath" | ||
| 8 | "strings" | ||
| 9 | "testing" | ||
| 10 | |||
| 11 | "gitbay.org/gitbay/internal/buildinfo" | ||
| 12 | "gitbay.org/gitbay/internal/config" | ||
| 13 | "gitbay.org/gitbay/internal/control" | ||
| 14 | ) | ||
| 15 | |||
| 16 | func capture(t *testing.T, fn func()) string { | ||
| 17 | t.Helper() | ||
| 18 | var buf bytes.Buffer | ||
| 19 | prev := slog.Default() | ||
| 20 | slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil))) | ||
| 21 | defer slog.SetDefault(prev) | ||
| 22 | fn() | ||
| 23 | return buf.String() | ||
| 24 | } | ||
| 25 | |||
| 26 | func TestLogBuildWarnsOnAnUncommittedBuild(t *testing.T) { | ||
| 27 | prev := buildinfo.Commit | ||
| 28 | defer func() { buildinfo.Commit = prev }() | ||
| 29 | |||
| 30 | buildinfo.Commit = "abc123abc123" | ||
| 31 | if out := capture(t, logBuild); !strings.Contains(out, "level=INFO") { | ||
| 32 | t.Errorf("a committed build should log INFO:\n%s", out) | ||
| 33 | } | ||
| 34 | |||
| 35 | buildinfo.Commit = "abc123abc123-dirty" | ||
| 36 | out := capture(t, logBuild) | ||
| 37 | if !strings.Contains(out, "level=WARN") { | ||
| 38 | t.Errorf("a dirty build should log WARN:\n%s", out) | ||
| 39 | } | ||
| 40 | if !strings.Contains(out, "abc123abc123-dirty") { | ||
| 41 | t.Errorf("the warning should name the build:\n%s", out) | ||
| 42 | } | ||
| 43 | } | ||
| 44 | |||
| 45 | // sourceRepo builds a bare repository holding one commit on the default | ||
| 46 | // branch, plus one commit off it, and returns the config pointing at it. | ||
| 47 | func sourceRepo(t *testing.T) (cfg config.Config, onBranch, offBranch string) { | ||
| 48 | t.Helper() | ||
| 49 | root := t.TempDir() | ||
| 50 | dir := control.RepoDir(root, "krz", "gitbay") | ||
| 51 | |||
| 52 | work := filepath.Join(t.TempDir(), "work") | ||
| 53 | run := func(args ...string) string { | ||
| 54 | t.Helper() | ||
| 55 | cmd := exec.Command("git", args...) | ||
| 56 | cmd.Env = append(cmd.Environ(), | ||
| 57 | "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@e", "GIT_AUTHOR_DATE=2026-01-01T00:00:00Z", | ||
| 58 | "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@e", "GIT_COMMITTER_DATE=2026-01-01T00:00:00Z") | ||
| 59 | out, err := cmd.CombinedOutput() | ||
| 60 | if err != nil { | ||
| 61 | t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) | ||
| 62 | } | ||
| 63 | return strings.TrimSpace(string(out)) | ||
| 64 | } | ||
| 65 | |||
| 66 | run("init", "-q", "-b", "main", work) | ||
| 67 | run("-C", work, "commit", "-q", "--allow-empty", "-m", "on the branch") | ||
| 68 | onBranch = run("-C", work, "rev-parse", "HEAD") | ||
| 69 | run("-C", work, "checkout", "-q", "-b", "side") | ||
| 70 | run("-C", work, "commit", "-q", "--allow-empty", "-m", "off the branch") | ||
| 71 | offBranch = run("-C", work, "rev-parse", "HEAD") | ||
| 72 | |||
| 73 | run("clone", "-q", "--bare", work, dir) | ||
| 74 | run("-C", dir, "symbolic-ref", "HEAD", "refs/heads/main") | ||
| 75 | |||
| 76 | cfg = config.Config{Server: config.Server{Root: root, SourceRepo: "krz/gitbay"}} | ||
| 77 | return cfg, onBranch, offBranch | ||
| 78 | } | ||
| 79 | |||
| 80 | func TestWarnIfUnmerged(t *testing.T) { | ||
| 81 | prev := buildinfo.Commit | ||
| 82 | defer func() { buildinfo.Commit = prev }() | ||
| 83 | |||
| 84 | cfg, onBranch, offBranch := sourceRepo(t) | ||
| 85 | |||
| 86 | buildinfo.Commit = onBranch | ||
| 87 | if out := capture(t, func() { warnIfUnmerged(cfg) }); out != "" { | ||
| 88 | t.Errorf("a build on the default branch should be silent:\n%s", out) | ||
| 89 | } | ||
| 90 | |||
| 91 | buildinfo.Commit = offBranch | ||
| 92 | out := capture(t, func() { warnIfUnmerged(cfg) }) | ||
| 93 | if !strings.Contains(out, "not on the source repository") { | ||
| 94 | t.Errorf("a build off the default branch should warn:\n%s", out) | ||
| 95 | } | ||
| 96 | |||
| 97 | // A commit the repository has never seen cannot be vouched for. | ||
| 98 | buildinfo.Commit = "0123456789abcdef0123456789abcdef01234567" | ||
| 99 | if out := capture(t, func() { warnIfUnmerged(cfg) }); !strings.Contains(out, "cannot check") { | ||
| 100 | t.Errorf("an unknown commit should warn:\n%s", out) | ||
| 101 | } | ||
| 102 | } | ||
| 103 | |||
| 104 | func TestWarnIfUnmergedIsSilentWithoutASourceRepo(t *testing.T) { | ||
| 105 | prev := buildinfo.Commit | ||
| 106 | defer func() { buildinfo.Commit = prev }() | ||
| 107 | buildinfo.Commit = "abc123abc123" | ||
| 108 | |||
| 109 | // The default for every instance that does not host its own source. | ||
| 110 | cfg := config.Config{Server: config.Server{Root: t.TempDir()}} | ||
| 111 | if out := capture(t, func() { warnIfUnmerged(cfg) }); out != "" { | ||
| 112 | t.Errorf("no source_repo should mean no check:\n%s", out) | ||
| 113 | } | ||
| 114 | |||
| 115 | // A dirty build has already been warned about by logBuild; do not warn twice. | ||
| 116 | cfg.Server.SourceRepo = "krz/gitbay" | ||
| 117 | buildinfo.Commit = "abc123abc123-dirty" | ||
| 118 | if out := capture(t, func() { warnIfUnmerged(cfg) }); out != "" { | ||
| 119 | t.Errorf("a dirty build is logBuild's to report, not this one's:\n%s", out) | ||
| 120 | } | ||
| 121 | } | ||
internal/buildinfo/buildinfo.go +12 −1
| @@ -3,7 +3,10 @@ | |||
| 3 | // a hash comparison to infer it. | 3 | // a hash comparison to infer it. |
| 4 | package buildinfo | 4 | package buildinfo |
| 5 | 5 | ||
| 6 | import "runtime/debug" | 6 | import ( |
| 7 | "runtime/debug" | ||
| 8 | "strings" | ||
| 9 | ) | ||
| 7 | 10 | ||
| 8 | // Commit is stamped at link time by the Makefile: | 11 | // Commit is stamped at link time by the Makefile: |
| 9 | // | 12 | // |
| @@ -43,3 +46,11 @@ func String() string { | |||
| 43 | } | 46 | } |
| 44 | return rev + modified | 47 | return rev + modified |
| 45 | } | 48 | } |
| 49 | |||
| 50 | // Identified reports whether this build can be traced back to a commit that | ||
| 51 | // exists in history. A dirty or missing stamp means it cannot: the tree it was | ||
| 52 | // built from was never committed, so nothing can say what is running. | ||
| 53 | func Identified() bool { | ||
| 54 | s := String() | ||
| 55 | return s != "unknown" && !strings.HasSuffix(s, "-dirty") | ||
| 56 | } | ||
internal/buildinfo/buildinfo_test.go +19
| @@ -54,3 +54,22 @@ func TestMakefileStampMatchesHEAD(t *testing.T) { | |||
| 54 | t.Errorf("String() = %q, but HEAD is %q", got, want) | 54 | t.Errorf("String() = %q, but HEAD is %q", got, want) |
| 55 | } | 55 | } |
| 56 | } | 56 | } |
| 57 | |||
| 58 | func TestIdentified(t *testing.T) { | ||
| 59 | prev := Commit | ||
| 60 | defer func() { Commit = prev }() | ||
| 61 | |||
| 62 | for _, c := range []struct { | ||
| 63 | stamp string | ||
| 64 | want bool | ||
| 65 | }{ | ||
| 66 | {"b685adf5ab7a", true}, | ||
| 67 | {"b685adf5ab7a-dirty", false}, | ||
| 68 | {"unknown", false}, | ||
| 69 | } { | ||
| 70 | Commit = c.stamp | ||
| 71 | if got := Identified(); got != c.want { | ||
| 72 | t.Errorf("Identified() with stamp %q = %v, want %v", c.stamp, got, c.want) | ||
| 73 | } | ||
| 74 | } | ||
| 75 | } | ||
internal/config/config.go +6
| @@ -35,6 +35,12 @@ type Config struct { | |||
| 35 | type Server struct { | 35 | type Server struct { |
| 36 | Root string `toml:"root"` | 36 | Root string `toml:"root"` |
| 37 | SiteURL string `toml:"site_url"` | 37 | SiteURL string `toml:"site_url"` |
| 38 | |||
| 39 | // SourceRepo names the repository this instance develops itself in, as | ||
| 40 | // "owner/name". When set, startup warns if the running build's commit is | ||
| 41 | // not on that repository's default branch. Empty disables the check, which | ||
| 42 | // is right for any instance that does not host its own source. | ||
| 43 | SourceRepo string `toml:"source_repo"` | ||
| 38 | } | 44 | } |
| 39 | 45 | ||
| 40 | type SSH struct { | 46 | type SSH struct { |