v0.35.1 2aca1df

cli_mirror.go RawHistoryFolder

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
278// newMirrorSyncCmd describes the server's wait bound (usecase.DefaultMirrorSyncWait, 8 s).
279func newMirrorSyncCmd(env *cliEnv) *cobra.Command {
280	var all, force bool
281	cmd := &cobra.Command{Use: "sync <repo>|--all", Short: "Sync mirrors now", Long: "Sync one repository, or every repository with --all (owner only).\nThe server pushes a few repositories at a time. The command waits about 8 s; a sync still running or waiting\nthen is reported as queued, keeps going on the server, and its outcome is shown by `ermite mirror status`.\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 {
282		if (all && len(args) == 0) || (!all && len(args) == 1) {
283			return nil
284		}
285		return fmt.Errorf("specify exactly one repository or --all")
286	}}
287	cmd.Flags().BoolVar(&all, "all", false, "sync all repository mirrors")
288	cmd.Flags().BoolVar(&force, "force", false, "skip no-op check/throttle and lift pause; never force-push")
289	cmd.RunE = func(cmd *cobra.Command, args []string) error {
290		c, err := env.client(cmd)
291		if err != nil {
292			return err
293		}
294		req := &ermite.SyncMirrorRequest{All: all, Force: force}
295		if !all {
296			req.Repo = args[0]
297		}
298		res, err := c.Mirrors().SyncMirror(env.ctx(cmd), connect.NewRequest(req))
299		if err != nil {
300			return c.Describe(err)
301		}
302		failed, pending := 0, 0
303		rows := make([][]string, 0, len(res.Msg.GetResults()))
304		for _, r := range res.Msg.GetResults() {
305			if r.GetQueued() {
306				pending++
307			}
308			reason := r.GetStatus().GetReason()
309			if r.GetError() != "" {
310				failed++
311				reason = r.GetError()
312			}
313			rows = append(rows, []string{r.GetRepo(), mirrorStateName(r.GetStatus().GetState()), strconv.FormatBool(r.GetQueued()), strconv.FormatBool(r.GetNoOp()), reason})
314		}
315		if err := table(cmd.OutOrStdout(), []string{"REPO", "STATE", "QUEUED", "NO-OP", "REASON"}, rows); err != nil {
316			return err
317		}
318		if pending > 0 {
319			_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%d of %d mirror syncs still running: they continue in the background; check `ermite mirror status`\n", pending, len(rows))
320		}
321		if failed > 0 {
322			return fmt.Errorf("%d of %d mirror syncs failed", failed, len(rows))
323		}
324		return nil
325	}
326	return cmd
327}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close