Go · 6437 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package main
4
5 import (
6 "context"
7 "errors"
8 "fmt"
9 "strconv"
10 "strings"
11 "text/tabwriter"
12 "time"
13
14 "github.com/jackc/pgx/v5/pgtype"
15 "github.com/jackc/pgx/v5/pgxpool"
16 "github.com/spf13/cobra"
17
18 "github.com/tenseleyFlow/shithub/internal/actions/runnerlabels"
19 "github.com/tenseleyFlow/shithub/internal/actions/runnertoken"
20 actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
21 "github.com/tenseleyFlow/shithub/internal/infra/config"
22 "github.com/tenseleyFlow/shithub/internal/infra/db"
23 "github.com/tenseleyFlow/shithub/internal/infra/metrics"
24 )
25
26 func newAdminRunnerCmd() *cobra.Command {
27 cmd := &cobra.Command{
28 Use: "runner",
29 Short: "Register, list, and revoke Actions runners",
30 }
31 cmd.AddCommand(newAdminRunnerRegisterCmd())
32 cmd.AddCommand(newAdminRunnerListCmd())
33 cmd.AddCommand(newAdminRunnerRevokeCmd())
34 return cmd
35 }
36
37 func newAdminRunnerRegisterCmd() *cobra.Command {
38 var name string
39 var labelsRaw string
40 var capacity int
41 cmd := &cobra.Command{
42 Use: "register --name <name> [--labels self-hosted,linux] [--capacity 1]",
43 Short: "Register an Actions runner and print its token once",
44 RunE: func(cmd *cobra.Command, _ []string) error {
45 name = strings.TrimSpace(name)
46 if name == "" {
47 return errors.New("admin runner register: --name is required")
48 }
49 labels, err := parseRunnerLabels(labelsRaw)
50 if err != nil {
51 return err
52 }
53 if capacity < 1 || capacity > 64 {
54 return errors.New("admin runner register: --capacity must be between 1 and 64")
55 }
56
57 cfg, err := config.Load(nil)
58 if err != nil {
59 return err
60 }
61 ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
62 defer cancel()
63 pool, err := openAdminRunnerPool(ctx, cfg, "register")
64 if err != nil {
65 return err
66 }
67 defer pool.Close()
68
69 token, tokenHash, err := runnertoken.New()
70 if err != nil {
71 return fmt.Errorf("admin runner register: mint token: %w", err)
72 }
73
74 q := actionsdb.New()
75 tx, err := pool.Begin(ctx)
76 if err != nil {
77 return fmt.Errorf("admin runner register: begin: %w", err)
78 }
79 committed := false
80 defer func() {
81 if !committed {
82 _ = tx.Rollback(ctx)
83 }
84 }()
85
86 runner, err := q.InsertRunner(ctx, tx, actionsdb.InsertRunnerParams{
87 Name: name,
88 Labels: labels,
89 Capacity: int32(capacity),
90 RegisteredByUserID: pgtype.Int8{},
91 })
92 if err != nil {
93 return fmt.Errorf("admin runner register: insert runner: %w", err)
94 }
95 if _, err := q.InsertRunnerToken(ctx, tx, actionsdb.InsertRunnerTokenParams{
96 RunnerID: runner.ID,
97 TokenHash: tokenHash,
98 ExpiresAt: pgtype.Timestamptz{},
99 }); err != nil {
100 return fmt.Errorf("admin runner register: insert token: %w", err)
101 }
102 if err := tx.Commit(ctx); err != nil {
103 return fmt.Errorf("admin runner register: commit: %w", err)
104 }
105 committed = true
106 metrics.ActionsRunnerRegistrationsTotal.Inc()
107
108 _, _ = fmt.Fprintf(cmd.OutOrStdout(),
109 "runner registered\nid: %d\nname: %s\nlabels: %s\ncapacity: %d\ntoken: %s\n\nStore this token now; shithub never shows it again.\n",
110 runner.ID, runner.Name, strings.Join(runner.Labels, ","), runner.Capacity, token)
111 return nil
112 },
113 }
114 cmd.Flags().StringVar(&name, "name", "", "Runner name (letters, numbers, underscore, dash)")
115 cmd.Flags().StringVar(&labelsRaw, "labels", "", "Comma-separated runner labels")
116 cmd.Flags().IntVar(&capacity, "capacity", 1, "Maximum concurrent jobs this runner may execute")
117 return cmd
118 }
119
120 func newAdminRunnerListCmd() *cobra.Command {
121 return &cobra.Command{
122 Use: "list",
123 Short: "List registered Actions runners",
124 RunE: func(cmd *cobra.Command, _ []string) error {
125 cfg, err := config.Load(nil)
126 if err != nil {
127 return err
128 }
129 ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
130 defer cancel()
131 pool, err := openAdminRunnerPool(ctx, cfg, "list")
132 if err != nil {
133 return err
134 }
135 defer pool.Close()
136
137 rows, err := actionsdb.New().ListRunners(ctx, pool)
138 if err != nil {
139 return fmt.Errorf("admin runner list: %w", err)
140 }
141 tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
142 _, _ = fmt.Fprintln(tw, "ID\tNAME\tSTATUS\tCAPACITY\tLABELS\tLAST_HEARTBEAT")
143 for _, r := range rows {
144 last := "never"
145 if r.LastHeartbeatAt.Valid {
146 last = r.LastHeartbeatAt.Time.Format(time.RFC3339)
147 }
148 _, _ = fmt.Fprintf(tw, "%d\t%s\t%s\t%d\t%s\t%s\n",
149 r.ID, r.Name, r.Status, r.Capacity, strings.Join(r.Labels, ","), last)
150 }
151 return tw.Flush()
152 },
153 }
154 }
155
156 func newAdminRunnerRevokeCmd() *cobra.Command {
157 var idRaw string
158 cmd := &cobra.Command{
159 Use: "revoke --id <id>",
160 Short: "Revoke all registration tokens for an Actions runner",
161 RunE: func(cmd *cobra.Command, _ []string) error {
162 id, err := strconv.ParseInt(strings.TrimSpace(idRaw), 10, 64)
163 if err != nil || id <= 0 {
164 return errors.New("admin runner revoke: --id must be a positive integer")
165 }
166 cfg, err := config.Load(nil)
167 if err != nil {
168 return err
169 }
170 ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
171 defer cancel()
172 pool, err := openAdminRunnerPool(ctx, cfg, "revoke")
173 if err != nil {
174 return err
175 }
176 defer pool.Close()
177
178 q := actionsdb.New()
179 runner, err := q.GetRunnerByID(ctx, pool, id)
180 if err != nil {
181 return fmt.Errorf("admin runner revoke: runner %d not found", id)
182 }
183 if err := q.RevokeAllTokensForRunner(ctx, pool, id); err != nil {
184 return fmt.Errorf("admin runner revoke: %w", err)
185 }
186 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "runner revoked\nid: %d\nname: %s\n", runner.ID, runner.Name)
187 return nil
188 },
189 }
190 cmd.Flags().StringVar(&idRaw, "id", "", "Runner id")
191 return cmd
192 }
193
194 func openAdminRunnerPool(ctx context.Context, cfg config.Config, op string) (*pgxpool.Pool, error) {
195 if cfg.DB.URL == "" {
196 return nil, fmt.Errorf("admin runner %s: DB not configured (set SHITHUB_DATABASE_URL)", op)
197 }
198 pool, err := db.Open(ctx, db.Config{
199 URL: cfg.DB.URL, MaxConns: 2, MinConns: 0,
200 ConnectTimeout: cfg.DB.ConnectTimeout,
201 })
202 if err != nil {
203 return nil, fmt.Errorf("admin runner %s: db open: %w", op, err)
204 }
205 return pool, nil
206 }
207
208 func parseRunnerLabels(raw string) ([]string, error) {
209 return runnerlabels.ParseCSV(raw)
210 }
211
212 func init() {
213 adminCmd.AddCommand(newAdminRunnerCmd())
214 adminActionsCmd.AddCommand(newAdminRunnerCmd())
215 }
216