1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "fmt"
7 "strconv"
8 "strings"
9
10 "connectrpc.com/connect"
11 "github.com/spf13/cobra"
12
13 "bnema.dev/ermite-api/gen/ermite"
14)
15
16const jsonMirrorKeyHelp = `
17JSON fields of a key (--json): authorized_key, fingerprint, created_at (RFC 3339, UTC).`
18
19const jsonMirrorConfigHelp = `
20JSON fields of the policy (--json): enabled, url_template, cron, all_branches, updated_at (RFC 3339, UTC, null if never set).`
21
22const jsonMirrorStatusHelp = `
23JSON fields of a status (--json): repo, enabled, source (disabled, global, override, excluded), url
24(admins only, else empty), cron, all_branches, state (disabled, ok, failing, paused), failure_class,
25reason, detail (admins only), consecutive_failures, last_attempt_at, last_success_at, next_retry_at,
26next_run_at, paused_at (RFC 3339, UTC, null when unset).`
27
28type jsonMirrorKey struct {
29 AuthorizedKey string `json:"authorized_key"`
30 Fingerprint string `json:"fingerprint"`
31 CreatedAt *string `json:"created_at"`
32}
33
34type jsonMirrorConfig struct {
35 Enabled bool `json:"enabled"`
36 URLTemplate string `json:"url_template"`
37 Cron string `json:"cron"`
38 AllBranches bool `json:"all_branches"`
39 UpdatedAt *string `json:"updated_at"`
40}
41
42type jsonMirrorStatus struct {
43 Repo string `json:"repo"`
44 Enabled bool `json:"enabled"`
45 Source string `json:"source"`
46 URL string `json:"url"`
47 Cron string `json:"cron"`
48 AllBranches bool `json:"all_branches"`
49 State string `json:"state"`
50 FailureClass string `json:"failure_class"`
51 Reason string `json:"reason"`
52 Detail string `json:"detail"`
53 ConsecutiveFailures int32 `json:"consecutive_failures"`
54 LastAttemptAt *string `json:"last_attempt_at"`
55 LastSuccessAt *string `json:"last_success_at"`
56 NextRetryAt *string `json:"next_retry_at"`
57 NextRunAt *string `json:"next_run_at"`
58 PausedAt *string `json:"paused_at"`
59}
60
61func toJSONMirrorKey(k *ermite.MirrorKey) jsonMirrorKey {
62 return jsonMirrorKey{AuthorizedKey: k.GetAuthorizedKey(), Fingerprint: k.GetFingerprint(), CreatedAt: jsonTime(k.GetCreatedAt())}
63}
64
65func toJSONMirrorConfig(g *ermite.MirrorConfig) jsonMirrorConfig {
66 return jsonMirrorConfig{Enabled: g.GetEnabled(), URLTemplate: g.GetUrlTemplate(), Cron: g.GetCron(), AllBranches: g.GetAllBranches(), UpdatedAt: jsonTime(g.GetUpdatedAt())}
67}
68
69func mirrorSourceName(s ermite.MirrorSource) string {
70 return strings.ToLower(strings.TrimPrefix(s.String(), "MIRROR_SOURCE_"))
71}
72
73func toJSONMirrorStatus(s *ermite.MirrorStatus) jsonMirrorStatus {
74 t := s.GetTarget()
75 return jsonMirrorStatus{
76 Repo: t.GetRepo(), Enabled: t.GetEnabled(), Source: mirrorSourceName(t.GetSource()), URL: t.GetUrl(), Cron: t.GetCron(), AllBranches: t.GetAllBranches(),
77 State: mirrorStateName(s.GetState()), FailureClass: s.GetFailureClass(), Reason: s.GetReason(), Detail: s.GetDetail(), ConsecutiveFailures: s.GetConsecutiveFailures(),
78 LastAttemptAt: jsonTime(s.GetLastAttemptAt()), LastSuccessAt: jsonTime(s.GetLastSuccessAt()), NextRetryAt: jsonTime(s.GetNextRetryAt()), NextRunAt: jsonTime(s.GetNextRunAt()), PausedAt: jsonTime(s.GetPausedAt()),
79 }
80}
81
82func newMirrorCmd(env *cliEnv) *cobra.Command {
83 cmd := &cobra.Command{Use: "mirror", Short: "Manage non-forcing push mirrors"}
84 key := &cobra.Command{Use: "key", Short: "Show or rotate the mirror SSH key"}
85 for _, rotate := range []bool{false, true} {
86 name, short := "show", "Print the mirror authorized_keys line"
87 long := "Print only the authorized_keys line on stdout; requires authentication and ERMITE_SECRETS_KEY on the server.\nPipe it into: gh ssh-key add - --title \"ermite mirror\"." + jsonMirrorKeyHelp
88 if rotate {
89 name, short = "rotate", "Replace the mirror SSH key (owner only)"
90 long = "Replace the mirror SSH key at once and print the new authorized_keys line on stdout; the replaced\nkey's fingerprint goes to stderr. Every mirror fails until the new key is added to the remotes.\nRequires owner rights and ERMITE_SECRETS_KEY on the server." + jsonMirrorKeyHelp
91 }
92 sub := &cobra.Command{Use: name, Short: short, Args: cobra.NoArgs, Long: long}
93 asJSON := addJSONFlag(sub)
94 sub.RunE = func(cmd *cobra.Command, _ []string) error {
95 c, err := env.client(cmd)
96 if err != nil {
97 return err
98 }
99 var k *ermite.MirrorKey
100 if rotate {
101 res, e := c.Mirrors().RotateMirrorKey(env.ctx(cmd), connect.NewRequest(&ermite.RotateMirrorKeyRequest{}))
102 if e != nil {
103 return c.Describe(e)
104 }
105 k = res.Msg.GetKey()
106 if prev := res.Msg.GetPreviousFingerprint(); prev != "" {
107 noticef(cmd, "replaced key %s", clean(prev))
108 }
109 noticef(cmd, "remotes refuse pushes until this key is added")
110 } else {
111 res, e := c.Mirrors().GetMirrorKey(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorKeyRequest{}))
112 if e != nil {
113 return c.Describe(e)
114 }
115 k = res.Msg.GetKey()
116 }
117 if *asJSON {
118 return writeJSON(cmd.OutOrStdout(), toJSONMirrorKey(k))
119 }
120 _, err = fmt.Fprintln(cmd.OutOrStdout(), clean(k.GetAuthorizedKey()))
121 return err
122 }
123 key.AddCommand(sub)
124 }
125 cmd.AddCommand(key, newMirrorConfigCmd(env), newMirrorAddCmd(env), newMirrorStatusCmd(env), newMirrorSyncCmd(env))
126 for _, name := range []string{"remove", "exclude", "include"} {
127 sub := &cobra.Command{Use: name + " <repo>", Short: name + " a repository mirror", Args: cobra.ExactArgs(1)}
128 sub.RunE = func(cmd *cobra.Command, args []string) error {
129 c, err := env.client(cmd)
130 if err != nil {
131 return err
132 }
133 if name == "remove" {
134 _, err = c.Mirrors().RemoveRepoMirror(env.ctx(cmd), connect.NewRequest(&ermite.RemoveRepoMirrorRequest{Repo: args[0]}))
135 } else {
136 _, err = c.Mirrors().SetRepoMirrorExcluded(env.ctx(cmd), connect.NewRequest(&ermite.SetRepoMirrorExcludedRequest{Repo: args[0], Excluded: name == "exclude"}))
137 }
138 if err != nil {
139 return c.Describe(err)
140 }
141 noticef(cmd, "%s: %s", name, clean(args[0]))
142 return nil
143 }
144 cmd.AddCommand(sub)
145 }
146 return cmd
147}
148
149func newMirrorConfigCmd(env *cliEnv) *cobra.Command {
150 var enabled, all bool
151 var url, cron string
152 cmd := &cobra.Command{Use: "config", Short: "Patch the global mirror policy (owner only)", Long: "Change the flags given and keep the other settings; use `config show` to read the policy." + jsonMirrorConfigHelp, Args: cobra.NoArgs}
153 cmd.Flags().BoolVar(&enabled, "enabled", false, "enable the global mirror policy")
154 cmd.Flags().StringVar(&url, "url", "", "SSH URL template containing {repo}")
155 cmd.Flags().StringVar(&cron, "cron", "", "five-field UTC cron (minimum gap five minutes)")
156 cmd.Flags().BoolVar(&all, "all-branches", false, "mirror all branches instead of only the default branch")
157 asJSON := addJSONFlag(cmd)
158 output := func(cmd *cobra.Command, g *ermite.MirrorConfig, json bool) error {
159 if json {
160 return writeJSON(cmd.OutOrStdout(), toJSONMirrorConfig(g))
161 }
162 return fields(cmd.OutOrStdout(), [][2]string{{"enabled", strconv.FormatBool(g.GetEnabled())}, {"url", g.GetUrlTemplate()}, {"cron (UTC)", g.GetCron()}, {"all branches", strconv.FormatBool(g.GetAllBranches())}})
163 }
164 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
165 c, err := env.client(cmd)
166 if err != nil {
167 return err
168 }
169 req := &ermite.SetMirrorConfigRequest{}
170 if cmd.Flags().Changed("enabled") {
171 req.Enabled = &enabled
172 }
173 if cmd.Flags().Changed("url") {
174 req.UrlTemplate = &url
175 }
176 if cmd.Flags().Changed("cron") {
177 req.Cron = &cron
178 }
179 if cmd.Flags().Changed("all-branches") {
180 req.AllBranches = &all
181 }
182 if req.Enabled == nil && req.UrlTemplate == nil && req.Cron == nil && req.AllBranches == nil {
183 return fmt.Errorf("specify at least one config flag, or use `mirror config show`")
184 }
185 res, err := c.Mirrors().SetMirrorConfig(env.ctx(cmd), connect.NewRequest(req))
186 if err != nil {
187 return c.Describe(err)
188 }
189 return output(cmd, res.Msg.GetConfig(), *asJSON)
190 }
191 show := &cobra.Command{Use: "show", Short: "Show the global mirror policy", Long: "Show the global mirror policy (owner only)." + jsonMirrorConfigHelp, Args: cobra.NoArgs}
192 showJSON := addJSONFlag(show)
193 show.RunE = func(cmd *cobra.Command, _ []string) error {
194 c, err := env.client(cmd)
195 if err != nil {
196 return err
197 }
198 res, err := c.Mirrors().GetMirrorConfig(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorConfigRequest{}))
199 if err != nil {
200 return c.Describe(err)
201 }
202 return output(cmd, res.Msg.GetConfig(), *showJSON)
203 }
204 cmd.AddCommand(show)
205 return cmd
206}
207
208func newMirrorAddCmd(env *cliEnv) *cobra.Command {
209 var cron string
210 var all bool
211 cmd := &cobra.Command{Use: "add <repo> <url>", Short: "Set a repository mirror override", Args: cobra.ExactArgs(2)}
212 cmd.Flags().StringVar(&cron, "cron", "", "five-field UTC cron (empty inherits global)")
213 cmd.Flags().BoolVar(&all, "all-branches", false, "mirror all branches (omitted inherits global)")
214 cmd.RunE = func(cmd *cobra.Command, args []string) error {
215 c, err := env.client(cmd)
216 if err != nil {
217 return err
218 }
219 req := &ermite.SetRepoMirrorRequest{Repo: args[0], Url: args[1], Cron: cron}
220 if cmd.Flags().Changed("all-branches") {
221 req.AllBranches = &all
222 }
223 _, err = c.Mirrors().SetRepoMirror(env.ctx(cmd), connect.NewRequest(req))
224 if err != nil {
225 return c.Describe(err)
226 }
227 noticef(cmd, "mirror configured for %s", clean(args[0]))
228 return nil
229 }
230 return cmd
231}
232
233func mirrorStateName(s ermite.MirrorState) string {
234 return strings.ToLower(strings.TrimPrefix(s.String(), "MIRROR_STATE_"))
235}
236
237func newMirrorStatusCmd(env *cliEnv) *cobra.Command {
238 cmd := &cobra.Command{Use: "status [repo]", Short: "Show mirror states, reasons and next runs", Long: "Show one repository, or every repository (owner only).\n--json prints an object for one repository and an array for all." + jsonMirrorStatusHelp, Args: cobra.MaximumNArgs(1)}
239 asJSON := addJSONFlag(cmd)
240 cmd.RunE = func(cmd *cobra.Command, args []string) error {
241 c, err := env.client(cmd)
242 if err != nil {
243 return err
244 }
245 var statuses []*ermite.MirrorStatus
246 if len(args) == 1 {
247 res, err := c.Mirrors().GetMirrorStatus(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorStatusRequest{Repo: args[0]}))
248 if err != nil {
249 return c.Describe(err)
250 }
251 if *asJSON {
252 return writeJSON(cmd.OutOrStdout(), toJSONMirrorStatus(res.Msg.GetStatus()))
253 }
254 statuses = append(statuses, res.Msg.GetStatus())
255 } else {
256 res, err := c.Mirrors().ListMirrorStatus(env.ctx(cmd), connect.NewRequest(&ermite.ListMirrorStatusRequest{}))
257 if err != nil {
258 return c.Describe(err)
259 }
260 if *asJSON {
261 items := make([]jsonMirrorStatus, 0, len(res.Msg.GetStatuses()))
262 for _, st := range res.Msg.GetStatuses() {
263 items = append(items, toJSONMirrorStatus(st))
264 }
265 return writeJSON(cmd.OutOrStdout(), items)
266 }
267 statuses = res.Msg.GetStatuses()
268 }
269 rows := make([][]string, 0, len(statuses))
270 for _, s := range statuses {
271 rows = append(rows, []string{s.GetTarget().GetRepo(), mirrorStateName(s.GetState()), s.GetReason(), shortTime(s.GetLastSuccessAt(), "-"), shortTime(s.GetNextRunAt(), "-"), strconv.Itoa(int(s.GetConsecutiveFailures()))})
272 }
273 return table(cmd.OutOrStdout(), []string{"REPO", "STATE", "REASON", "LAST SUCCESS", "NEXT RUN", "FAILURES"}, rows)
274 }
275 return cmd
276}
277
278func newMirrorSyncCmd(env *cliEnv) *cobra.Command {
279 var all, force bool
280 cmd := &cobra.Command{Use: "sync <repo>|--all", Short: "Sync mirrors now", Long: "Sync one repository, or every repository with --all (owner only).\n--force never force-pushes: it only skips the no-op check and throttle and lifts a pause.", Args: func(_ *cobra.Command, args []string) error {
281 if (all && len(args) == 0) || (!all && len(args) == 1) {
282 return nil
283 }
284 return fmt.Errorf("specify exactly one repository or --all")
285 }}
286 cmd.Flags().BoolVar(&all, "all", false, "sync all repository mirrors")
287 cmd.Flags().BoolVar(&force, "force", false, "skip no-op check/throttle and lift pause; never force-push")
288 cmd.RunE = func(cmd *cobra.Command, args []string) error {
289 c, err := env.client(cmd)
290 if err != nil {
291 return err
292 }
293 req := &ermite.SyncMirrorRequest{All: all, Force: force}
294 if !all {
295 req.Repo = args[0]
296 }
297 res, err := c.Mirrors().SyncMirror(env.ctx(cmd), connect.NewRequest(req))
298 if err != nil {
299 return c.Describe(err)
300 }
301 failed := 0
302 rows := make([][]string, 0, len(res.Msg.GetResults()))
303 for _, r := range res.Msg.GetResults() {
304 reason := r.GetStatus().GetReason()
305 if r.GetError() != "" {
306 failed++
307 reason = r.GetError()
308 }
309 rows = append(rows, []string{r.GetRepo(), mirrorStateName(r.GetStatus().GetState()), strconv.FormatBool(r.GetQueued()), strconv.FormatBool(r.GetNoOp()), reason})
310 }
311 if err := table(cmd.OutOrStdout(), []string{"REPO", "STATE", "QUEUED", "NO-OP", "REASON"}, rows); err != nil {
312 return err
313 }
314 if failed > 0 {
315 return fmt.Errorf("%d of %d mirror syncs failed", failed, len(rows))
316 }
317 return nil
318 }
319 return cmd
320}