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