tenseleyflow/shithub / ce48194

Browse files

cmd/admin: register and revoke actions runners (S41c)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
ce481942d9111598d18570907517004d7dccd39f
Parents
ef73d0c
Tree
393cfde

4 changed files

StatusFile+-
A cmd/shithubd/admin_runner.go 236 0
A cmd/shithubd/admin_runner_test.go 47 0
A internal/actions/runnertoken/token.go 53 0
A internal/actions/runnertoken/token_test.go 45 0
cmd/shithubd/admin_runner.goadded
@@ -0,0 +1,236 @@
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
+}
cmd/shithubd/admin_runner_test.goadded
@@ -0,0 +1,47 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package main
4
+
5
+import (
6
+	"reflect"
7
+	"testing"
8
+)
9
+
10
+func TestParseRunnerLabels(t *testing.T) {
11
+	tests := []struct {
12
+		name string
13
+		raw  string
14
+		want []string
15
+	}{
16
+		{name: "empty", raw: "", want: []string{}},
17
+		{name: "trim and dedupe", raw: " self-hosted, linux,linux,ubuntu-24.04 ", want: []string{"self-hosted", "linux", "ubuntu-24.04"}},
18
+		{name: "underscore dot dash", raw: "gpu_cuda-12.4", want: []string{"gpu_cuda-12.4"}},
19
+	}
20
+	for _, tt := range tests {
21
+		t.Run(tt.name, func(t *testing.T) {
22
+			got, err := parseRunnerLabels(tt.raw)
23
+			if err != nil {
24
+				t.Fatalf("parseRunnerLabels: %v", err)
25
+			}
26
+			if !reflect.DeepEqual(got, tt.want) {
27
+				t.Fatalf("labels: got %#v, want %#v", got, tt.want)
28
+			}
29
+		})
30
+	}
31
+}
32
+
33
+func TestParseRunnerLabelsRejectsInvalid(t *testing.T) {
34
+	tests := []string{
35
+		"linux,",
36
+		"linux,,x64",
37
+		"has space",
38
+		"semi;colon",
39
+	}
40
+	for _, raw := range tests {
41
+		t.Run(raw, func(t *testing.T) {
42
+			if _, err := parseRunnerLabels(raw); err == nil {
43
+				t.Fatal("parseRunnerLabels returned nil error")
44
+			}
45
+		})
46
+	}
47
+}
internal/actions/runnertoken/token.goadded
@@ -0,0 +1,53 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package runnertoken mints and hashes long-lived runner registration tokens.
4
+//
5
+// Tokens are 32 random bytes rendered as hex for operator copy/paste. Only the
6
+// SHA-256 hash is stored in runner_tokens; the plaintext is printed once by
7
+// `shithubd admin actions runner register` and then lost.
8
+package runnertoken
9
+
10
+import (
11
+	"crypto/rand"
12
+	"crypto/sha256"
13
+	"crypto/subtle"
14
+	"encoding/hex"
15
+	"errors"
16
+	"strings"
17
+)
18
+
19
+const SizeBytes = 32
20
+
21
+var (
22
+	ErrMalformed = errors.New("runnertoken: malformed token")
23
+	ErrWrongSize = errors.New("runnertoken: wrong token length")
24
+)
25
+
26
+// New mints a token and returns the hex encoding plus its SHA-256 hash.
27
+func New() (encoded string, hash []byte, err error) {
28
+	raw := make([]byte, SizeBytes)
29
+	if _, err := rand.Read(raw); err != nil {
30
+		return "", nil, err
31
+	}
32
+	encoded = hex.EncodeToString(raw)
33
+	sum := sha256.Sum256(raw)
34
+	return encoded, sum[:], nil
35
+}
36
+
37
+// HashOf decodes a hex registration token and returns the stored hash.
38
+func HashOf(encoded string) ([]byte, error) {
39
+	raw, err := hex.DecodeString(strings.TrimSpace(encoded))
40
+	if err != nil {
41
+		return nil, ErrMalformed
42
+	}
43
+	if len(raw) != SizeBytes {
44
+		return nil, ErrWrongSize
45
+	}
46
+	sum := sha256.Sum256(raw)
47
+	return sum[:], nil
48
+}
49
+
50
+// Equal compares two token hashes in constant time.
51
+func Equal(a, b []byte) bool {
52
+	return subtle.ConstantTimeCompare(a, b) == 1
53
+}
internal/actions/runnertoken/token_test.goadded
@@ -0,0 +1,45 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package runnertoken_test
4
+
5
+import (
6
+	"encoding/hex"
7
+	"errors"
8
+	"strings"
9
+	"testing"
10
+
11
+	"github.com/tenseleyFlow/shithub/internal/actions/runnertoken"
12
+)
13
+
14
+func TestNewAndHashOfRoundTrip(t *testing.T) {
15
+	encoded, hash, err := runnertoken.New()
16
+	if err != nil {
17
+		t.Fatalf("New: %v", err)
18
+	}
19
+	if len(encoded) != runnertoken.SizeBytes*2 {
20
+		t.Fatalf("encoded length: got %d, want %d", len(encoded), runnertoken.SizeBytes*2)
21
+	}
22
+	if _, err := hex.DecodeString(encoded); err != nil {
23
+		t.Fatalf("encoded token is not hex: %v", err)
24
+	}
25
+
26
+	got, err := runnertoken.HashOf(encoded)
27
+	if err != nil {
28
+		t.Fatalf("HashOf: %v", err)
29
+	}
30
+	if !runnertoken.Equal(got, hash) {
31
+		t.Fatalf("HashOf did not reproduce stored hash")
32
+	}
33
+	if strings.Contains(hex.EncodeToString(hash), encoded) {
34
+		t.Fatalf("hash contains plaintext token")
35
+	}
36
+}
37
+
38
+func TestHashOfRejectsMalformedAndWrongSize(t *testing.T) {
39
+	if _, err := runnertoken.HashOf("not-hex"); !errors.Is(err, runnertoken.ErrMalformed) {
40
+		t.Fatalf("malformed: got %v, want ErrMalformed", err)
41
+	}
42
+	if _, err := runnertoken.HashOf("abcd"); !errors.Is(err, runnertoken.ErrWrongSize) {
43
+		t.Fatalf("wrong size: got %v, want ErrWrongSize", err)
44
+	}
45
+}