Go · 6697 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package main
4
5 import (
6 "context"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "os"
11 "time"
12
13 "github.com/spf13/cobra"
14
15 actionslifecycle "github.com/tenseleyFlow/shithub/internal/actions/lifecycle"
16 actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
17 "github.com/tenseleyFlow/shithub/internal/actions/workflow"
18 "github.com/tenseleyFlow/shithub/internal/infra/config"
19 "github.com/tenseleyFlow/shithub/internal/infra/db"
20 )
21
22 // adminActionsCmd is the parent group for actions-related operator
23 // subcommands. Currently scoped to read-only inspection (`parse`); will
24 // grow with `runner register`, `runner list`, etc. in S41c.
25 var adminActionsCmd = &cobra.Command{
26 Use: "actions",
27 Short: "Inspect and operate the Actions/CI subsystem",
28 }
29
30 // adminActionsParseCmd reads a workflow YAML file from disk, runs it
31 // through the parser, and prints diagnostics + a canonical JSON
32 // rendering of the parsed AST. No DB or runner dependency — this is
33 // the smoke command operators run when a workflow misbehaves and we
34 // want a deterministic dump of what the parser actually saw.
35 //
36 // Exit codes:
37 // - 0: parse succeeded and produced no Error-severity diagnostics
38 // - 1: parse error (file too large, malformed YAML, IO error)
39 // - 2: parse produced one or more Error-severity diagnostics
40 var adminActionsParseCmd = &cobra.Command{
41 Use: "parse <file>",
42 Short: "Parse a workflow YAML file and print diagnostics + canonical JSON",
43 Long: `Reads a workflow file, runs the v1 parser against it, and prints any
44 diagnostics followed by a canonical JSON rendering of the parsed AST.
45
46 Useful for:
47 - debugging "why is my workflow not picking up changes" reports
48 - validating a workflow file before committing it
49 - producing a stable AST snapshot for inclusion in bug reports
50
51 Exit code 0 = clean parse, 2 = Error-severity diagnostics produced,
52 1 = the file itself was unreadable or oversized.`,
53 Args: cobra.ExactArgs(1),
54 RunE: func(cmd *cobra.Command, args []string) error {
55 path := args[0]
56 src, err := os.ReadFile(path)
57 if err != nil {
58 return fmt.Errorf("read %s: %w", path, err)
59 }
60 wf, diags, err := workflow.Parse(src)
61 if err != nil {
62 return fmt.Errorf("parse %s: %w", path, err)
63 }
64
65 out := cmd.OutOrStdout()
66 errs := 0
67 for _, d := range diags {
68 fmt.Fprintln(out, d.String())
69 if d.Severity == workflow.Error {
70 errs++
71 }
72 }
73 if len(diags) > 0 {
74 fmt.Fprintln(out, "---")
75 }
76
77 enc := json.NewEncoder(out)
78 enc.SetIndent("", " ")
79 if err := enc.Encode(wf); err != nil {
80 return fmt.Errorf("encode AST: %w", err)
81 }
82
83 if errs > 0 {
84 os.Exit(2)
85 }
86 return nil
87 },
88 }
89
90 func newAdminActionsCancelAllCmd() *cobra.Command {
91 var repoID int64
92 var limit int
93 var dryRun bool
94 var confirm bool
95
96 cmd := &cobra.Command{
97 Use: "cancel-all",
98 Short: "Request cancellation for active Actions workflow runs",
99 Long: `Requests cancellation for queued/running Actions workflow runs.
100
101 By default this scans all repositories, oldest first. Use --repo-id to scope
102 the operation to one repository. Running jobs receive cancel_requested=true and
103 are killed by their runner's cancel-check loop; queued jobs become terminal
104 immediately.
105
106 This is an operator break-glass command. Run with --dry-run first, then repeat
107 with --confirm to mutate state.`,
108 Args: cobra.NoArgs,
109 RunE: func(cmd *cobra.Command, _ []string) error {
110 if repoID < 0 {
111 return errors.New("admin actions cancel-all: --repo-id must be zero or positive")
112 }
113 if limit < 1 || limit > 5000 {
114 return errors.New("admin actions cancel-all: --limit must be between 1 and 5000")
115 }
116 if !dryRun && !confirm {
117 return errors.New("admin actions cancel-all: refusing to mutate without --confirm; use --dry-run to inspect")
118 }
119
120 cfg, err := config.Load(nil)
121 if err != nil {
122 return err
123 }
124 if cfg.DB.URL == "" {
125 return errors.New("admin actions cancel-all: DB not configured (set SHITHUB_DATABASE_URL)")
126 }
127 ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Minute)
128 defer cancel()
129
130 pool, err := db.Open(ctx, db.Config{
131 URL: cfg.DB.URL,
132 MaxConns: 2,
133 MinConns: 0,
134 ConnectTimeout: cfg.DB.ConnectTimeout,
135 })
136 if err != nil {
137 return fmt.Errorf("admin actions cancel-all: db open: %w", err)
138 }
139 defer pool.Close()
140
141 q := actionsdb.New()
142 runs, err := q.ListActiveWorkflowRunsForAdmin(ctx, pool, actionsdb.ListActiveWorkflowRunsForAdminParams{
143 RepoID: repoID,
144 LimitCount: int32(limit),
145 })
146 if err != nil {
147 return fmt.Errorf("admin actions cancel-all: list active runs: %w", err)
148 }
149
150 out := cmd.OutOrStdout()
151 scope := "all repositories"
152 if repoID != 0 {
153 scope = fmt.Sprintf("repo_id=%d", repoID)
154 }
155 if dryRun {
156 for _, run := range runs {
157 _, _ = fmt.Fprintf(out,
158 "would cancel: run_id=%d repo_id=%d run_index=%d status=%s workflow=%s ref=%s sha=%s\n",
159 run.ID, run.RepoID, run.RunIndex, run.Status, run.WorkflowFile, run.HeadRef, run.HeadSha)
160 }
161 _, _ = fmt.Fprintf(out, "cancel-all dry-run: found %d active run(s) in %s (limit=%d)\n",
162 len(runs), scope, limit)
163 return nil
164 }
165
166 cancelledRuns := 0
167 cancelledJobs := 0
168 completedRuns := 0
169 for _, run := range runs {
170 result, err := actionslifecycle.CancelRun(ctx, actionslifecycle.Deps{Pool: pool}, run.ID, actionslifecycle.CancelReasonUser)
171 if err != nil {
172 return fmt.Errorf("admin actions cancel-all: cancel run %d: %w", run.ID, err)
173 }
174 if len(result.ChangedJobs) > 0 {
175 cancelledRuns++
176 }
177 cancelledJobs += len(result.ChangedJobs)
178 if result.RunCompleted {
179 completedRuns++
180 }
181 _, _ = fmt.Fprintf(out,
182 "cancelled: run_id=%d repo_id=%d run_index=%d changed_jobs=%d run_completed=%t\n",
183 run.ID, run.RepoID, run.RunIndex, len(result.ChangedJobs), result.RunCompleted)
184 }
185 _, _ = fmt.Fprintf(out,
186 "cancel-all: scanned %d active run(s) in %s; changed_runs=%d changed_jobs=%d completed_runs=%d\n",
187 len(runs), scope, cancelledRuns, cancelledJobs, completedRuns)
188 return nil
189 },
190 }
191 cmd.Flags().Int64Var(&repoID, "repo-id", 0, "Repository id to scope cancellation to; 0 scans all repositories")
192 cmd.Flags().IntVar(&limit, "limit", 500, "Maximum active runs to scan")
193 cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Print matching active runs without mutating state")
194 cmd.Flags().BoolVar(&confirm, "confirm", false, "Confirm cancellation of matching active runs")
195 return cmd
196 }
197
198 func init() {
199 adminActionsCmd.AddCommand(adminActionsParseCmd)
200 adminActionsCmd.AddCommand(newAdminActionsCancelAllCmd())
201 adminCmd.AddCommand(adminActionsCmd)
202 }
203