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