@@ -4,8 +4,10 @@ package main |
| 4 | 4 | |
| 5 | 5 | import ( |
| 6 | 6 | "context" |
| 7 | + "encoding/json" |
| 7 | 8 | "errors" |
| 8 | 9 | "fmt" |
| 10 | + "io" |
| 9 | 11 | "strconv" |
| 10 | 12 | "strings" |
| 11 | 13 | "text/tabwriter" |
@@ -30,6 +32,7 @@ func newAdminRunnerCmd() *cobra.Command { |
| 30 | 32 | } |
| 31 | 33 | cmd.AddCommand(newAdminRunnerRegisterCmd()) |
| 32 | 34 | cmd.AddCommand(newAdminRunnerListCmd()) |
| 35 | + cmd.AddCommand(newAdminRunnerQueueCmd()) |
| 33 | 36 | cmd.AddCommand(newAdminRunnerRevokeCmd()) |
| 34 | 37 | return cmd |
| 35 | 38 | } |
@@ -38,8 +41,10 @@ func newAdminRunnerRegisterCmd() *cobra.Command { |
| 38 | 41 | var name string |
| 39 | 42 | var labelsRaw string |
| 40 | 43 | var capacity int |
| 44 | + var output string |
| 45 | + var expiresIn time.Duration |
| 41 | 46 | cmd := &cobra.Command{ |
| 42 | | - Use: "register --name <name> [--labels self-hosted,linux] [--capacity 1]", |
| 47 | + Use: "register --name <name> [--labels self-hosted,linux,ubuntu-latest,x64] [--capacity 1]", |
| 43 | 48 | Short: "Register an Actions runner and print its token once", |
| 44 | 49 | RunE: func(cmd *cobra.Command, _ []string) error { |
| 45 | 50 | name = strings.TrimSpace(name) |
@@ -53,6 +58,17 @@ func newAdminRunnerRegisterCmd() *cobra.Command { |
| 53 | 58 | if capacity < 1 || capacity > 64 { |
| 54 | 59 | return errors.New("admin runner register: --capacity must be between 1 and 64") |
| 55 | 60 | } |
| 61 | + output = strings.ToLower(strings.TrimSpace(output)) |
| 62 | + switch output { |
| 63 | + case "", "text": |
| 64 | + output = "text" |
| 65 | + case "json": |
| 66 | + default: |
| 67 | + return errors.New("admin runner register: --output must be text or json") |
| 68 | + } |
| 69 | + if expiresIn < 0 { |
| 70 | + return errors.New("admin runner register: --expires-in must be non-negative") |
| 71 | + } |
| 56 | 72 | |
| 57 | 73 | cfg, err := config.Load(nil) |
| 58 | 74 | if err != nil { |
@@ -70,6 +86,13 @@ func newAdminRunnerRegisterCmd() *cobra.Command { |
| 70 | 86 | if err != nil { |
| 71 | 87 | return fmt.Errorf("admin runner register: mint token: %w", err) |
| 72 | 88 | } |
| 89 | + var expiresAt pgtype.Timestamptz |
| 90 | + var outputExpiresAt *time.Time |
| 91 | + if expiresIn > 0 { |
| 92 | + t := time.Now().UTC().Add(expiresIn) |
| 93 | + expiresAt = pgtype.Timestamptz{Time: t, Valid: true} |
| 94 | + outputExpiresAt = &t |
| 95 | + } |
| 73 | 96 | |
| 74 | 97 | q := actionsdb.New() |
| 75 | 98 | tx, err := pool.Begin(ctx) |
@@ -95,7 +118,7 @@ func newAdminRunnerRegisterCmd() *cobra.Command { |
| 95 | 118 | if _, err := q.InsertRunnerToken(ctx, tx, actionsdb.InsertRunnerTokenParams{ |
| 96 | 119 | RunnerID: runner.ID, |
| 97 | 120 | TokenHash: tokenHash, |
| 98 | | - ExpiresAt: pgtype.Timestamptz{}, |
| 121 | + ExpiresAt: expiresAt, |
| 99 | 122 | }); err != nil { |
| 100 | 123 | return fmt.Errorf("admin runner register: insert token: %w", err) |
| 101 | 124 | } |
@@ -105,18 +128,49 @@ func newAdminRunnerRegisterCmd() *cobra.Command { |
| 105 | 128 | committed = true |
| 106 | 129 | metrics.ActionsRunnerRegistrationsTotal.Inc() |
| 107 | 130 | |
| 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 |
| 131 | + return writeRunnerRegisterOutput(cmd.OutOrStdout(), output, runnerRegisterOutput{ |
| 132 | + ID: runner.ID, |
| 133 | + Name: runner.Name, |
| 134 | + Labels: runner.Labels, |
| 135 | + Capacity: runner.Capacity, |
| 136 | + Token: token, |
| 137 | + TokenExpiresAt: outputExpiresAt, |
| 138 | + }) |
| 112 | 139 | }, |
| 113 | 140 | } |
| 114 | 141 | cmd.Flags().StringVar(&name, "name", "", "Runner name (letters, numbers, underscore, dash)") |
| 115 | | - cmd.Flags().StringVar(&labelsRaw, "labels", "", "Comma-separated runner labels") |
| 142 | + cmd.Flags().StringVar(&labelsRaw, "labels", strings.Join(runnerlabels.DefaultShared(), ","), "Comma-separated runner labels") |
| 116 | 143 | cmd.Flags().IntVar(&capacity, "capacity", 1, "Maximum concurrent jobs this runner may execute") |
| 144 | + cmd.Flags().DurationVar(&expiresIn, "expires-in", 0, "Registration token lifetime (0 means no expiration)") |
| 145 | + cmd.Flags().StringVar(&output, "output", "text", "Output format: text or json") |
| 117 | 146 | return cmd |
| 118 | 147 | } |
| 119 | 148 | |
| 149 | +type runnerRegisterOutput struct { |
| 150 | + ID int64 `json:"id"` |
| 151 | + Name string `json:"name"` |
| 152 | + Labels []string `json:"labels"` |
| 153 | + Capacity int32 `json:"capacity"` |
| 154 | + Token string `json:"token"` |
| 155 | + TokenExpiresAt *time.Time `json:"token_expires_at,omitempty"` |
| 156 | +} |
| 157 | + |
| 158 | +func writeRunnerRegisterOutput(w io.Writer, format string, out runnerRegisterOutput) error { |
| 159 | + if format == "json" { |
| 160 | + enc := json.NewEncoder(w) |
| 161 | + enc.SetIndent("", " ") |
| 162 | + return enc.Encode(out) |
| 163 | + } |
| 164 | + expires := "never" |
| 165 | + if out.TokenExpiresAt != nil { |
| 166 | + expires = out.TokenExpiresAt.Format(time.RFC3339) |
| 167 | + } |
| 168 | + _, err := fmt.Fprintf(w, |
| 169 | + "runner registered\nid: %d\nname: %s\nlabels: %s\ncapacity: %d\ntoken_expires_at: %s\ntoken: %s\n\nStore this token now; shithub never shows it again.\n", |
| 170 | + out.ID, out.Name, strings.Join(out.Labels, ","), out.Capacity, expires, out.Token) |
| 171 | + return err |
| 172 | +} |
| 173 | + |
| 120 | 174 | func newAdminRunnerListCmd() *cobra.Command { |
| 121 | 175 | return &cobra.Command{ |
| 122 | 176 | Use: "list", |
@@ -153,6 +207,85 @@ func newAdminRunnerListCmd() *cobra.Command { |
| 153 | 207 | } |
| 154 | 208 | } |
| 155 | 209 | |
| 210 | +func newAdminRunnerQueueCmd() *cobra.Command { |
| 211 | + var output string |
| 212 | + cmd := &cobra.Command{ |
| 213 | + Use: "queue", |
| 214 | + Short: "Summarize queued Actions jobs by runs-on label", |
| 215 | + RunE: func(cmd *cobra.Command, _ []string) error { |
| 216 | + output = strings.ToLower(strings.TrimSpace(output)) |
| 217 | + switch output { |
| 218 | + case "", "text": |
| 219 | + output = "text" |
| 220 | + case "json": |
| 221 | + default: |
| 222 | + return errors.New("admin runner queue: --output must be text or json") |
| 223 | + } |
| 224 | + cfg, err := config.Load(nil) |
| 225 | + if err != nil { |
| 226 | + return err |
| 227 | + } |
| 228 | + ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) |
| 229 | + defer cancel() |
| 230 | + pool, err := openAdminRunnerPool(ctx, cfg, "queue") |
| 231 | + if err != nil { |
| 232 | + return err |
| 233 | + } |
| 234 | + defer pool.Close() |
| 235 | + |
| 236 | + rows, err := actionsdb.New().ListQueuedWorkflowJobRunsOn(ctx, pool) |
| 237 | + if err != nil { |
| 238 | + return fmt.Errorf("admin runner queue: %w", err) |
| 239 | + } |
| 240 | + return writeRunnerQueueOutput(cmd.OutOrStdout(), output, rows, time.Now().UTC()) |
| 241 | + }, |
| 242 | + } |
| 243 | + cmd.Flags().StringVar(&output, "output", "text", "Output format: text or json") |
| 244 | + return cmd |
| 245 | +} |
| 246 | + |
| 247 | +type runnerQueueOutputRow struct { |
| 248 | + RunsOn string `json:"runs_on"` |
| 249 | + QueuedJobs int32 `json:"queued_jobs"` |
| 250 | + MatchingRunnerCount int32 `json:"matching_runner_count"` |
| 251 | + OldestQueuedAt string `json:"oldest_queued_at,omitempty"` |
| 252 | + OldestQueuedSeconds int64 `json:"oldest_queued_seconds,omitempty"` |
| 253 | +} |
| 254 | + |
| 255 | +func writeRunnerQueueOutput(w io.Writer, format string, rows []actionsdb.ListQueuedWorkflowJobRunsOnRow, now time.Time) error { |
| 256 | + out := make([]runnerQueueOutputRow, 0, len(rows)) |
| 257 | + for _, row := range rows { |
| 258 | + item := runnerQueueOutputRow{ |
| 259 | + RunsOn: row.RunsOn, |
| 260 | + QueuedJobs: row.QueuedJobs, |
| 261 | + MatchingRunnerCount: row.MatchingRunnerCount, |
| 262 | + } |
| 263 | + if row.OldestQueuedAt.Valid { |
| 264 | + item.OldestQueuedAt = row.OldestQueuedAt.Time.UTC().Format(time.RFC3339) |
| 265 | + if d := now.Sub(row.OldestQueuedAt.Time); d > 0 { |
| 266 | + item.OldestQueuedSeconds = int64(d.Seconds()) |
| 267 | + } |
| 268 | + } |
| 269 | + out = append(out, item) |
| 270 | + } |
| 271 | + if format == "json" { |
| 272 | + enc := json.NewEncoder(w) |
| 273 | + enc.SetIndent("", " ") |
| 274 | + return enc.Encode(out) |
| 275 | + } |
| 276 | + |
| 277 | + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) |
| 278 | + _, _ = fmt.Fprintln(tw, "RUNS_ON\tQUEUED_JOBS\tMATCHING_RUNNERS\tOLDEST_QUEUED") |
| 279 | + for _, row := range out { |
| 280 | + oldest := "-" |
| 281 | + if row.OldestQueuedAt != "" { |
| 282 | + oldest = row.OldestQueuedAt |
| 283 | + } |
| 284 | + _, _ = fmt.Fprintf(tw, "%s\t%d\t%d\t%s\n", row.RunsOn, row.QueuedJobs, row.MatchingRunnerCount, oldest) |
| 285 | + } |
| 286 | + return tw.Flush() |
| 287 | +} |
| 288 | + |
| 156 | 289 | func newAdminRunnerRevokeCmd() *cobra.Command { |
| 157 | 290 | var idRaw string |
| 158 | 291 | cmd := &cobra.Command{ |