1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "strings"
10 "time"
11
12 "connectrpc.com/connect"
13 "github.com/spf13/cobra"
14
15 "bnema.dev/ermite-api/gen/ermite"
16 "bnema.dev/ermite/internal/adapters/client"
17)
18
19type jsonRunner struct {
20 ID string `json:"id"`
21 Name string `json:"name"`
22 Labels []string `json:"labels"`
23 Isolation string `json:"isolation"`
24 Version string `json:"version"`
25 Arch string `json:"arch"`
26 Os string `json:"os"`
27 RegisteredAt *string `json:"registered_at"`
28 LastSeen *string `json:"last_seen"`
29 Busy bool `json:"busy"`
30}
31
32const jsonRunnerHelp = `
33JSON fields of a runner (--json): id, name, labels, isolation (vm, container-sysbox,
34container-privileged), version, arch, os, registered_at, last_seen (RFC 3339 UTC, null if never
35seen) and busy. A runner not seen for more than about 2 minutes is offline.`
36
37func newRunnerCmd(env *cliEnv) *cobra.Command {
38 runner := &cobra.Command{
39 Use: "runner",
40 Short: "List and remove CI runners",
41 Long: "Manage the CI runners registered on a remote server. A runner registers itself with a token\n" +
42 "made by `ermite token create --preset runner`. Needs the runner:register scope, on a token that is\n" +
43 "not limited to some repositories.",
44 }
45 runner.AddCommand(newRunnerListCmd(env), newRunnerRemoveCmd(env))
46 return runner
47}
48
49func newRunnerListCmd(env *cliEnv) *cobra.Command {
50 cmd := &cobra.Command{
51 Use: "list",
52 Short: "List the registered runners",
53 Long: "List the registered runners ordered by name." + jsonRunnerHelp + "\n--json prints an array of runners.",
54 Args: cobra.NoArgs,
55 }
56 asJSON := addJSONFlag(cmd)
57 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
58 c, err := env.client(cmd)
59 if err != nil {
60 return err
61 }
62 ctx := env.ctx(cmd)
63 runners, err := collect(ctx, func(token string) ([]*ermite.Runner, string, error) {
64 res, err := c.Runs().ListRunners(ctx, connect.NewRequest(&ermite.ListRunnersRequest{PageToken: token}))
65 if err != nil {
66 return nil, "", err
67 }
68 return res.Msg.GetRunners(), res.Msg.GetNextPageToken(), nil
69 })
70 if err != nil {
71 return err
72 }
73 if *asJSON {
74 out := make([]jsonRunner, 0, len(runners))
75 for _, r := range runners {
76 labels := r.GetLabels()
77 if labels == nil {
78 labels = []string{}
79 }
80 out = append(out, jsonRunner{
81 ID: r.GetId(), Name: r.GetName(), Labels: labels, Isolation: isolationText(r.GetIsolation()), Version: r.GetVersion(),
82 Arch: r.GetArch(), Os: r.GetOs(), RegisteredAt: jsonTime(r.GetRegisteredAt()), LastSeen: jsonTime(r.GetLastSeen()), Busy: r.GetBusy(),
83 })
84 }
85 return writeJSON(cmd.OutOrStdout(), out)
86 }
87 if len(runners) == 0 {
88 noticef(cmd, "no runners: register one with `ermite-runner register`")
89 return nil
90 }
91 rows := make([][]string, 0, len(runners))
92 for _, r := range runners {
93 state := "offline"
94 if ls := r.GetLastSeen(); ls.IsValid() && env.now().Sub(ls.AsTime()) < 2*time.Minute {
95 state = "online"
96 if r.GetBusy() {
97 state = "busy"
98 }
99 }
100 rows = append(rows, []string{
101 r.GetId(), r.GetName(), state, isolationText(r.GetIsolation()), r.GetVersion(),
102 strings.Join(r.GetLabels(), ","), shortTime(r.GetLastSeen(), "never"),
103 })
104 }
105 return table(cmd.OutOrStdout(), []string{"ID", "NAME", "STATE", "ISOLATION", "VERSION", "LABELS", "LAST SEEN"}, rows)
106 }
107 return cmd
108}
109
110func newRunnerRemoveCmd(env *cliEnv) *cobra.Command {
111 cmd := &cobra.Command{
112 Use: "remove <runner id>",
113 Short: "Remove a runner",
114 Long: "Remove a runner: its token stops working at once and it gets no more jobs. A job it is running is not\n" +
115 "affected: it ends, or fails as a lost runner when its heartbeats stop.",
116 Args: cobra.ExactArgs(1),
117 ValidArgsFunction: func(cmd *cobra.Command, args []string, prefix string) ([]string, cobra.ShellCompDirective) {
118 if len(args) > 0 {
119 return nil, cobra.ShellCompDirectiveNoFileComp
120 }
121 out := complete(env, cmd, prefix, func(ctx context.Context, c *client.Client) ([]*ermite.Runner, error) {
122 res, err := c.Runs().ListRunners(ctx, connect.NewRequest(&ermite.ListRunnersRequest{}))
123 if err != nil {
124 return nil, err
125 }
126 return res.Msg.GetRunners(), nil
127 }, func(r *ermite.Runner) (string, string) { return r.GetId(), r.GetName() })
128 return out, cobra.ShellCompDirectiveNoFileComp
129 },
130 }
131 cmd.RunE = func(cmd *cobra.Command, args []string) error {
132 c, err := env.client(cmd)
133 if err != nil {
134 return err
135 }
136 if _, err := c.Runs().RemoveRunner(env.ctx(cmd), connect.NewRequest(&ermite.RemoveRunnerRequest{RunnerId: args[0]})); err != nil {
137 var ce *connect.Error
138 if errors.As(err, &ce) && ce.Code() == connect.CodeNotFound {
139 return fmt.Errorf("no runner has the id %q", args[0])
140 }
141 return err
142 }
143 noticef(cmd, "runner %s removed", args[0])
144 return nil
145 }
146 return cmd
147}