Resolve builds a dead runner abandoned, and forward bare-redirect stdin !124
5 files changed, +188 −7
Layout: unified · split
cmd/gitbay/coverage_test.go +36
| @@ -55,3 +55,39 @@ func TestEveryCommandIsReachable(t *testing.T) { | |||
| 55 | } | 55 | } |
| 56 | } | 56 | } |
| 57 | } | 57 | } |
| 58 | |||
| 59 | // Commands whose payload is stdin with no flag naming it: `gitbay keys add < | ||
| 60 | // key.pub`, not `--file -`. They must be wired alwaysStdin, because the | ||
| 61 | // stdinOK path only forwards stdin when it sees `--file -` in the arguments — | ||
| 62 | // so a bare redirect reached the server as an empty body and the command | ||
| 63 | // failed on input the SSH API accepts. | ||
| 64 | var bareStdin = []string{ | ||
| 65 | "keys add", | ||
| 66 | "repo deploy-key add", | ||
| 67 | "repo secret set", | ||
| 68 | "release asset add", | ||
| 69 | } | ||
| 70 | |||
| 71 | func TestBareRedirectCommandsAlwaysForwardStdin(t *testing.T) { | ||
| 72 | modes := map[string]string{} | ||
| 73 | var walk func(*cobra.Command) | ||
| 74 | walk = func(c *cobra.Command) { | ||
| 75 | if p := c.Annotations[serverPath]; p != "" { | ||
| 76 | modes[p] = c.Annotations[stdinMode] | ||
| 77 | } | ||
| 78 | for _, sub := range c.Commands() { | ||
| 79 | walk(sub) | ||
| 80 | } | ||
| 81 | } | ||
| 82 | walk(newRoot()) | ||
| 83 | |||
| 84 | for _, path := range bareStdin { | ||
| 85 | switch modes[path] { | ||
| 86 | case "always": | ||
| 87 | case "": | ||
| 88 | t.Errorf("%q is not wired in the CLI", path) | ||
| 89 | default: | ||
| 90 | t.Errorf("%q is wired %s; a bare redirect needs alwaysStdin", path, modes[path]) | ||
| 91 | } | ||
| 92 | } | ||
| 93 | } | ||
cmd/gitbay/main.go +24 −7
| @@ -83,23 +83,40 @@ func newRoot() *cobra.Command { | |||
| 83 | // serverPath is the annotation key holding a passthrough command's | 83 | // serverPath is the annotation key holding a passthrough command's |
| 84 | // server-side path, so the tree can be checked against the registry. | 84 | // server-side path, so the tree can be checked against the registry. |
| 85 | const serverPath = "gitbay.server_path" | 85 | const serverPath = "gitbay.server_path" |
| 86 | const stdinMode = "gitbay.stdin_mode" | ||
| 86 | 87 | ||
| 87 | // passOpts describes how one CLI command maps onto the server command. | 88 | // passOpts describes how one CLI command maps onto the server command. |
| 88 | type passOpts struct { | 89 | type passOpts struct { |
| 89 | server []string // server-side command path | 90 | server []string // server-side command path |
| 90 | needsRepo bool // prepend inferred owner/name unless given | 91 | needsRepo bool // prepend inferred owner/name unless given |
| 91 | stdinOK bool // wire local stdin through (keys add, --file -) | 92 | stdinOK bool // wire local stdin through when --file - asks for it |
| 92 | alwaysStdin bool // stdin is the payload (release asset add) | 93 | alwaysStdin bool // stdin is the payload, named by no flag: a bare redirect |
| 93 | editor string // open $EDITOR for a body when none given | 94 | editor string // open $EDITOR for a body when none given |
| 94 | } | 95 | } |
| 95 | 96 | ||
| 96 | // pass builds a passthrough command. Flags are parsed by the server, which | 97 | // pass builds a passthrough command. Flags are parsed by the server, which |
| 97 | // is the single source of truth for them; the CLI stays thin. | 98 | // is the single source of truth for them; the CLI stays thin. |
| 99 | // stdinModeName reports how this command takes stdin, so the coverage test can | ||
| 100 | // check that a command reading a bare redirect is not left waiting for a | ||
| 101 | // `--file -` that its callers never type. | ||
| 102 | func (o passOpts) stdinModeName() string { | ||
| 103 | switch { | ||
| 104 | case o.alwaysStdin: | ||
| 105 | return "always" | ||
| 106 | case o.stdinOK: | ||
| 107 | return "flag" | ||
| 108 | } | ||
| 109 | return "none" | ||
| 110 | } | ||
| 111 | |||
| 98 | func pass(use, short string, o passOpts) *cobra.Command { | 112 | func pass(use, short string, o passOpts) *cobra.Command { |
| 99 | return &cobra.Command{ | 113 | return &cobra.Command{ |
| 100 | Use: use, | 114 | Use: use, |
| 101 | Short: short, | 115 | Short: short, |
| 102 | Annotations: map[string]string{serverPath: strings.Join(o.server, " ")}, | 116 | Annotations: map[string]string{ |
| 117 | serverPath: strings.Join(o.server, " "), | ||
| 118 | stdinMode: o.stdinModeName(), | ||
| 119 | }, | ||
| 103 | DisableFlagParsing: true, | 120 | DisableFlagParsing: true, |
| 104 | RunE: func(cmd *cobra.Command, args []string) error { | 121 | RunE: func(cmd *cobra.Command, args []string) error { |
| 105 | // cobra still owns `forge <cmd> --help`. | 122 | // cobra still owns `forge <cmd> --help`. |
| @@ -201,7 +218,7 @@ func local(use, short string, fn func(args []string) int) *cobra.Command { | |||
| 201 | 218 | ||
| 202 | func authCmd() *cobra.Command { | 219 | func authCmd() *cobra.Command { |
| 203 | keysAdd := pass("add", "register an SSH public key (reads the key from stdin or --file -)", | 220 | keysAdd := pass("add", "register an SSH public key (reads the key from stdin or --file -)", |
| 204 | passOpts{server: []string{"keys", "add"}, stdinOK: true}) | 221 | passOpts{server: []string{"keys", "add"}, alwaysStdin: true}) |
| 205 | // keys add always reads stdin on the server; wire it through directly. | 222 | // keys add always reads stdin on the server; wire it through directly. |
| 206 | keysAdd.RunE = func(cmd *cobra.Command, args []string) error { | 223 | keysAdd.RunE = func(cmd *cobra.Command, args []string) error { |
| 207 | t, err := resolveTarget() | 224 | t, err := resolveTarget() |
| @@ -283,7 +300,7 @@ func repoCmd() *cobra.Command { | |||
| 283 | pass("import-issues", "import GitHub issue/PR history: --from <ghowner/ghrepo> [--token-stdin]", | 300 | pass("import-issues", "import GitHub issue/PR history: --from <ghowner/ghrepo> [--token-stdin]", |
| 284 | passOpts{server: []string{"repo", "import-issues"}, needsRepo: true, stdinOK: true}), | 301 | passOpts{server: []string{"repo", "import-issues"}, needsRepo: true, stdinOK: true}), |
| 285 | group("deploy-key", "repository-bound CI keys", | 302 | group("deploy-key", "repository-bound CI keys", |
| 286 | pass("add", "bind a key: [--rw] < key.pub", passOpts{server: []string{"repo", "deploy-key", "add"}, needsRepo: true, stdinOK: true}), | 303 | pass("add", "bind a key: [--rw] < key.pub", passOpts{server: []string{"repo", "deploy-key", "add"}, needsRepo: true, alwaysStdin: true}), |
| 287 | pass("list", "list deploy keys", passOpts{server: []string{"repo", "deploy-key", "list"}, needsRepo: true}), | 304 | pass("list", "list deploy keys", passOpts{server: []string{"repo", "deploy-key", "list"}, needsRepo: true}), |
| 288 | pass("remove", "remove a deploy key: <fingerprint>", passOpts{server: []string{"repo", "deploy-key", "remove"}, needsRepo: true}), | 305 | pass("remove", "remove a deploy key: <fingerprint>", passOpts{server: []string{"repo", "deploy-key", "remove"}, needsRepo: true}), |
| 289 | ), | 306 | ), |
internal/control/build.go +15
| @@ -301,6 +301,21 @@ func runRunnerNext(c *Ctx, args []string) int { | |||
| 301 | if code := requireRunner(c); code >= 0 { | 301 | if code := requireRunner(c); code >= 0 { |
| 302 | return code | 302 | return code |
| 303 | } | 303 | } |
| 304 | // Resolve anything a previous runner claimed and never reported, so a | ||
| 305 | // killed runner does not leave a build running and a commit pending forever. | ||
| 306 | if stale, err := c.Store.ReapStaleBuilds(); err != nil { | ||
| 307 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 308 | } else { | ||
| 309 | for _, sb := range stale { | ||
| 310 | repo, err := c.Store.RepoByID(sb.RepoID) | ||
| 311 | if err != nil { | ||
| 312 | continue | ||
| 313 | } | ||
| 314 | url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), sb.Number) | ||
| 315 | c.Store.SetCommitStatus(repo.ID, sb.SHA, "ci/"+sb.Job, "failure", | ||
| 316 | "build abandoned", url, c.User.ID) | ||
| 317 | } | ||
| 318 | } | ||
| 304 | b, ok, err := c.Store.ClaimBuild() | 319 | b, ok, err := c.Store.ClaimBuild() |
| 305 | if err != nil { | 320 | if err != nil { |
| 306 | return c.fail(protocol.ExitFailure, "%v", err) | 321 | return c.fail(protocol.ExitFailure, "%v", err) |
internal/store/builds.go +42
| @@ -3,6 +3,7 @@ package store | |||
| 3 | import ( | 3 | import ( |
| 4 | "database/sql" | 4 | "database/sql" |
| 5 | "errors" | 5 | "errors" |
| 6 | "time" | ||
| 6 | ) | 7 | ) |
| 7 | 8 | ||
| 8 | // Build is one CI job execution for one commit. | 9 | // Build is one CI job execution for one commit. |
| @@ -83,6 +84,47 @@ func (s *Store) ClaimBuild() (Build, bool, error) { | |||
| 83 | return b, true, tx.Commit() | 84 | return b, true, tx.Commit() |
| 84 | } | 85 | } |
| 85 | 86 | ||
| 87 | // StaleBuildDeadline is how long a claimed build may stay running before the | ||
| 88 | // server gives up on it. Comfortably longer than the runner's own -timeout | ||
| 89 | // (45m by default), so this only fires when the runner never reported at all — | ||
| 90 | // it was killed, restarted, or lost the network mid-build. | ||
| 91 | const StaleBuildDeadline = 90 * time.Minute | ||
| 92 | |||
| 93 | // ReapStaleBuilds fails every build that has been running past the deadline and | ||
| 94 | // returns them, so the caller can resolve their commit statuses. A runner that | ||
| 95 | // dies between claiming a build and reporting it otherwise leaves the row | ||
| 96 | // claimed forever, and the commit pending forever with it. | ||
| 97 | func (s *Store) ReapStaleBuilds() ([]Build, error) { | ||
| 98 | cutoff := time.Now().UTC().Add(-StaleBuildDeadline).Format("2006-01-02T15:04:05Z") | ||
| 99 | rows, err := s.DB.Query(buildSelect+ | ||
| 100 | " WHERE status = 'running' AND started_at != '' AND started_at < ?", cutoff) | ||
| 101 | if err != nil { | ||
| 102 | return nil, err | ||
| 103 | } | ||
| 104 | defer rows.Close() | ||
| 105 | var stale []Build | ||
| 106 | for rows.Next() { | ||
| 107 | b, err := scanBuild(rows) | ||
| 108 | if err != nil { | ||
| 109 | return nil, err | ||
| 110 | } | ||
| 111 | stale = append(stale, b) | ||
| 112 | } | ||
| 113 | if err := rows.Err(); err != nil { | ||
| 114 | return nil, err | ||
| 115 | } | ||
| 116 | for _, b := range stale { | ||
| 117 | if err := s.AppendBuildLog(b.ID, []byte( | ||
| 118 | "\nbuild abandoned: the runner never reported an outcome\n")); err != nil { | ||
| 119 | return nil, err | ||
| 120 | } | ||
| 121 | if err := s.FinishBuild(b.ID, "failure"); err != nil { | ||
| 122 | return nil, err | ||
| 123 | } | ||
| 124 | } | ||
| 125 | return stale, nil | ||
| 126 | } | ||
| 127 | |||
| 86 | // AppendBuildLog adds a chunk to the build's log, dropping bytes past the cap. | 128 | // AppendBuildLog adds a chunk to the build's log, dropping bytes past the cap. |
| 87 | func (s *Store) AppendBuildLog(id int64, chunk []byte) error { | 129 | func (s *Store) AppendBuildLog(id int64, chunk []byte) error { |
| 88 | _, err := s.DB.Exec(` | 130 | _, err := s.DB.Exec(` |
internal/store/builds_test.go added +71
| @@ -0,0 +1,71 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "strings" | ||
| 5 | "testing" | ||
| 6 | ) | ||
| 7 | |||
| 8 | // A runner that dies between claiming a build and reporting it leaves the row | ||
| 9 | // claimed. The next claim resolves it rather than leaving the build running and | ||
| 10 | // the commit pending forever. | ||
| 11 | func TestReapStaleBuilds(t *testing.T) { | ||
| 12 | s := open(t) | ||
| 13 | if err := s.MigrateUp(); err != nil { | ||
| 14 | t.Fatal(err) | ||
| 15 | } | ||
| 16 | uid, err := s.CreateUser("cmc", true) | ||
| 17 | if err != nil { | ||
| 18 | t.Fatal(err) | ||
| 19 | } | ||
| 20 | if _, err := s.CreateRepo("user", uid, "orgo", "public"); err != nil { | ||
| 21 | t.Fatal(err) | ||
| 22 | } | ||
| 23 | |||
| 24 | stuck, err := s.CreateBuild(1, "test", "abc123", "main", `["true"]`) | ||
| 25 | if err != nil { | ||
| 26 | t.Fatal(err) | ||
| 27 | } | ||
| 28 | fresh, err := s.CreateBuild(1, "pages", "abc123", "main", `["true"]`) | ||
| 29 | if err != nil { | ||
| 30 | t.Fatal(err) | ||
| 31 | } | ||
| 32 | |||
| 33 | // Claim both, then age only the first past the deadline. | ||
| 34 | for range 2 { | ||
| 35 | if _, ok, err := s.ClaimBuild(); err != nil || !ok { | ||
| 36 | t.Fatalf("claim: %v ok=%v", err, ok) | ||
| 37 | } | ||
| 38 | } | ||
| 39 | if _, err := s.DB.Exec( | ||
| 40 | `UPDATE builds SET started_at = '2020-01-01T00:00:00Z' WHERE number = ?`, stuck); err != nil { | ||
| 41 | t.Fatal(err) | ||
| 42 | } | ||
| 43 | |||
| 44 | reaped, err := s.ReapStaleBuilds() | ||
| 45 | if err != nil { | ||
| 46 | t.Fatal(err) | ||
| 47 | } | ||
| 48 | if len(reaped) != 1 || reaped[0].Number != stuck { | ||
| 49 | t.Fatalf("reaped %+v, want only build %d", reaped, stuck) | ||
| 50 | } | ||
| 51 | |||
| 52 | b, err := s.BuildByNumber(1, stuck) | ||
| 53 | if err != nil { | ||
| 54 | t.Fatal(err) | ||
| 55 | } | ||
| 56 | if b.Status != "failure" || b.FinishedAt == "" { | ||
| 57 | t.Fatalf("stale build is %s finished %q, want failure with a timestamp", b.Status, b.FinishedAt) | ||
| 58 | } | ||
| 59 | log, err := s.BuildLog(b.ID) | ||
| 60 | if err != nil { | ||
| 61 | t.Fatal(err) | ||
| 62 | } | ||
| 63 | if !strings.Contains(string(log), "abandoned") { | ||
| 64 | t.Fatalf("log does not say why it failed: %q", log) | ||
| 65 | } | ||
| 66 | |||
| 67 | // A build still inside the deadline is left alone. | ||
| 68 | if b, err := s.BuildByNumber(1, fresh); err != nil || b.Status != "running" { | ||
| 69 | t.Fatalf("fresh build is %v (%v), want running", b.Status, err) | ||
| 70 | } | ||
| 71 | } | ||