feat(cli): add the mirror commands and wire push mirrors into the forge
- commit
- 0ebe0739de4dd09eed63be9e9298ea17fecb5ee6
- author
- brice · · 2026-10-20 06:01 UTC
- committer
- brice · · 2026-10-20 10:02 UTC
- parent
- 86b5a04
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +478 -3
M internal/app/api_test.go +1 -1
| @@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||
| 196 | 196 | for _, who := range allPrincipals { | |
| 197 | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 198 | 198 | require.NoError(t, err, who) | |
| 199 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who) | |
| 199 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who) | |
| 200 | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | |
| 201 | 201 | } | |
| 202 | 202 | }) | |
| @@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||||
| 196 | for _, who := range allPrincipals { | 196 | for _, who := range allPrincipals { | ||
| 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | ||
| 198 | require.NoError(t, err, who) | 198 | require.NoError(t, err, who) | ||
| 199 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who) | 199 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who) |
| 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | ||
| 201 | } | 201 | } | ||
| 202 | }) | 202 | }) | ||
M internal/app/cli_e2e_test.go +1 -1
| @@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 350 | 350 | ver := decode[versionOut](t, res.stdout) | |
| 351 | 351 | assert.Equal(t, c.url, ver.Server.URL) | |
| 352 | 352 | assert.NotEmpty(t, ver.Server.Version) | |
| 353 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features) | |
| 353 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features) | |
| 354 | 354 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | |
| 355 | 355 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 356 | 356 | require.Equal(t, 0, code, stderr) | |
| @@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||||
| 350 | ver := decode[versionOut](t, res.stdout) | 350 | ver := decode[versionOut](t, res.stdout) | ||
| 351 | assert.Equal(t, c.url, ver.Server.URL) | 351 | assert.Equal(t, c.url, ver.Server.URL) | ||
| 352 | assert.NotEmpty(t, ver.Server.Version) | 352 | assert.NotEmpty(t, ver.Server.Version) | ||
| 353 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features) | 353 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features) |
| 354 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | 354 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | ||
| 355 | stdout, stderr, code = c.bin(t, secret, "version") | 355 | stdout, stderr, code = c.bin(t, secret, "version") | ||
| 356 | require.Equal(t, 0, code, stderr) | 356 | require.Equal(t, 0, code, stderr) | ||
A internal/app/cli_mirror.go +256 -0
| @@ -0,0 +1,256 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "strconv" | |
| 8 | + | "strings" | |
| 9 | + | ||
| 10 | + | "connectrpc.com/connect" | |
| 11 | + | "github.com/spf13/cobra" | |
| 12 | + | "google.golang.org/protobuf/encoding/protojson" | |
| 13 | + | "google.golang.org/protobuf/proto" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite-api/gen/ermite" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // mirrorJSON keeps the API's snake_case field names, including unset defaults. | |
| 19 | + | func mirrorJSON(cmd *cobra.Command, msg proto.Message) error { | |
| 20 | + | b, err := (protojson.MarshalOptions{UseProtoNames: true, EmitUnpopulated: true}).Marshal(msg) | |
| 21 | + | if err != nil { | |
| 22 | + | return err | |
| 23 | + | } | |
| 24 | + | _, err = fmt.Fprintln(cmd.OutOrStdout(), string(b)) | |
| 25 | + | return err | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func newMirrorCmd(env *cliEnv) *cobra.Command { | |
| 29 | + | cmd := &cobra.Command{Use: "mirror", Short: "Manage non-forcing push mirrors"} | |
| 30 | + | key := &cobra.Command{Use: "key", Short: "Show or rotate the mirror SSH key"} | |
| 31 | + | for _, rotate := range []bool{false, true} { | |
| 32 | + | name := "show" | |
| 33 | + | if rotate { | |
| 34 | + | name = "rotate" | |
| 35 | + | } | |
| 36 | + | sub := &cobra.Command{Use: name, Short: "Print the mirror authorized_keys line", Args: cobra.NoArgs, | |
| 37 | + | Long: "Print only the authorized_keys line on stdout; requires authentication and ERMITE_SECRETS_KEY on the server.\nPipe show into: gh ssh-key add - --title \"ermite mirror\". Rotation requires owner rights and takes effect immediately."} | |
| 38 | + | asJSON := addJSONFlag(sub) | |
| 39 | + | sub.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 40 | + | c, err := env.client(cmd) | |
| 41 | + | if err != nil { | |
| 42 | + | return err | |
| 43 | + | } | |
| 44 | + | var k *ermite.MirrorKey | |
| 45 | + | if rotate { | |
| 46 | + | res, e := c.Mirrors().RotateMirrorKey(env.ctx(cmd), connect.NewRequest(&ermite.RotateMirrorKeyRequest{})) | |
| 47 | + | if e != nil { | |
| 48 | + | return c.Describe(e) | |
| 49 | + | } | |
| 50 | + | k = res.Msg.GetKey() | |
| 51 | + | noticef(cmd, "remotes refuse pushes until this key is added") | |
| 52 | + | } else { | |
| 53 | + | res, e := c.Mirrors().GetMirrorKey(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorKeyRequest{})) | |
| 54 | + | if e != nil { | |
| 55 | + | return c.Describe(e) | |
| 56 | + | } | |
| 57 | + | k = res.Msg.GetKey() | |
| 58 | + | } | |
| 59 | + | if *asJSON { | |
| 60 | + | return mirrorJSON(cmd, k) | |
| 61 | + | } | |
| 62 | + | _, err = fmt.Fprintln(cmd.OutOrStdout(), clean(k.GetAuthorizedKey())) | |
| 63 | + | return err | |
| 64 | + | } | |
| 65 | + | key.AddCommand(sub) | |
| 66 | + | } | |
| 67 | + | cmd.AddCommand(key, newMirrorConfigCmd(env), newMirrorAddCmd(env), newMirrorStatusCmd(env), newMirrorSyncCmd(env)) | |
| 68 | + | for _, name := range []string{"remove", "exclude", "include"} { | |
| 69 | + | sub := &cobra.Command{Use: name + " <repo>", Short: name + " a repository mirror", Args: cobra.ExactArgs(1)} | |
| 70 | + | sub.RunE = func(cmd *cobra.Command, args []string) error { | |
| 71 | + | c, err := env.client(cmd) | |
| 72 | + | if err != nil { | |
| 73 | + | return err | |
| 74 | + | } | |
| 75 | + | if name == "remove" { | |
| 76 | + | _, err = c.Mirrors().RemoveRepoMirror(env.ctx(cmd), connect.NewRequest(&ermite.RemoveRepoMirrorRequest{Repo: args[0]})) | |
| 77 | + | } else { | |
| 78 | + | _, err = c.Mirrors().SetRepoMirrorExcluded(env.ctx(cmd), connect.NewRequest(&ermite.SetRepoMirrorExcludedRequest{Repo: args[0], Excluded: name == "exclude"})) | |
| 79 | + | } | |
| 80 | + | if err != nil { | |
| 81 | + | return c.Describe(err) | |
| 82 | + | } | |
| 83 | + | noticef(cmd, "%s: %s", name, clean(args[0])) | |
| 84 | + | return nil | |
| 85 | + | } | |
| 86 | + | cmd.AddCommand(sub) | |
| 87 | + | } | |
| 88 | + | return cmd | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func newMirrorConfigCmd(env *cliEnv) *cobra.Command { | |
| 92 | + | var enabled, all bool | |
| 93 | + | var url, cron string | |
| 94 | + | cmd := &cobra.Command{Use: "config", Short: "Patch the global mirror policy (owner only)", Args: cobra.NoArgs} | |
| 95 | + | cmd.Flags().BoolVar(&enabled, "enabled", false, "enable the global mirror policy") | |
| 96 | + | cmd.Flags().StringVar(&url, "url", "", "SSH URL template containing {repo}") | |
| 97 | + | cmd.Flags().StringVar(&cron, "cron", "", "five-field UTC cron (minimum gap five minutes)") | |
| 98 | + | cmd.Flags().BoolVar(&all, "all-branches", false, "mirror all branches instead of only the default branch") | |
| 99 | + | asJSON := addJSONFlag(cmd) | |
| 100 | + | output := func(cmd *cobra.Command, g *ermite.MirrorConfig, json bool) error { | |
| 101 | + | if json { | |
| 102 | + | return mirrorJSON(cmd, g) | |
| 103 | + | } | |
| 104 | + | return fields(cmd.OutOrStdout(), [][2]string{{"enabled", strconv.FormatBool(g.GetEnabled())}, {"url", g.GetUrlTemplate()}, {"cron (UTC)", g.GetCron()}, {"all branches", strconv.FormatBool(g.GetAllBranches())}}) | |
| 105 | + | } | |
| 106 | + | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 107 | + | c, err := env.client(cmd) | |
| 108 | + | if err != nil { | |
| 109 | + | return err | |
| 110 | + | } | |
| 111 | + | req := &ermite.SetMirrorConfigRequest{} | |
| 112 | + | if cmd.Flags().Changed("enabled") { | |
| 113 | + | req.Enabled = &enabled | |
| 114 | + | } | |
| 115 | + | if cmd.Flags().Changed("url") { | |
| 116 | + | req.UrlTemplate = &url | |
| 117 | + | } | |
| 118 | + | if cmd.Flags().Changed("cron") { | |
| 119 | + | req.Cron = &cron | |
| 120 | + | } | |
| 121 | + | if cmd.Flags().Changed("all-branches") { | |
| 122 | + | req.AllBranches = &all | |
| 123 | + | } | |
| 124 | + | if req.Enabled == nil && req.UrlTemplate == nil && req.Cron == nil && req.AllBranches == nil { | |
| 125 | + | return fmt.Errorf("specify at least one config flag, or use `mirror config show`") | |
| 126 | + | } | |
| 127 | + | res, err := c.Mirrors().SetMirrorConfig(env.ctx(cmd), connect.NewRequest(req)) | |
| 128 | + | if err != nil { | |
| 129 | + | return c.Describe(err) | |
| 130 | + | } | |
| 131 | + | return output(cmd, res.Msg.GetConfig(), *asJSON) | |
| 132 | + | } | |
| 133 | + | show := &cobra.Command{Use: "show", Short: "Show the global mirror policy", Args: cobra.NoArgs} | |
| 134 | + | showJSON := addJSONFlag(show) | |
| 135 | + | show.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 136 | + | c, err := env.client(cmd) | |
| 137 | + | if err != nil { | |
| 138 | + | return err | |
| 139 | + | } | |
| 140 | + | res, err := c.Mirrors().GetMirrorConfig(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorConfigRequest{})) | |
| 141 | + | if err != nil { | |
| 142 | + | return c.Describe(err) | |
| 143 | + | } | |
| 144 | + | return output(cmd, res.Msg.GetConfig(), *showJSON) | |
| 145 | + | } | |
| 146 | + | cmd.AddCommand(show) | |
| 147 | + | return cmd | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func newMirrorAddCmd(env *cliEnv) *cobra.Command { | |
| 151 | + | var cron string | |
| 152 | + | var all bool | |
| 153 | + | cmd := &cobra.Command{Use: "add <repo> <url>", Short: "Set a repository mirror override", Args: cobra.ExactArgs(2)} | |
| 154 | + | cmd.Flags().StringVar(&cron, "cron", "", "five-field UTC cron (empty inherits global)") | |
| 155 | + | cmd.Flags().BoolVar(&all, "all-branches", false, "mirror all branches (omitted inherits global)") | |
| 156 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 157 | + | c, err := env.client(cmd) | |
| 158 | + | if err != nil { | |
| 159 | + | return err | |
| 160 | + | } | |
| 161 | + | req := &ermite.SetRepoMirrorRequest{Repo: args[0], Url: args[1], Cron: cron} | |
| 162 | + | if cmd.Flags().Changed("all-branches") { | |
| 163 | + | req.AllBranches = &all | |
| 164 | + | } | |
| 165 | + | _, err = c.Mirrors().SetRepoMirror(env.ctx(cmd), connect.NewRequest(req)) | |
| 166 | + | if err != nil { | |
| 167 | + | return c.Describe(err) | |
| 168 | + | } | |
| 169 | + | noticef(cmd, "mirror configured for %s", clean(args[0])) | |
| 170 | + | return nil | |
| 171 | + | } | |
| 172 | + | return cmd | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func mirrorStateName(s ermite.MirrorState) string { | |
| 176 | + | return strings.ToLower(strings.TrimPrefix(s.String(), "MIRROR_STATE_")) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func newMirrorStatusCmd(env *cliEnv) *cobra.Command { | |
| 180 | + | cmd := &cobra.Command{Use: "status [repo]", Short: "Show mirror states, reasons and next runs", Args: cobra.MaximumNArgs(1)} | |
| 181 | + | asJSON := addJSONFlag(cmd) | |
| 182 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 183 | + | c, err := env.client(cmd) | |
| 184 | + | if err != nil { | |
| 185 | + | return err | |
| 186 | + | } | |
| 187 | + | var statuses []*ermite.MirrorStatus | |
| 188 | + | if len(args) == 1 { | |
| 189 | + | res, err := c.Mirrors().GetMirrorStatus(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorStatusRequest{Repo: args[0]})) | |
| 190 | + | if err != nil { | |
| 191 | + | return c.Describe(err) | |
| 192 | + | } | |
| 193 | + | if *asJSON { | |
| 194 | + | return mirrorJSON(cmd, res.Msg.GetStatus()) | |
| 195 | + | } | |
| 196 | + | statuses = append(statuses, res.Msg.GetStatus()) | |
| 197 | + | } else { | |
| 198 | + | res, err := c.Mirrors().ListMirrorStatus(env.ctx(cmd), connect.NewRequest(&ermite.ListMirrorStatusRequest{})) | |
| 199 | + | if err != nil { | |
| 200 | + | return c.Describe(err) | |
| 201 | + | } | |
| 202 | + | if *asJSON { | |
| 203 | + | // Marshal each status with proto JSON, but retain the CLI list convention: an array. | |
| 204 | + | items := make([]string, 0, len(res.Msg.GetStatuses())) | |
| 205 | + | for _, s := range res.Msg.GetStatuses() { | |
| 206 | + | b, e := (protojson.MarshalOptions{UseProtoNames: true, EmitUnpopulated: true}).Marshal(s) | |
| 207 | + | if e != nil { | |
| 208 | + | return e | |
| 209 | + | } | |
| 210 | + | items = append(items, string(b)) | |
| 211 | + | } | |
| 212 | + | _, err = fmt.Fprintln(cmd.OutOrStdout(), "["+strings.Join(items, ",")+"]") | |
| 213 | + | return err | |
| 214 | + | } | |
| 215 | + | statuses = res.Msg.GetStatuses() | |
| 216 | + | } | |
| 217 | + | rows := make([][]string, 0, len(statuses)) | |
| 218 | + | for _, s := range statuses { | |
| 219 | + | rows = append(rows, []string{s.GetTarget().GetRepo(), mirrorStateName(s.GetState()), s.GetReason(), shortTime(s.GetLastSuccessAt(), "-"), shortTime(s.GetNextRunAt(), "-"), strconv.Itoa(int(s.GetConsecutiveFailures()))}) | |
| 220 | + | } | |
| 221 | + | return table(cmd.OutOrStdout(), []string{"REPO", "STATE", "REASON", "LAST SUCCESS", "NEXT RUN", "FAILURES"}, rows) | |
| 222 | + | } | |
| 223 | + | return cmd | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func newMirrorSyncCmd(env *cliEnv) *cobra.Command { | |
| 227 | + | var all, force bool | |
| 228 | + | 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 { | |
| 229 | + | if (all && len(args) == 0) || (!all && len(args) == 1) { | |
| 230 | + | return nil | |
| 231 | + | } | |
| 232 | + | return fmt.Errorf("specify exactly one repository or --all") | |
| 233 | + | }} | |
| 234 | + | cmd.Flags().BoolVar(&all, "all", false, "sync all repository mirrors") | |
| 235 | + | cmd.Flags().BoolVar(&force, "force", false, "skip no-op check/throttle and lift pause; never force-push") | |
| 236 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 237 | + | c, err := env.client(cmd) | |
| 238 | + | if err != nil { | |
| 239 | + | return err | |
| 240 | + | } | |
| 241 | + | req := &ermite.SyncMirrorRequest{All: all, Force: force} | |
| 242 | + | if !all { | |
| 243 | + | req.Repo = args[0] | |
| 244 | + | } | |
| 245 | + | res, err := c.Mirrors().SyncMirror(env.ctx(cmd), connect.NewRequest(req)) | |
| 246 | + | if err != nil { | |
| 247 | + | return c.Describe(err) | |
| 248 | + | } | |
| 249 | + | rows := make([][]string, 0, len(res.Msg.GetResults())) | |
| 250 | + | for _, r := range res.Msg.GetResults() { | |
| 251 | + | rows = append(rows, []string{r.GetRepo(), mirrorStateName(r.GetStatus().GetState()), strconv.FormatBool(r.GetQueued()), strconv.FormatBool(r.GetNoOp()), r.GetStatus().GetReason()}) | |
| 252 | + | } | |
| 253 | + | return table(cmd.OutOrStdout(), []string{"REPO", "STATE", "QUEUED", "NO-OP", "REASON"}, rows) | |
| 254 | + | } | |
| 255 | + | return cmd | |
| 256 | + | } | |
| @@ -0,0 +1,256 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "strconv" | |||
| 8 | + | "strings" | |||
| 9 | + | ||||
| 10 | + | "connectrpc.com/connect" | |||
| 11 | + | "github.com/spf13/cobra" | |||
| 12 | + | "google.golang.org/protobuf/encoding/protojson" | |||
| 13 | + | "google.golang.org/protobuf/proto" | |||
| 14 | + | ||||
| 15 | + | "bnema.dev/ermite-api/gen/ermite" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // mirrorJSON keeps the API's snake_case field names, including unset defaults. | |||
| 19 | + | func mirrorJSON(cmd *cobra.Command, msg proto.Message) error { | |||
| 20 | + | b, err := (protojson.MarshalOptions{UseProtoNames: true, EmitUnpopulated: true}).Marshal(msg) | |||
| 21 | + | if err != nil { | |||
| 22 | + | return err | |||
| 23 | + | } | |||
| 24 | + | _, err = fmt.Fprintln(cmd.OutOrStdout(), string(b)) | |||
| 25 | + | return err | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func newMirrorCmd(env *cliEnv) *cobra.Command { | |||
| 29 | + | cmd := &cobra.Command{Use: "mirror", Short: "Manage non-forcing push mirrors"} | |||
| 30 | + | key := &cobra.Command{Use: "key", Short: "Show or rotate the mirror SSH key"} | |||
| 31 | + | for _, rotate := range []bool{false, true} { | |||
| 32 | + | name := "show" | |||
| 33 | + | if rotate { | |||
| 34 | + | name = "rotate" | |||
| 35 | + | } | |||
| 36 | + | sub := &cobra.Command{Use: name, Short: "Print the mirror authorized_keys line", Args: cobra.NoArgs, | |||
| 37 | + | Long: "Print only the authorized_keys line on stdout; requires authentication and ERMITE_SECRETS_KEY on the server.\nPipe show into: gh ssh-key add - --title \"ermite mirror\". Rotation requires owner rights and takes effect immediately."} | |||
| 38 | + | asJSON := addJSONFlag(sub) | |||
| 39 | + | sub.RunE = func(cmd *cobra.Command, _ []string) error { | |||
| 40 | + | c, err := env.client(cmd) | |||
| 41 | + | if err != nil { | |||
| 42 | + | return err | |||
| 43 | + | } | |||
| 44 | + | var k *ermite.MirrorKey | |||
| 45 | + | if rotate { | |||
| 46 | + | res, e := c.Mirrors().RotateMirrorKey(env.ctx(cmd), connect.NewRequest(&ermite.RotateMirrorKeyRequest{})) | |||
| 47 | + | if e != nil { | |||
| 48 | + | return c.Describe(e) | |||
| 49 | + | } | |||
| 50 | + | k = res.Msg.GetKey() | |||
| 51 | + | noticef(cmd, "remotes refuse pushes until this key is added") | |||
| 52 | + | } else { | |||
| 53 | + | res, e := c.Mirrors().GetMirrorKey(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorKeyRequest{})) | |||
| 54 | + | if e != nil { | |||
| 55 | + | return c.Describe(e) | |||
| 56 | + | } | |||
| 57 | + | k = res.Msg.GetKey() | |||
| 58 | + | } | |||
| 59 | + | if *asJSON { | |||
| 60 | + | return mirrorJSON(cmd, k) | |||
| 61 | + | } | |||
| 62 | + | _, err = fmt.Fprintln(cmd.OutOrStdout(), clean(k.GetAuthorizedKey())) | |||
| 63 | + | return err | |||
| 64 | + | } | |||
| 65 | + | key.AddCommand(sub) | |||
| 66 | + | } | |||
| 67 | + | cmd.AddCommand(key, newMirrorConfigCmd(env), newMirrorAddCmd(env), newMirrorStatusCmd(env), newMirrorSyncCmd(env)) | |||
| 68 | + | for _, name := range []string{"remove", "exclude", "include"} { | |||
| 69 | + | sub := &cobra.Command{Use: name + " <repo>", Short: name + " a repository mirror", Args: cobra.ExactArgs(1)} | |||
| 70 | + | sub.RunE = func(cmd *cobra.Command, args []string) error { | |||
| 71 | + | c, err := env.client(cmd) | |||
| 72 | + | if err != nil { | |||
| 73 | + | return err | |||
| 74 | + | } | |||
| 75 | + | if name == "remove" { | |||
| 76 | + | _, err = c.Mirrors().RemoveRepoMirror(env.ctx(cmd), connect.NewRequest(&ermite.RemoveRepoMirrorRequest{Repo: args[0]})) | |||
| 77 | + | } else { | |||
| 78 | + | _, err = c.Mirrors().SetRepoMirrorExcluded(env.ctx(cmd), connect.NewRequest(&ermite.SetRepoMirrorExcludedRequest{Repo: args[0], Excluded: name == "exclude"})) | |||
| 79 | + | } | |||
| 80 | + | if err != nil { | |||
| 81 | + | return c.Describe(err) | |||
| 82 | + | } | |||
| 83 | + | noticef(cmd, "%s: %s", name, clean(args[0])) | |||
| 84 | + | return nil | |||
| 85 | + | } | |||
| 86 | + | cmd.AddCommand(sub) | |||
| 87 | + | } | |||
| 88 | + | return cmd | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func newMirrorConfigCmd(env *cliEnv) *cobra.Command { | |||
| 92 | + | var enabled, all bool | |||
| 93 | + | var url, cron string | |||
| 94 | + | cmd := &cobra.Command{Use: "config", Short: "Patch the global mirror policy (owner only)", Args: cobra.NoArgs} | |||
| 95 | + | cmd.Flags().BoolVar(&enabled, "enabled", false, "enable the global mirror policy") | |||
| 96 | + | cmd.Flags().StringVar(&url, "url", "", "SSH URL template containing {repo}") | |||
| 97 | + | cmd.Flags().StringVar(&cron, "cron", "", "five-field UTC cron (minimum gap five minutes)") | |||
| 98 | + | cmd.Flags().BoolVar(&all, "all-branches", false, "mirror all branches instead of only the default branch") | |||
| 99 | + | asJSON := addJSONFlag(cmd) | |||
| 100 | + | output := func(cmd *cobra.Command, g *ermite.MirrorConfig, json bool) error { | |||
| 101 | + | if json { | |||
| 102 | + | return mirrorJSON(cmd, g) | |||
| 103 | + | } | |||
| 104 | + | return fields(cmd.OutOrStdout(), [][2]string{{"enabled", strconv.FormatBool(g.GetEnabled())}, {"url", g.GetUrlTemplate()}, {"cron (UTC)", g.GetCron()}, {"all branches", strconv.FormatBool(g.GetAllBranches())}}) | |||
| 105 | + | } | |||
| 106 | + | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |||
| 107 | + | c, err := env.client(cmd) | |||
| 108 | + | if err != nil { | |||
| 109 | + | return err | |||
| 110 | + | } | |||
| 111 | + | req := &ermite.SetMirrorConfigRequest{} | |||
| 112 | + | if cmd.Flags().Changed("enabled") { | |||
| 113 | + | req.Enabled = &enabled | |||
| 114 | + | } | |||
| 115 | + | if cmd.Flags().Changed("url") { | |||
| 116 | + | req.UrlTemplate = &url | |||
| 117 | + | } | |||
| 118 | + | if cmd.Flags().Changed("cron") { | |||
| 119 | + | req.Cron = &cron | |||
| 120 | + | } | |||
| 121 | + | if cmd.Flags().Changed("all-branches") { | |||
| 122 | + | req.AllBranches = &all | |||
| 123 | + | } | |||
| 124 | + | if req.Enabled == nil && req.UrlTemplate == nil && req.Cron == nil && req.AllBranches == nil { | |||
| 125 | + | return fmt.Errorf("specify at least one config flag, or use `mirror config show`") | |||
| 126 | + | } | |||
| 127 | + | res, err := c.Mirrors().SetMirrorConfig(env.ctx(cmd), connect.NewRequest(req)) | |||
| 128 | + | if err != nil { | |||
| 129 | + | return c.Describe(err) | |||
| 130 | + | } | |||
| 131 | + | return output(cmd, res.Msg.GetConfig(), *asJSON) | |||
| 132 | + | } | |||
| 133 | + | show := &cobra.Command{Use: "show", Short: "Show the global mirror policy", Args: cobra.NoArgs} | |||
| 134 | + | showJSON := addJSONFlag(show) | |||
| 135 | + | show.RunE = func(cmd *cobra.Command, _ []string) error { | |||
| 136 | + | c, err := env.client(cmd) | |||
| 137 | + | if err != nil { | |||
| 138 | + | return err | |||
| 139 | + | } | |||
| 140 | + | res, err := c.Mirrors().GetMirrorConfig(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorConfigRequest{})) | |||
| 141 | + | if err != nil { | |||
| 142 | + | return c.Describe(err) | |||
| 143 | + | } | |||
| 144 | + | return output(cmd, res.Msg.GetConfig(), *showJSON) | |||
| 145 | + | } | |||
| 146 | + | cmd.AddCommand(show) | |||
| 147 | + | return cmd | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func newMirrorAddCmd(env *cliEnv) *cobra.Command { | |||
| 151 | + | var cron string | |||
| 152 | + | var all bool | |||
| 153 | + | cmd := &cobra.Command{Use: "add <repo> <url>", Short: "Set a repository mirror override", Args: cobra.ExactArgs(2)} | |||
| 154 | + | cmd.Flags().StringVar(&cron, "cron", "", "five-field UTC cron (empty inherits global)") | |||
| 155 | + | cmd.Flags().BoolVar(&all, "all-branches", false, "mirror all branches (omitted inherits global)") | |||
| 156 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |||
| 157 | + | c, err := env.client(cmd) | |||
| 158 | + | if err != nil { | |||
| 159 | + | return err | |||
| 160 | + | } | |||
| 161 | + | req := &ermite.SetRepoMirrorRequest{Repo: args[0], Url: args[1], Cron: cron} | |||
| 162 | + | if cmd.Flags().Changed("all-branches") { | |||
| 163 | + | req.AllBranches = &all | |||
| 164 | + | } | |||
| 165 | + | _, err = c.Mirrors().SetRepoMirror(env.ctx(cmd), connect.NewRequest(req)) | |||
| 166 | + | if err != nil { | |||
| 167 | + | return c.Describe(err) | |||
| 168 | + | } | |||
| 169 | + | noticef(cmd, "mirror configured for %s", clean(args[0])) | |||
| 170 | + | return nil | |||
| 171 | + | } | |||
| 172 | + | return cmd | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func mirrorStateName(s ermite.MirrorState) string { | |||
| 176 | + | return strings.ToLower(strings.TrimPrefix(s.String(), "MIRROR_STATE_")) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func newMirrorStatusCmd(env *cliEnv) *cobra.Command { | |||
| 180 | + | cmd := &cobra.Command{Use: "status [repo]", Short: "Show mirror states, reasons and next runs", Args: cobra.MaximumNArgs(1)} | |||
| 181 | + | asJSON := addJSONFlag(cmd) | |||
| 182 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |||
| 183 | + | c, err := env.client(cmd) | |||
| 184 | + | if err != nil { | |||
| 185 | + | return err | |||
| 186 | + | } | |||
| 187 | + | var statuses []*ermite.MirrorStatus | |||
| 188 | + | if len(args) == 1 { | |||
| 189 | + | res, err := c.Mirrors().GetMirrorStatus(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorStatusRequest{Repo: args[0]})) | |||
| 190 | + | if err != nil { | |||
| 191 | + | return c.Describe(err) | |||
| 192 | + | } | |||
| 193 | + | if *asJSON { | |||
| 194 | + | return mirrorJSON(cmd, res.Msg.GetStatus()) | |||
| 195 | + | } | |||
| 196 | + | statuses = append(statuses, res.Msg.GetStatus()) | |||
| 197 | + | } else { | |||
| 198 | + | res, err := c.Mirrors().ListMirrorStatus(env.ctx(cmd), connect.NewRequest(&ermite.ListMirrorStatusRequest{})) | |||
| 199 | + | if err != nil { | |||
| 200 | + | return c.Describe(err) | |||
| 201 | + | } | |||
| 202 | + | if *asJSON { | |||
| 203 | + | // Marshal each status with proto JSON, but retain the CLI list convention: an array. | |||
| 204 | + | items := make([]string, 0, len(res.Msg.GetStatuses())) | |||
| 205 | + | for _, s := range res.Msg.GetStatuses() { | |||
| 206 | + | b, e := (protojson.MarshalOptions{UseProtoNames: true, EmitUnpopulated: true}).Marshal(s) | |||
| 207 | + | if e != nil { | |||
| 208 | + | return e | |||
| 209 | + | } | |||
| 210 | + | items = append(items, string(b)) | |||
| 211 | + | } | |||
| 212 | + | _, err = fmt.Fprintln(cmd.OutOrStdout(), "["+strings.Join(items, ",")+"]") | |||
| 213 | + | return err | |||
| 214 | + | } | |||
| 215 | + | statuses = res.Msg.GetStatuses() | |||
| 216 | + | } | |||
| 217 | + | rows := make([][]string, 0, len(statuses)) | |||
| 218 | + | for _, s := range statuses { | |||
| 219 | + | rows = append(rows, []string{s.GetTarget().GetRepo(), mirrorStateName(s.GetState()), s.GetReason(), shortTime(s.GetLastSuccessAt(), "-"), shortTime(s.GetNextRunAt(), "-"), strconv.Itoa(int(s.GetConsecutiveFailures()))}) | |||
| 220 | + | } | |||
| 221 | + | return table(cmd.OutOrStdout(), []string{"REPO", "STATE", "REASON", "LAST SUCCESS", "NEXT RUN", "FAILURES"}, rows) | |||
| 222 | + | } | |||
| 223 | + | return cmd | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | func newMirrorSyncCmd(env *cliEnv) *cobra.Command { | |||
| 227 | + | var all, force bool | |||
| 228 | + | 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 { | |||
| 229 | + | if (all && len(args) == 0) || (!all && len(args) == 1) { | |||
| 230 | + | return nil | |||
| 231 | + | } | |||
| 232 | + | return fmt.Errorf("specify exactly one repository or --all") | |||
| 233 | + | }} | |||
| 234 | + | cmd.Flags().BoolVar(&all, "all", false, "sync all repository mirrors") | |||
| 235 | + | cmd.Flags().BoolVar(&force, "force", false, "skip no-op check/throttle and lift pause; never force-push") | |||
| 236 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |||
| 237 | + | c, err := env.client(cmd) | |||
| 238 | + | if err != nil { | |||
| 239 | + | return err | |||
| 240 | + | } | |||
| 241 | + | req := &ermite.SyncMirrorRequest{All: all, Force: force} | |||
| 242 | + | if !all { | |||
| 243 | + | req.Repo = args[0] | |||
| 244 | + | } | |||
| 245 | + | res, err := c.Mirrors().SyncMirror(env.ctx(cmd), connect.NewRequest(req)) | |||
| 246 | + | if err != nil { | |||
| 247 | + | return c.Describe(err) | |||
| 248 | + | } | |||
| 249 | + | rows := make([][]string, 0, len(res.Msg.GetResults())) | |||
| 250 | + | for _, r := range res.Msg.GetResults() { | |||
| 251 | + | rows = append(rows, []string{r.GetRepo(), mirrorStateName(r.GetStatus().GetState()), strconv.FormatBool(r.GetQueued()), strconv.FormatBool(r.GetNoOp()), r.GetStatus().GetReason()}) | |||
| 252 | + | } | |||
| 253 | + | return table(cmd.OutOrStdout(), []string{"REPO", "STATE", "QUEUED", "NO-OP", "REASON"}, rows) | |||
| 254 | + | } | |||
| 255 | + | return cmd | |||
| 256 | + | } | |||
A internal/app/cli_mirror_e2e_test.go +121 -0
| @@ -0,0 +1,121 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | "golang.org/x/crypto/ssh" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/adapters/gitcli" | |
| 17 | + | "bnema.dev/ermite/internal/adapters/gitpush" | |
| 18 | + | "bnema.dev/ermite/internal/config" | |
| 19 | + | "bnema.dev/ermite/internal/domain" | |
| 20 | + | "bnema.dev/ermite/internal/usecase" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // Pushes mark mirrors dirty; a due cron tick performs the asynchronous sync. | |
| 24 | + | // Advancing the injected clock exercises that contract without waiting five minutes. | |
| 25 | + | func TestMirrorCLIEndToEnd(t *testing.T) { | |
| 26 | + | clk := &testClock{} | |
| 27 | + | clk.set("2025-12-31 23:59:00") | |
| 28 | + | transport := t.TempDir() | |
| 29 | + | var dataDir string | |
| 30 | + | h := newHarnessWith(t, harnessOpts{env: map[string]string{config.EnvSecretsKey: secretsKeyA}}, func(o *options) { | |
| 31 | + | o.mirrors = func(c *usecase.MirrorsConfig) { | |
| 32 | + | c.AllowAnyURL = true | |
| 33 | + | c.Clock = clk | |
| 34 | + | c.ScheduleTick = time.Hour | |
| 35 | + | c.Pusher = gitpush.New(gitpush.Config{ | |
| 36 | + | Bin: requireGit(t), Path: os.Getenv("PATH"), Home: transport, | |
| 37 | + | KnownHosts: filepath.Join(transport, "known_hosts"), TempDir: filepath.Join(transport, "keys"), | |
| 38 | + | AllowedProtocols: "file", RepoDir: func(repo domain.RepoName) string { return gitcli.RepoDir(dataDir, repo) }, | |
| 39 | + | }) | |
| 40 | + | } | |
| 41 | + | }) | |
| 42 | + | // The transport needs the harness data directory, which is known after newForge. | |
| 43 | + | // Replace the test-only closure's captured directory before any sync is queued. | |
| 44 | + | dataDir = h.data | |
| 45 | + | c := &cliHarness{harness: h, url: h.srv.URL} | |
| 46 | + | run := func(args ...string) result { return c.cli(t, adminToken, append([]string{"mirror"}, args...)...) } | |
| 47 | + | h.createRepo("proj", domain.VisibilityPrivate) | |
| 48 | + | remote := filepath.Join(t.TempDir(), "remote.git") | |
| 49 | + | h.mustGit(transport, "init", "--bare", remote) | |
| 50 | + | run("add", "proj", "file://"+remote, "--cron", "*/5 * * * *").ok(t) | |
| 51 | + | key := run("key", "show").ok(t) | |
| 52 | + | assert.Empty(t, key.stderr) | |
| 53 | + | pub, _, _, rest, err := ssh.ParseAuthorizedKey([]byte(key.stdout)) | |
| 54 | + | require.NoError(t, err) | |
| 55 | + | assert.Empty(t, rest) | |
| 56 | + | assert.Equal(t, "ssh-ed25519", pub.Type()) | |
| 57 | + | assert.Equal(t, 1, strings.Count(key.stdout, "\n")) | |
| 58 | + | assert.Contains(t, c.cli(t, adminToken, "version").ok(t).stdout, "mirror") | |
| 59 | + | events := h.forge.Bus.Subscribe(h.ctx) | |
| 60 | + | wt := h.workTree("main", "README.md") | |
| 61 | + | h.mustGit(wt, "tag", "v1") | |
| 62 | + | h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main", "--tags") | |
| 63 | + | clk.set("2026-01-01 00:00:00") | |
| 64 | + | h.forge.Mirrors.ScheduleNow(h.ctx) | |
| 65 | + | require.Eventually(t, func() bool { | |
| 66 | + | s, err := h.forge.Mirrors.GetStatus(h.ctx, domain.Root(), h.name("proj")) | |
| 67 | + | return err == nil && !s.Status.LastSuccessAt.IsZero() | |
| 68 | + | }, 10*time.Second, 10*time.Millisecond) | |
| 69 | + | main := strings.TrimSpace(h.mustGit(wt, "rev-parse", "main")) | |
| 70 | + | assert.Equal(t, main, strings.TrimSpace(h.mustGit(transport, "--git-dir="+remote, "rev-parse", "refs/heads/main"))) | |
| 71 | + | assert.Equal(t, main, strings.TrimSpace(h.mustGit(transport, "--git-dir="+remote, "rev-parse", "refs/tags/v1"))) | |
| 72 | + | clk.set("2026-01-01 00:02:00") | |
| 73 | + | assert.Contains(t, run("sync", "proj").ok(t).stdout, "true", "unchanged refs are a no-op") | |
| 74 | + | ||
| 75 | + | // Advance the remote independently, so the next atomic mirror push is rejected. | |
| 76 | + | other := filepath.Join(t.TempDir(), "other") | |
| 77 | + | h.mustGit(transport, "clone", "--branch", "main", remote, other) | |
| 78 | + | h.mustGit(other, "commit", "--allow-empty", "-m", "remote-only") | |
| 79 | + | h.mustGit(other, "push", "origin", "main") | |
| 80 | + | h.mustGit(wt, "commit", "--allow-empty", "-m", "forge-only") | |
| 81 | + | h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main") | |
| 82 | + | clk.set("2026-01-01 00:05:00") | |
| 83 | + | h.forge.Mirrors.ScheduleNow(h.ctx) | |
| 84 | + | require.Eventually(t, func() bool { | |
| 85 | + | s, err := h.forge.Mirrors.GetStatus(h.ctx, domain.Root(), h.name("proj")) | |
| 86 | + | return err == nil && s.Status.State == domain.MirrorPaused | |
| 87 | + | }, 10*time.Second, 10*time.Millisecond) | |
| 88 | + | status := run("status", "proj").ok(t).stdout | |
| 89 | + | assert.Contains(t, status, "paused") | |
| 90 | + | assert.Contains(t, status, "remote has commits") | |
| 91 | + | assert.Contains(t, run("status", "proj", "--json").ok(t).stdout, `"failure_class":"diverged"`) | |
| 92 | + | h.mustGit(wt, "commit", "--allow-empty", "-m", "warning") | |
| 93 | + | warning := h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main") | |
| 94 | + | assert.Contains(t, warning, "remote: ermite: mirror") | |
| 95 | + | assert.Contains(t, warning, "paused") | |
| 96 | + | token := c.signIn(t) | |
| 97 | + | page := (browser{h}).do("GET", "/proj", map[string]string{"Cookie": sessionCookie + "=" + token}, "") | |
| 98 | + | assert.Equal(t, 200, page.status) | |
| 99 | + | assert.Contains(t, page.body, "Mirror paused") | |
| 100 | + | assert.Contains(t, page.body, "ermite mirror status proj") | |
| 101 | + | ||
| 102 | + | // Repair the remote, then force a normal (never forced) Git push to recover. | |
| 103 | + | latest := strings.TrimSpace(h.mustGit(wt, "rev-parse", "main")) | |
| 104 | + | h.mustGit(wt, "push", "--force", remote, "main") // deliberate remote repair, not mirror behavior | |
| 105 | + | run("sync", "proj", "--force").ok(t) | |
| 106 | + | assert.Equal(t, latest, strings.TrimSpace(h.mustGit(transport, "--git-dir="+remote, "rev-parse", "refs/heads/main"))) | |
| 107 | + | assert.Contains(t, run("status", "proj").ok(t).stdout, "ok") | |
| 108 | + | run("sync", "--all", "--force").ok(t) | |
| 109 | + | var kinds []string | |
| 110 | + | for { | |
| 111 | + | select { | |
| 112 | + | case event := <-events: | |
| 113 | + | if event.EventKind() == "mirror.failed" || event.EventKind() == "mirror.recovered" { | |
| 114 | + | kinds = append(kinds, event.EventKind()) | |
| 115 | + | } | |
| 116 | + | default: | |
| 117 | + | assert.Equal(t, []string{"mirror.failed", "mirror.recovered"}, kinds) | |
| 118 | + | return | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | } | |
| @@ -0,0 +1,121 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/stretchr/testify/assert" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | "golang.org/x/crypto/ssh" | |||
| 15 | + | ||||
| 16 | + | "bnema.dev/ermite/internal/adapters/gitcli" | |||
| 17 | + | "bnema.dev/ermite/internal/adapters/gitpush" | |||
| 18 | + | "bnema.dev/ermite/internal/config" | |||
| 19 | + | "bnema.dev/ermite/internal/domain" | |||
| 20 | + | "bnema.dev/ermite/internal/usecase" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // Pushes mark mirrors dirty; a due cron tick performs the asynchronous sync. | |||
| 24 | + | // Advancing the injected clock exercises that contract without waiting five minutes. | |||
| 25 | + | func TestMirrorCLIEndToEnd(t *testing.T) { | |||
| 26 | + | clk := &testClock{} | |||
| 27 | + | clk.set("2025-12-31 23:59:00") | |||
| 28 | + | transport := t.TempDir() | |||
| 29 | + | var dataDir string | |||
| 30 | + | h := newHarnessWith(t, harnessOpts{env: map[string]string{config.EnvSecretsKey: secretsKeyA}}, func(o *options) { | |||
| 31 | + | o.mirrors = func(c *usecase.MirrorsConfig) { | |||
| 32 | + | c.AllowAnyURL = true | |||
| 33 | + | c.Clock = clk | |||
| 34 | + | c.ScheduleTick = time.Hour | |||
| 35 | + | c.Pusher = gitpush.New(gitpush.Config{ | |||
| 36 | + | Bin: requireGit(t), Path: os.Getenv("PATH"), Home: transport, | |||
| 37 | + | KnownHosts: filepath.Join(transport, "known_hosts"), TempDir: filepath.Join(transport, "keys"), | |||
| 38 | + | AllowedProtocols: "file", RepoDir: func(repo domain.RepoName) string { return gitcli.RepoDir(dataDir, repo) }, | |||
| 39 | + | }) | |||
| 40 | + | } | |||
| 41 | + | }) | |||
| 42 | + | // The transport needs the harness data directory, which is known after newForge. | |||
| 43 | + | // Replace the test-only closure's captured directory before any sync is queued. | |||
| 44 | + | dataDir = h.data | |||
| 45 | + | c := &cliHarness{harness: h, url: h.srv.URL} | |||
| 46 | + | run := func(args ...string) result { return c.cli(t, adminToken, append([]string{"mirror"}, args...)...) } | |||
| 47 | + | h.createRepo("proj", domain.VisibilityPrivate) | |||
| 48 | + | remote := filepath.Join(t.TempDir(), "remote.git") | |||
| 49 | + | h.mustGit(transport, "init", "--bare", remote) | |||
| 50 | + | run("add", "proj", "file://"+remote, "--cron", "*/5 * * * *").ok(t) | |||
| 51 | + | key := run("key", "show").ok(t) | |||
| 52 | + | assert.Empty(t, key.stderr) | |||
| 53 | + | pub, _, _, rest, err := ssh.ParseAuthorizedKey([]byte(key.stdout)) | |||
| 54 | + | require.NoError(t, err) | |||
| 55 | + | assert.Empty(t, rest) | |||
| 56 | + | assert.Equal(t, "ssh-ed25519", pub.Type()) | |||
| 57 | + | assert.Equal(t, 1, strings.Count(key.stdout, "\n")) | |||
| 58 | + | assert.Contains(t, c.cli(t, adminToken, "version").ok(t).stdout, "mirror") | |||
| 59 | + | events := h.forge.Bus.Subscribe(h.ctx) | |||
| 60 | + | wt := h.workTree("main", "README.md") | |||
| 61 | + | h.mustGit(wt, "tag", "v1") | |||
| 62 | + | h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main", "--tags") | |||
| 63 | + | clk.set("2026-01-01 00:00:00") | |||
| 64 | + | h.forge.Mirrors.ScheduleNow(h.ctx) | |||
| 65 | + | require.Eventually(t, func() bool { | |||
| 66 | + | s, err := h.forge.Mirrors.GetStatus(h.ctx, domain.Root(), h.name("proj")) | |||
| 67 | + | return err == nil && !s.Status.LastSuccessAt.IsZero() | |||
| 68 | + | }, 10*time.Second, 10*time.Millisecond) | |||
| 69 | + | main := strings.TrimSpace(h.mustGit(wt, "rev-parse", "main")) | |||
| 70 | + | assert.Equal(t, main, strings.TrimSpace(h.mustGit(transport, "--git-dir="+remote, "rev-parse", "refs/heads/main"))) | |||
| 71 | + | assert.Equal(t, main, strings.TrimSpace(h.mustGit(transport, "--git-dir="+remote, "rev-parse", "refs/tags/v1"))) | |||
| 72 | + | clk.set("2026-01-01 00:02:00") | |||
| 73 | + | assert.Contains(t, run("sync", "proj").ok(t).stdout, "true", "unchanged refs are a no-op") | |||
| 74 | + | ||||
| 75 | + | // Advance the remote independently, so the next atomic mirror push is rejected. | |||
| 76 | + | other := filepath.Join(t.TempDir(), "other") | |||
| 77 | + | h.mustGit(transport, "clone", "--branch", "main", remote, other) | |||
| 78 | + | h.mustGit(other, "commit", "--allow-empty", "-m", "remote-only") | |||
| 79 | + | h.mustGit(other, "push", "origin", "main") | |||
| 80 | + | h.mustGit(wt, "commit", "--allow-empty", "-m", "forge-only") | |||
| 81 | + | h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main") | |||
| 82 | + | clk.set("2026-01-01 00:05:00") | |||
| 83 | + | h.forge.Mirrors.ScheduleNow(h.ctx) | |||
| 84 | + | require.Eventually(t, func() bool { | |||
| 85 | + | s, err := h.forge.Mirrors.GetStatus(h.ctx, domain.Root(), h.name("proj")) | |||
| 86 | + | return err == nil && s.Status.State == domain.MirrorPaused | |||
| 87 | + | }, 10*time.Second, 10*time.Millisecond) | |||
| 88 | + | status := run("status", "proj").ok(t).stdout | |||
| 89 | + | assert.Contains(t, status, "paused") | |||
| 90 | + | assert.Contains(t, status, "remote has commits") | |||
| 91 | + | assert.Contains(t, run("status", "proj", "--json").ok(t).stdout, `"failure_class":"diverged"`) | |||
| 92 | + | h.mustGit(wt, "commit", "--allow-empty", "-m", "warning") | |||
| 93 | + | warning := h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main") | |||
| 94 | + | assert.Contains(t, warning, "remote: ermite: mirror") | |||
| 95 | + | assert.Contains(t, warning, "paused") | |||
| 96 | + | token := c.signIn(t) | |||
| 97 | + | page := (browser{h}).do("GET", "/proj", map[string]string{"Cookie": sessionCookie + "=" + token}, "") | |||
| 98 | + | assert.Equal(t, 200, page.status) | |||
| 99 | + | assert.Contains(t, page.body, "Mirror paused") | |||
| 100 | + | assert.Contains(t, page.body, "ermite mirror status proj") | |||
| 101 | + | ||||
| 102 | + | // Repair the remote, then force a normal (never forced) Git push to recover. | |||
| 103 | + | latest := strings.TrimSpace(h.mustGit(wt, "rev-parse", "main")) | |||
| 104 | + | h.mustGit(wt, "push", "--force", remote, "main") // deliberate remote repair, not mirror behavior | |||
| 105 | + | run("sync", "proj", "--force").ok(t) | |||
| 106 | + | assert.Equal(t, latest, strings.TrimSpace(h.mustGit(transport, "--git-dir="+remote, "rev-parse", "refs/heads/main"))) | |||
| 107 | + | assert.Contains(t, run("status", "proj").ok(t).stdout, "ok") | |||
| 108 | + | run("sync", "--all", "--force").ok(t) | |||
| 109 | + | var kinds []string | |||
| 110 | + | for { | |||
| 111 | + | select { | |||
| 112 | + | case event := <-events: | |||
| 113 | + | if event.EventKind() == "mirror.failed" || event.EventKind() == "mirror.recovered" { | |||
| 114 | + | kinds = append(kinds, event.EventKind()) | |||
| 115 | + | } | |||
| 116 | + | default: | |||
| 117 | + | assert.Equal(t, []string{"mirror.failed", "mirror.recovered"}, kinds) | |||
| 118 | + | return | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | } | |||
A internal/app/cli_mirror_test.go +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestMirrorCLIArguments(t *testing.T) { | |
| 14 | + | for _, args := range [][]string{ | |
| 15 | + | {"mirror", "sync"}, {"mirror", "sync", "repo", "--all"}, {"mirror", "sync", "a", "b"}, | |
| 16 | + | {"mirror", "add", "repo"}, {"mirror", "remove"}, {"mirror", "exclude"}, {"mirror", "include"}, | |
| 17 | + | {"mirror", "key", "show", "extra"}, {"mirror", "key", "rotate", "extra"}, {"mirror", "status", "a", "b"}, | |
| 18 | + | } { | |
| 19 | + | t.Run(strings.Join(args, " "), func(t *testing.T) { | |
| 20 | + | r := runCLI(t, nil, "", args...) | |
| 21 | + | require.Error(t, r.err) | |
| 22 | + | assert.NotContains(t, r.err.Error(), "ERMITE_URL", "arguments must be rejected before loading credentials") | |
| 23 | + | }) | |
| 24 | + | } | |
| 25 | + | r := runCLI(t, nil, "", "mirror", "sync", "--help").ok(t) | |
| 26 | + | assert.Contains(t, r.stdout, "never force-pushes") | |
| 27 | + | assert.Contains(t, r.stdout, "lifts a pause") | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestMirrorCLIConfiguration(t *testing.T) { | |
| 31 | + | h := newSecretsCLI(t) | |
| 32 | + | run := func(args ...string) result { return h.cli(t, adminToken, append([]string{"mirror"}, args...)...) } | |
| 33 | + | run("config", "--enabled", "--url", "git@example.test:owner/{repo}.git", "--cron", "*/5 * * * *", "--all-branches").ok(t) | |
| 34 | + | r := run("config", "show", "--json").ok(t) | |
| 35 | + | g := decode[map[string]any](t, r.stdout) | |
| 36 | + | assert.Equal(t, true, g["enabled"]) | |
| 37 | + | assert.Equal(t, true, g["all_branches"]) | |
| 38 | + | assert.Equal(t, "git@example.test:owner/{repo}.git", g["url_template"]) | |
| 39 | + | run("config", "--enabled=false", "--all-branches=false").ok(t) | |
| 40 | + | g = decode[map[string]any](t, run("config", "show", "--json").ok(t).stdout) | |
| 41 | + | assert.Equal(t, false, g["enabled"]) | |
| 42 | + | assert.Equal(t, false, g["all_branches"]) | |
| 43 | + | assert.Equal(t, "*/5 * * * *", g["cron"], "omitted fields are not cleared") | |
| 44 | + | run("add", "proj", "git@example.test:owner/proj.git", "--cron", "*/10 * * * *", "--all-branches=false").ok(t) | |
| 45 | + | r = run("status", "proj", "--json").ok(t) | |
| 46 | + | s := decode[map[string]any](t, r.stdout) | |
| 47 | + | target := s["target"].(map[string]any) | |
| 48 | + | assert.Equal(t, "MIRROR_SOURCE_OVERRIDE", target["source"]) | |
| 49 | + | assert.Equal(t, false, target["all_branches"]) | |
| 50 | + | assert.Contains(t, run("status").ok(t).stdout, "FAILURES") | |
| 51 | + | assert.Len(t, decode[[]any](t, run("status", "--json").ok(t).stdout), 1) | |
| 52 | + | run("remove", "proj").ok(t) | |
| 53 | + | run("exclude", "proj").ok(t) | |
| 54 | + | s = decode[map[string]any](t, run("status", "proj", "--json").ok(t).stdout) | |
| 55 | + | assert.Equal(t, "MIRROR_SOURCE_EXCLUDED", s["target"].(map[string]any)["source"]) | |
| 56 | + | run("include", "proj").ok(t) | |
| 57 | + | first := run("key", "show").ok(t) | |
| 58 | + | assert.Empty(t, first.stderr) | |
| 59 | + | rotated := run("key", "rotate").ok(t) | |
| 60 | + | assert.NotEqual(t, first.stdout, rotated.stdout) | |
| 61 | + | assert.Contains(t, rotated.stderr, "remotes refuse pushes until this key is added") | |
| 62 | + | assert.Equal(t, rotated.stdout, run("key", "show").ok(t).stdout) | |
| 63 | + | assert.Equal(t, []string{"authorized_key", "created_at", "fingerprint"}, keys(t, run("key", "show", "--json").ok(t).stdout)) | |
| 64 | + | } | |
| @@ -0,0 +1,64 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestMirrorCLIArguments(t *testing.T) { | |||
| 14 | + | for _, args := range [][]string{ | |||
| 15 | + | {"mirror", "sync"}, {"mirror", "sync", "repo", "--all"}, {"mirror", "sync", "a", "b"}, | |||
| 16 | + | {"mirror", "add", "repo"}, {"mirror", "remove"}, {"mirror", "exclude"}, {"mirror", "include"}, | |||
| 17 | + | {"mirror", "key", "show", "extra"}, {"mirror", "key", "rotate", "extra"}, {"mirror", "status", "a", "b"}, | |||
| 18 | + | } { | |||
| 19 | + | t.Run(strings.Join(args, " "), func(t *testing.T) { | |||
| 20 | + | r := runCLI(t, nil, "", args...) | |||
| 21 | + | require.Error(t, r.err) | |||
| 22 | + | assert.NotContains(t, r.err.Error(), "ERMITE_URL", "arguments must be rejected before loading credentials") | |||
| 23 | + | }) | |||
| 24 | + | } | |||
| 25 | + | r := runCLI(t, nil, "", "mirror", "sync", "--help").ok(t) | |||
| 26 | + | assert.Contains(t, r.stdout, "never force-pushes") | |||
| 27 | + | assert.Contains(t, r.stdout, "lifts a pause") | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestMirrorCLIConfiguration(t *testing.T) { | |||
| 31 | + | h := newSecretsCLI(t) | |||
| 32 | + | run := func(args ...string) result { return h.cli(t, adminToken, append([]string{"mirror"}, args...)...) } | |||
| 33 | + | run("config", "--enabled", "--url", "git@example.test:owner/{repo}.git", "--cron", "*/5 * * * *", "--all-branches").ok(t) | |||
| 34 | + | r := run("config", "show", "--json").ok(t) | |||
| 35 | + | g := decode[map[string]any](t, r.stdout) | |||
| 36 | + | assert.Equal(t, true, g["enabled"]) | |||
| 37 | + | assert.Equal(t, true, g["all_branches"]) | |||
| 38 | + | assert.Equal(t, "git@example.test:owner/{repo}.git", g["url_template"]) | |||
| 39 | + | run("config", "--enabled=false", "--all-branches=false").ok(t) | |||
| 40 | + | g = decode[map[string]any](t, run("config", "show", "--json").ok(t).stdout) | |||
| 41 | + | assert.Equal(t, false, g["enabled"]) | |||
| 42 | + | assert.Equal(t, false, g["all_branches"]) | |||
| 43 | + | assert.Equal(t, "*/5 * * * *", g["cron"], "omitted fields are not cleared") | |||
| 44 | + | run("add", "proj", "git@example.test:owner/proj.git", "--cron", "*/10 * * * *", "--all-branches=false").ok(t) | |||
| 45 | + | r = run("status", "proj", "--json").ok(t) | |||
| 46 | + | s := decode[map[string]any](t, r.stdout) | |||
| 47 | + | target := s["target"].(map[string]any) | |||
| 48 | + | assert.Equal(t, "MIRROR_SOURCE_OVERRIDE", target["source"]) | |||
| 49 | + | assert.Equal(t, false, target["all_branches"]) | |||
| 50 | + | assert.Contains(t, run("status").ok(t).stdout, "FAILURES") | |||
| 51 | + | assert.Len(t, decode[[]any](t, run("status", "--json").ok(t).stdout), 1) | |||
| 52 | + | run("remove", "proj").ok(t) | |||
| 53 | + | run("exclude", "proj").ok(t) | |||
| 54 | + | s = decode[map[string]any](t, run("status", "proj", "--json").ok(t).stdout) | |||
| 55 | + | assert.Equal(t, "MIRROR_SOURCE_EXCLUDED", s["target"].(map[string]any)["source"]) | |||
| 56 | + | run("include", "proj").ok(t) | |||
| 57 | + | first := run("key", "show").ok(t) | |||
| 58 | + | assert.Empty(t, first.stderr) | |||
| 59 | + | rotated := run("key", "rotate").ok(t) | |||
| 60 | + | assert.NotEqual(t, first.stdout, rotated.stdout) | |||
| 61 | + | assert.Contains(t, rotated.stderr, "remotes refuse pushes until this key is added") | |||
| 62 | + | assert.Equal(t, rotated.stdout, run("key", "show").ok(t).stdout) | |||
| 63 | + | assert.Equal(t, []string{"authorized_key", "created_at", "fingerprint"}, keys(t, run("key", "show", "--json").ok(t).stdout)) | |||
| 64 | + | } | |||
M internal/app/forge.go +34 -0
| @@ -26,6 +26,7 @@ import ( | |||
| 26 | 26 | "bnema.dev/ermite/internal/adapters/githttp" | |
| 27 | 27 | "bnema.dev/ermite/internal/adapters/githubapi" | |
| 28 | 28 | "bnema.dev/ermite/internal/adapters/githubraw" | |
| 29 | + | "bnema.dev/ermite/internal/adapters/gitpush" | |
| 29 | 30 | "bnema.dev/ermite/internal/adapters/gitremote" | |
| 30 | 31 | "bnema.dev/ermite/internal/adapters/httpserver" | |
| 31 | 32 | "bnema.dev/ermite/internal/adapters/kvstore" | |
| @@ -49,6 +50,9 @@ const maxReceiveBytes = githttp.DefaultMaxBodyBytes | |||
| 49 | 50 | ||
| 50 | 51 | // options are the parts of the wiring that tests replace. | |
| 51 | 52 | type options struct { | |
| 53 | + | // pusher and mirrors override mirror transport/settings in local tests only. | |
| 54 | + | pusher ports.MirrorPusher | |
| 55 | + | mirrors func(*usecase.MirrorsConfig) | |
| 52 | 56 | // hookExe is the executable the hook scripts exec; empty means this | |
| 53 | 57 | // process's own executable. | |
| 54 | 58 | hookExe string | |
| @@ -151,6 +155,8 @@ type forge struct { | |||
| 151 | 155 | CI *usecase.CI | |
| 152 | 156 | // Settings are the secrets, variables and environments of CI. | |
| 153 | 157 | Settings *usecase.CISettings | |
| 158 | + | // Mirrors exposes Warning and Badge for the hook server and web composition. | |
| 159 | + | Mirrors *usecase.Mirrors | |
| 154 | 160 | // OIDC is the provider of ID tokens, disabled without a secrets key. | |
| 155 | 161 | OIDC *usecase.OIDC | |
| 156 | 162 | API *rpc.API | |
| @@ -274,6 +280,26 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 274 | 280 | } | |
| 275 | 281 | sealer = box | |
| 276 | 282 | } | |
| 283 | + | pusher := opts.pusher | |
| 284 | + | if pusher == nil { | |
| 285 | + | pusher = gitpush.New(gitpush.Config{ | |
| 286 | + | Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"), | |
| 287 | + | KnownHosts: filepath.Join(cfg.DataDir, "mirror_known_hosts"), | |
| 288 | + | TempDir: filepath.Join(cfg.DataDir, "run", "mirror"), | |
| 289 | + | RepoDir: func(name domain.RepoName) string { return gitcli.RepoDir(cfg.DataDir, name) }, | |
| 290 | + | }) | |
| 291 | + | } | |
| 292 | + | mirrorCfg := usecase.MirrorsConfig{ | |
| 293 | + | Store: store, Keys: store, Sealer: sealer, Pusher: pusher, | |
| 294 | + | Meta: store, Git: git, Reader: reader, Bus: f.Bus, Clock: clock, PublicURL: cfg.PublicURL, | |
| 295 | + | } | |
| 296 | + | if opts.mirrors != nil { | |
| 297 | + | opts.mirrors(&mirrorCfg) | |
| 298 | + | } | |
| 299 | + | f.Mirrors = usecase.NewMirrors(mirrorCfg) | |
| 300 | + | f.Pushes.WithMirror(f.Mirrors) | |
| 301 | + | f.hooks.WithMirrorWarner(f.Mirrors) | |
| 302 | + | ||
| 277 | 303 | f.Settings, err = usecase.NewCISettings(ctx, store, store.Settings(), sealer, clock) | |
| 278 | 304 | if err != nil { | |
| 279 | 305 | return nil, fmt.Errorf("%s: %w", config.EnvSecretsKey, err) | |
| @@ -400,6 +426,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 400 | 426 | } | |
| 401 | 427 | f.API = rpc.New(rpc.Config{ | |
| 402 | 428 | Auth: f.Auth, | |
| 429 | + | Mirrors: f.Mirrors, | |
| 403 | 430 | Repos: f.Repos, | |
| 404 | 431 | Tokens: f.Tokens, | |
| 405 | 432 | Browse: f.Browse, | |
| @@ -462,6 +489,9 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 462 | 489 | if err != nil { | |
| 463 | 490 | return nil, fmt.Errorf("web: %w", err) | |
| 464 | 491 | } | |
| 492 | + | if s, ok := site.(*web.Server); ok { | |
| 493 | + | s.WithMirrorWarner(f.Mirrors) | |
| 494 | + | } | |
| 465 | 495 | routes = append(routes, webRoutes(site)...) | |
| 466 | 496 | f.server = httpserver.New(httpserver.Config{ | |
| 467 | 497 | Addr: cfg.Addr, | |
| @@ -489,6 +519,7 @@ func (f *forge) Run(ctx context.Context) error { | |||
| 489 | 519 | // outlives the HTTP server: requests still in flight while it drains are | |
| 490 | 520 | // pushes whose hooks need it. A failure of either stops both. | |
| 491 | 521 | func (f *forge) Serve(ctx context.Context, ln net.Listener) error { | |
| 522 | + | f.Mirrors.Start() | |
| 492 | 523 | ctx, cancel := context.WithCancel(ctx) | |
| 493 | 524 | defer cancel() | |
| 494 | 525 | hookCtx, stopHooks := context.WithCancel(context.WithoutCancel(ctx)) | |
| @@ -517,6 +548,9 @@ func (f *forge) Close(ctx context.Context) { | |||
| 517 | 548 | if f.MergeRequests != nil { | |
| 518 | 549 | f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability | |
| 519 | 550 | } | |
| 551 | + | if f.Mirrors != nil { | |
| 552 | + | f.Mirrors.Close(ctx) | |
| 553 | + | } | |
| 520 | 554 | if f.CI != nil { | |
| 521 | 555 | f.CI.Close(ctx) | |
| 522 | 556 | } | |
| @@ -26,6 +26,7 @@ import ( | |||||
| 26 | "bnema.dev/ermite/internal/adapters/githttp" | 26 | "bnema.dev/ermite/internal/adapters/githttp" | ||
| 27 | "bnema.dev/ermite/internal/adapters/githubapi" | 27 | "bnema.dev/ermite/internal/adapters/githubapi" | ||
| 28 | "bnema.dev/ermite/internal/adapters/githubraw" | 28 | "bnema.dev/ermite/internal/adapters/githubraw" | ||
| 29 | + | "bnema.dev/ermite/internal/adapters/gitpush" | |||
| 29 | "bnema.dev/ermite/internal/adapters/gitremote" | 30 | "bnema.dev/ermite/internal/adapters/gitremote" | ||
| 30 | "bnema.dev/ermite/internal/adapters/httpserver" | 31 | "bnema.dev/ermite/internal/adapters/httpserver" | ||
| 31 | "bnema.dev/ermite/internal/adapters/kvstore" | 32 | "bnema.dev/ermite/internal/adapters/kvstore" | ||
| @@ -49,6 +50,9 @@ const maxReceiveBytes = githttp.DefaultMaxBodyBytes | |||||
| 49 | 50 | ||||
| 50 | // options are the parts of the wiring that tests replace. | 51 | // options are the parts of the wiring that tests replace. | ||
| 51 | type options struct { | 52 | type options struct { | ||
| 53 | + | // pusher and mirrors override mirror transport/settings in local tests only. | |||
| 54 | + | pusher ports.MirrorPusher | |||
| 55 | + | mirrors func(*usecase.MirrorsConfig) | |||
| 52 | // hookExe is the executable the hook scripts exec; empty means this | 56 | // hookExe is the executable the hook scripts exec; empty means this | ||
| 53 | // process's own executable. | 57 | // process's own executable. | ||
| 54 | hookExe string | 58 | hookExe string | ||
| @@ -151,6 +155,8 @@ type forge struct { | |||||
| 151 | CI *usecase.CI | 155 | CI *usecase.CI | ||
| 152 | // Settings are the secrets, variables and environments of CI. | 156 | // Settings are the secrets, variables and environments of CI. | ||
| 153 | Settings *usecase.CISettings | 157 | Settings *usecase.CISettings | ||
| 158 | + | // Mirrors exposes Warning and Badge for the hook server and web composition. | |||
| 159 | + | Mirrors *usecase.Mirrors | |||
| 154 | // OIDC is the provider of ID tokens, disabled without a secrets key. | 160 | // OIDC is the provider of ID tokens, disabled without a secrets key. | ||
| 155 | OIDC *usecase.OIDC | 161 | OIDC *usecase.OIDC | ||
| 156 | API *rpc.API | 162 | API *rpc.API | ||
| @@ -274,6 +280,26 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 274 | } | 280 | } | ||
| 275 | sealer = box | 281 | sealer = box | ||
| 276 | } | 282 | } | ||
| 283 | + | pusher := opts.pusher | |||
| 284 | + | if pusher == nil { | |||
| 285 | + | pusher = gitpush.New(gitpush.Config{ | |||
| 286 | + | Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"), | |||
| 287 | + | KnownHosts: filepath.Join(cfg.DataDir, "mirror_known_hosts"), | |||
| 288 | + | TempDir: filepath.Join(cfg.DataDir, "run", "mirror"), | |||
| 289 | + | RepoDir: func(name domain.RepoName) string { return gitcli.RepoDir(cfg.DataDir, name) }, | |||
| 290 | + | }) | |||
| 291 | + | } | |||
| 292 | + | mirrorCfg := usecase.MirrorsConfig{ | |||
| 293 | + | Store: store, Keys: store, Sealer: sealer, Pusher: pusher, | |||
| 294 | + | Meta: store, Git: git, Reader: reader, Bus: f.Bus, Clock: clock, PublicURL: cfg.PublicURL, | |||
| 295 | + | } | |||
| 296 | + | if opts.mirrors != nil { | |||
| 297 | + | opts.mirrors(&mirrorCfg) | |||
| 298 | + | } | |||
| 299 | + | f.Mirrors = usecase.NewMirrors(mirrorCfg) | |||
| 300 | + | f.Pushes.WithMirror(f.Mirrors) | |||
| 301 | + | f.hooks.WithMirrorWarner(f.Mirrors) | |||
| 302 | + | ||||
| 277 | f.Settings, err = usecase.NewCISettings(ctx, store, store.Settings(), sealer, clock) | 303 | f.Settings, err = usecase.NewCISettings(ctx, store, store.Settings(), sealer, clock) | ||
| 278 | if err != nil { | 304 | if err != nil { | ||
| 279 | return nil, fmt.Errorf("%s: %w", config.EnvSecretsKey, err) | 305 | return nil, fmt.Errorf("%s: %w", config.EnvSecretsKey, err) | ||
| @@ -400,6 +426,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 400 | } | 426 | } | ||
| 401 | f.API = rpc.New(rpc.Config{ | 427 | f.API = rpc.New(rpc.Config{ | ||
| 402 | Auth: f.Auth, | 428 | Auth: f.Auth, | ||
| 429 | + | Mirrors: f.Mirrors, | |||
| 403 | Repos: f.Repos, | 430 | Repos: f.Repos, | ||
| 404 | Tokens: f.Tokens, | 431 | Tokens: f.Tokens, | ||
| 405 | Browse: f.Browse, | 432 | Browse: f.Browse, | ||
| @@ -462,6 +489,9 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 462 | if err != nil { | 489 | if err != nil { | ||
| 463 | return nil, fmt.Errorf("web: %w", err) | 490 | return nil, fmt.Errorf("web: %w", err) | ||
| 464 | } | 491 | } | ||
| 492 | + | if s, ok := site.(*web.Server); ok { | |||
| 493 | + | s.WithMirrorWarner(f.Mirrors) | |||
| 494 | + | } | |||
| 465 | routes = append(routes, webRoutes(site)...) | 495 | routes = append(routes, webRoutes(site)...) | ||
| 466 | f.server = httpserver.New(httpserver.Config{ | 496 | f.server = httpserver.New(httpserver.Config{ | ||
| 467 | Addr: cfg.Addr, | 497 | Addr: cfg.Addr, | ||
| @@ -489,6 +519,7 @@ func (f *forge) Run(ctx context.Context) error { | |||||
| 489 | // outlives the HTTP server: requests still in flight while it drains are | 519 | // outlives the HTTP server: requests still in flight while it drains are | ||
| 490 | // pushes whose hooks need it. A failure of either stops both. | 520 | // pushes whose hooks need it. A failure of either stops both. | ||
| 491 | func (f *forge) Serve(ctx context.Context, ln net.Listener) error { | 521 | func (f *forge) Serve(ctx context.Context, ln net.Listener) error { | ||
| 522 | + | f.Mirrors.Start() | |||
| 492 | ctx, cancel := context.WithCancel(ctx) | 523 | ctx, cancel := context.WithCancel(ctx) | ||
| 493 | defer cancel() | 524 | defer cancel() | ||
| 494 | hookCtx, stopHooks := context.WithCancel(context.WithoutCancel(ctx)) | 525 | hookCtx, stopHooks := context.WithCancel(context.WithoutCancel(ctx)) | ||
| @@ -517,6 +548,9 @@ func (f *forge) Close(ctx context.Context) { | |||||
| 517 | if f.MergeRequests != nil { | 548 | if f.MergeRequests != nil { | ||
| 518 | f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability | 549 | f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability | ||
| 519 | } | 550 | } | ||
| 551 | + | if f.Mirrors != nil { | |||
| 552 | + | f.Mirrors.Close(ctx) | |||
| 553 | + | } | |||
| 520 | if f.CI != nil { | 554 | if f.CI != nil { | ||
| 521 | f.CI.Close(ctx) | 555 | f.CI.Close(ctx) | ||
| 522 | } | 556 | } | ||
M internal/app/root.go +1 -1
| @@ -30,7 +30,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command { | |||
| 30 | 30 | } | |
| 31 | 31 | root.AddCommand( | |
| 32 | 32 | newServeCmd(), newHookCmd(), | |
| 33 | - | newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | |
| 33 | + | newMirrorCmd(env), newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | |
| 34 | 34 | newMRCmd(env), newIssueCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env), | |
| 35 | 35 | newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env), | |
| 36 | 36 | ) | |
| @@ -30,7 +30,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command { | |||||
| 30 | } | 30 | } | ||
| 31 | root.AddCommand( | 31 | root.AddCommand( | ||
| 32 | newServeCmd(), newHookCmd(), | 32 | newServeCmd(), newHookCmd(), | ||
| 33 | - | newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | 33 | + | newMirrorCmd(env), newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), |
| 34 | newMRCmd(env), newIssueCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env), | 34 | newMRCmd(env), newIssueCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env), | ||
| 35 | newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env), | 35 | newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env), | ||
| 36 | ) | 36 | ) | ||