Go · 6929 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 "regexp"
10 "strconv"
11 "strings"
12 "text/tabwriter"
13 "time"
14
15 "github.com/jackc/pgx/v5/pgtype"
16 "github.com/jackc/pgx/v5/pgxpool"
17 "github.com/spf13/cobra"
18
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 )
24
25 var runnerLabelRE = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
26
27 func newAdminRunnerCmd() *cobra.Command {
28 cmd := &cobra.Command{
29 Use: "runner",
30 Short: "Register, list, and revoke Actions runners",
31 }
32 cmd.AddCommand(newAdminRunnerRegisterCmd())
33 cmd.AddCommand(newAdminRunnerListCmd())
34 cmd.AddCommand(newAdminRunnerRevokeCmd())
35 return cmd
36 }
37
38 func newAdminRunnerRegisterCmd() *cobra.Command {
39 var name string
40 var labelsRaw string
41 var capacity int
42 cmd := &cobra.Command{
43 Use: "register --name <name> [--labels self-hosted,linux] [--capacity 1]",
44 Short: "Register an Actions runner and print its token once",
45 RunE: func(cmd *cobra.Command, _ []string) error {
46 name = strings.TrimSpace(name)
47 if name == "" {
48 return errors.New("admin runner register: --name is required")
49 }
50 labels, err := parseRunnerLabels(labelsRaw)
51 if err != nil {
52 return err
53 }
54 if capacity < 1 || capacity > 64 {
55 return errors.New("admin runner register: --capacity must be between 1 and 64")
56 }
57
58 cfg, err := config.Load(nil)
59 if err != nil {
60 return err
61 }
62 ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
63 defer cancel()
64 pool, err := openAdminRunnerPool(ctx, cfg, "register")
65 if err != nil {
66 return err
67 }
68 defer pool.Close()
69
70 token, tokenHash, err := runnertoken.New()
71 if err != nil {
72 return fmt.Errorf("admin runner register: mint token: %w", err)
73 }
74
75 q := actionsdb.New()
76 tx, err := pool.Begin(ctx)
77 if err != nil {
78 return fmt.Errorf("admin runner register: begin: %w", err)
79 }
80 committed := false
81 defer func() {
82 if !committed {
83 _ = tx.Rollback(ctx)
84 }
85 }()
86
87 runner, err := q.InsertRunner(ctx, tx, actionsdb.InsertRunnerParams{
88 Name: name,
89 Labels: labels,
90 Capacity: int32(capacity),
91 RegisteredByUserID: pgtype.Int8{},
92 })
93 if err != nil {
94 return fmt.Errorf("admin runner register: insert runner: %w", err)
95 }
96 if _, err := q.InsertRunnerToken(ctx, tx, actionsdb.InsertRunnerTokenParams{
97 RunnerID: runner.ID,
98 TokenHash: tokenHash,
99 ExpiresAt: pgtype.Timestamptz{},
100 }); err != nil {
101 return fmt.Errorf("admin runner register: insert token: %w", err)
102 }
103 if err := tx.Commit(ctx); err != nil {
104 return fmt.Errorf("admin runner register: commit: %w", err)
105 }
106 committed = true
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 raw = strings.TrimSpace(raw)
210 if raw == "" {
211 return []string{}, nil
212 }
213 parts := strings.Split(raw, ",")
214 seen := make(map[string]struct{}, len(parts))
215 labels := make([]string, 0, len(parts))
216 for _, part := range parts {
217 label := strings.TrimSpace(part)
218 if label == "" {
219 return nil, errors.New("admin runner: labels must not contain empty entries")
220 }
221 if len(label) > 100 || !runnerLabelRE.MatchString(label) {
222 return nil, fmt.Errorf("admin runner: invalid label %q", label)
223 }
224 if _, ok := seen[label]; ok {
225 continue
226 }
227 seen[label] = struct{}{}
228 labels = append(labels, label)
229 }
230 return labels, nil
231 }
232
233 func init() {
234 adminCmd.AddCommand(newAdminRunnerCmd())
235 adminActionsCmd.AddCommand(newAdminRunnerCmd())
236 }
237