detached bf863eb

Merge pull request #178 from bnema/refactor/rename-rollback-to-pin

refactor(cli): rename rollback command to pin
commit
bf863ebb4c1ed94f9d0515e6a829b6a8bcec2555
author
bnema · · 2026-04-28 03:31 UTC
committer
GitHub · · 2026-04-28 03:31 UTC
merge of
062dcae + c50d21b · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
10 files · +133 -123
M README.md +2 -2
@@ -46,7 +46,7 @@ gordon status
4646 Roll back, restart, or manage secrets — all from the command line:
4747
4848 ```bash
49-gordon rollback app.example.com # Revert to a previous image tag
49+gordon pin app.example.com # Pin to a specific image tag
5050 gordon restart app.example.com # Restart the container
5151 gordon secrets set app.example.com DB_HOST=db.internal API_KEY=secret123
5252 ```
@@ -95,7 +95,7 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf
9595 | `gordon routes add <domain> <image>` | Create or update a route |
9696 | `gordon routes remove <domain>` | Remove a route |
9797 | `gordon routes deploy <domain>` | Redeploy a route |
98-| `gordon rollback <domain>` | Roll back to a previous image |
98+| `gordon pin <domain>` | Pin a route to a specific image tag |
9999 | `gordon restart <domain>` | Restart a route container |
100100
101101 ### Images & Registry
@@ -46,7 +46,7 @@ gordon status
46 Roll back, restart, or manage secrets — all from the command line:46 Roll back, restart, or manage secrets — all from the command line:
47 47
48 ```bash48 ```bash
49-gordon rollback app.example.com # Revert to a previous image tag49+gordon pin app.example.com # Pin to a specific image tag
50 gordon restart app.example.com # Restart the container50 gordon restart app.example.com # Restart the container
51 gordon secrets set app.example.com DB_HOST=db.internal API_KEY=secret12351 gordon secrets set app.example.com DB_HOST=db.internal API_KEY=secret123
52 ```52 ```
@@ -95,7 +95,7 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf
95 | `gordon routes add <domain> <image>` | Create or update a route |95 | `gordon routes add <domain> <image>` | Create or update a route |
96 | `gordon routes remove <domain>` | Remove a route |96 | `gordon routes remove <domain>` | Remove a route |
97 | `gordon routes deploy <domain>` | Redeploy a route |97 | `gordon routes deploy <domain>` | Redeploy a route |
98-| `gordon rollback <domain>` | Roll back to a previous image |98+| `gordon pin <domain>` | Pin a route to a specific image tag |
99 | `gordon restart <domain>` | Restart a route container |99 | `gordon restart <domain>` | Restart a route container |
100 100
101 ### Images & Registry101 ### Images & Registry
M docs/cli/index.md +3 -3
@@ -38,7 +38,7 @@ Management commands run locally through in-process services by default. Add `--r
3838 | `gordon attachments push` | Build/push attachment images to registry | [attachments](./attachments.md) |
3939 | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) |
4040 | `gordon restart` | Restart a running container | [restart](./restart.md) |
41-| `gordon rollback` | Roll back to a previous image version | [rollback](./rollback.md) |
41+| `gordon pin` | Pin a route to a specific image tag | [pin](./pin.md) |
4242 | `gordon routes` | Manage routes | [routes](./routes.md) |
4343 | `gordon secrets` | Manage secrets | [secrets](./secrets.md) |
4444 | `gordon status` | Show Gordon server status | [status](./status.md) |
@@ -80,8 +80,8 @@ gordon push myapp --build
8080 # Push and deploy without confirmation
8181 gordon push myapp --no-confirm
8282
83-# Roll back to a previous tag
84-gordon rollback myapp.example.com
83+# Pin to a specific tag
84+gordon pin myapp.example.com
8585
8686 # View logs
8787 gordon logs # Gordon process logs
@@ -38,7 +38,7 @@ Management commands run locally through in-process services by default. Add `--r
38 | `gordon attachments push` | Build/push attachment images to registry | [attachments](./attachments.md) |38 | `gordon attachments push` | Build/push attachment images to registry | [attachments](./attachments.md) |
39 | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) |39 | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) |
40 | `gordon restart` | Restart a running container | [restart](./restart.md) |40 | `gordon restart` | Restart a running container | [restart](./restart.md) |
41-| `gordon rollback` | Roll back to a previous image version | [rollback](./rollback.md) |41+| `gordon pin` | Pin a route to a specific image tag | [pin](./pin.md) |
42 | `gordon routes` | Manage routes | [routes](./routes.md) |42 | `gordon routes` | Manage routes | [routes](./routes.md) |
43 | `gordon secrets` | Manage secrets | [secrets](./secrets.md) |43 | `gordon secrets` | Manage secrets | [secrets](./secrets.md) |
44 | `gordon status` | Show Gordon server status | [status](./status.md) |44 | `gordon status` | Show Gordon server status | [status](./status.md) |
@@ -80,8 +80,8 @@ gordon push myapp --build
80 # Push and deploy without confirmation80 # Push and deploy without confirmation
81 gordon push myapp --no-confirm81 gordon push myapp --no-confirm
82 82
83-# Roll back to a previous tag83+# Pin to a specific tag
84-gordon rollback myapp.example.com84+gordon pin myapp.example.com
85 85
86 # View logs86 # View logs
87 gordon logs # Gordon process logs87 gordon logs # Gordon process logs
A docs/cli/pin.md +72 -0
@@ -0,0 +1,72 @@
1+# Pin Command
2+
3+Pin a route to a specific image tag.
4+
5+## gordon pin
6+
7+### Synopsis
8+
9+```bash
10+gordon pin <domain> [options]
11+gordon pin list <domain>
12+```
13+
14+### Arguments
15+
16+| Argument | Description |
17+|----------|-------------|
18+| `<domain>` | The route domain to pin |
19+
20+### Options
21+
22+| Option | Description |
23+|--------|-------------|
24+| `--tag` | Target tag (skips interactive selection) |
25+| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
26+| `--token` | Authentication token for remote |
27+| `--json` | Output as JSON (for `pin list`) |
28+
29+### Description
30+
31+`gordon pin` deploys a selected image tag for the given route. Tags are
32+listed from the Gordon registry, with semver tags sorted in descending
33+order first, followed by non-semver tags.
34+
35+Use cases:
36+- **Rollback** — revert to a previous stable version
37+- **Forward** — advance to a newer version
38+- **Lock** — pin to a specific version instead of following `latest`
39+- **Test** — switch to an experimental tag
40+
41+`gordon pin list <domain>` lists available tags without deploying and
42+marks the current tag.
43+
44+If the selected tag matches the current running tag, no action is taken.
45+
46+### Examples
47+
48+```bash
49+# Interactive selection
50+gordon pin myapp.example.com
51+
52+# Pin to a specific tag
53+gordon pin myapp.example.com --tag v1.2.0
54+
55+# List available tags for a domain
56+gordon pin list myapp.example.com
57+
58+# List available tags as JSON
59+gordon pin list myapp.example.com --json
60+```
61+
62+### Notes
63+
64+- Local by default; remote mode optional. See [CLI Overview](./index.md) for targeting options.
65+- Tags are read from the Gordon registry configured on the server.
66+
67+## Related
68+
69+- [CLI Overview](./index.md)
70+- [Push Command](./push.md)
71+- [Routes Command](./routes.md)
72+- [Deployment Rollback Strategies](../deployment/rollback.md)
@@ -0,0 +1,72 @@
1+# Pin Command
2+
3+Pin a route to a specific image tag.
4+
5+## gordon pin
6+
7+### Synopsis
8+
9+```bash
10+gordon pin <domain> [options]
11+gordon pin list <domain>
12+```
13+
14+### Arguments
15+
16+| Argument | Description |
17+|----------|-------------|
18+| `<domain>` | The route domain to pin |
19+
20+### Options
21+
22+| Option | Description |
23+|--------|-------------|
24+| `--tag` | Target tag (skips interactive selection) |
25+| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
26+| `--token` | Authentication token for remote |
27+| `--json` | Output as JSON (for `pin list`) |
28+
29+### Description
30+
31+`gordon pin` deploys a selected image tag for the given route. Tags are
32+listed from the Gordon registry, with semver tags sorted in descending
33+order first, followed by non-semver tags.
34+
35+Use cases:
36+- **Rollback** — revert to a previous stable version
37+- **Forward** — advance to a newer version
38+- **Lock** — pin to a specific version instead of following `latest`
39+- **Test** — switch to an experimental tag
40+
41+`gordon pin list <domain>` lists available tags without deploying and
42+marks the current tag.
43+
44+If the selected tag matches the current running tag, no action is taken.
45+
46+### Examples
47+
48+```bash
49+# Interactive selection
50+gordon pin myapp.example.com
51+
52+# Pin to a specific tag
53+gordon pin myapp.example.com --tag v1.2.0
54+
55+# List available tags for a domain
56+gordon pin list myapp.example.com
57+
58+# List available tags as JSON
59+gordon pin list myapp.example.com --json
60+```
61+
62+### Notes
63+
64+- Local by default; remote mode optional. See [CLI Overview](./index.md) for targeting options.
65+- Tags are read from the Gordon registry configured on the server.
66+
67+## Related
68+
69+- [CLI Overview](./index.md)
70+- [Push Command](./push.md)
71+- [Routes Command](./routes.md)
72+- [Deployment Rollback Strategies](../deployment/rollback.md)
D docs/cli/rollback.md +0 -60
@@ -1,60 +0,0 @@
1-# Rollback Command
2-
3-Roll back a route to a previous image tag.
4-
5-## gordon rollback
6-
7-### Synopsis
8-
9-```bash
10-gordon rollback <domain> [options]
11-gordon rollback list <domain>
12-```
13-
14-### Arguments
15-
16-| Argument | Description |
17-|----------|-------------|
18-| `<domain>` | The route domain to roll back |
19-
20-### Options
21-
22-| Option | Description |
23-|--------|-------------|
24-| `--tag` | Target tag (skips interactive selection) |
25-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
26-| `--token` | Authentication token for remote |
27-
28-### Description
29-
30-`gordon rollback` deploys a selected tag for the route's repository.
31-Semver tags are sorted in descending order first, followed by non-semver tags.
32-
33-`gordon rollback list <domain>` only lists available tags and marks the current
34-tag without deploying.
35-
36-If the selected tag matches the current running tag, no action is taken.
37-
38-### Examples
39-
40-```bash
41-# Interactive selection
42-gordon rollback myapp.example.com
43-
44-# Roll back to a specific tag
45-gordon rollback myapp.example.com --tag v1.2.0
46-
47-# List available tags for a domain
48-gordon rollback list myapp.example.com
49-```
50-
51-### Notes
52-
53-- Remote mode required. See [CLI Overview](./index.md) for targeting options.
54-- Tags are read from the Gordon registry configured on the server.
55-
56-## Related
57-
58-- [CLI Overview](./index.md)
59-- [Push Command](./push.md)
60-- [Routes Command](./routes.md)
@@ -1,60 +0,0 @@
1-# Rollback Command
2-
3-Roll back a route to a previous image tag.
4-
5-## gordon rollback
6-
7-### Synopsis
8-
9-```bash
10-gordon rollback <domain> [options]
11-gordon rollback list <domain>
12-```
13-
14-### Arguments
15-
16-| Argument | Description |
17-|----------|-------------|
18-| `<domain>` | The route domain to roll back |
19-
20-### Options
21-
22-| Option | Description |
23-|--------|-------------|
24-| `--tag` | Target tag (skips interactive selection) |
25-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
26-| `--token` | Authentication token for remote |
27-
28-### Description
29-
30-`gordon rollback` deploys a selected tag for the route's repository.
31-Semver tags are sorted in descending order first, followed by non-semver tags.
32-
33-`gordon rollback list <domain>` only lists available tags and marks the current
34-tag without deploying.
35-
36-If the selected tag matches the current running tag, no action is taken.
37-
38-### Examples
39-
40-```bash
41-# Interactive selection
42-gordon rollback myapp.example.com
43-
44-# Roll back to a specific tag
45-gordon rollback myapp.example.com --tag v1.2.0
46-
47-# List available tags for a domain
48-gordon rollback list myapp.example.com
49-```
50-
51-### Notes
52-
53-- Remote mode required. See [CLI Overview](./index.md) for targeting options.
54-- Tags are read from the Gordon registry configured on the server.
55-
56-## Related
57-
58-- [CLI Overview](./index.md)
59-- [Push Command](./push.md)
60-- [Routes Command](./routes.md)
M internal/adapters/in/cli/json_parity_test.go +1 -1
@@ -20,7 +20,7 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
2020 {"backup list", newBackupListCmd},
2121 {"auth token list", newTokenListCmd},
2222 {"remotes list", newRemotesListCmd},
23- {"rollback list", newRollbackListCmd},
23+ {"pin list", newPinListCmd},
2424 {"config show", newConfigShowCmd},
2525 {"routes show", newRoutesShowCmd},
2626 {"networks list", newNetworksListCmd},
@@ -20,7 +20,7 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
20 {"backup list", newBackupListCmd},20 {"backup list", newBackupListCmd},
21 {"auth token list", newTokenListCmd},21 {"auth token list", newTokenListCmd},
22 {"remotes list", newRemotesListCmd},22 {"remotes list", newRemotesListCmd},
23- {"rollback list", newRollbackListCmd},23+ {"pin list", newPinListCmd},
24 {"config show", newConfigShowCmd},24 {"config show", newConfigShowCmd},
25 {"routes show", newRoutesShowCmd},25 {"routes show", newRoutesShowCmd},
26 {"networks list", newNetworksListCmd},26 {"networks list", newNetworksListCmd},
M internal/adapters/in/cli/json_test.go +3 -3
@@ -77,8 +77,8 @@ func TestRemotesList_JSONFlag_Accepted(t *testing.T) {
7777 }
7878 }
7979
80-func TestRollbackList_JSONFlag_Accepted(t *testing.T) {
81- cmd := newRollbackListCmd()
80+func TestPinList_JSONFlag_Accepted(t *testing.T) {
81+ cmd := newPinListCmd()
8282 f := cmd.Flags().Lookup("json")
8383 assert.NotNil(t, f)
8484 if f != nil {
@@ -101,7 +101,7 @@ func TestImagesList_JSONShape_RoundTripsDTO(t *testing.T) {
101101 assert.Equal(t, images[0].ID, got[0].ID)
102102 }
103103
104-func TestRollbackList_JSONShape_RoundTripsTags(t *testing.T) {
104+func TestPinList_JSONShape_RoundTripsTags(t *testing.T) {
105105 tags := []string{"v1.2.0", "v1.1.0"}
106106 payload, err := json.Marshal(tags)
107107 require.NoError(t, err)
@@ -77,8 +77,8 @@ func TestRemotesList_JSONFlag_Accepted(t *testing.T) {
77 }77 }
78 }78 }
79 79
80-func TestRollbackList_JSONFlag_Accepted(t *testing.T) {80+func TestPinList_JSONFlag_Accepted(t *testing.T) {
81- cmd := newRollbackListCmd()81+ cmd := newPinListCmd()
82 f := cmd.Flags().Lookup("json")82 f := cmd.Flags().Lookup("json")
83 assert.NotNil(t, f)83 assert.NotNil(t, f)
84 if f != nil {84 if f != nil {
@@ -101,7 +101,7 @@ func TestImagesList_JSONShape_RoundTripsDTO(t *testing.T) {
101 assert.Equal(t, images[0].ID, got[0].ID)101 assert.Equal(t, images[0].ID, got[0].ID)
102 }102 }
103 103
104-func TestRollbackList_JSONShape_RoundTripsTags(t *testing.T) {104+func TestPinList_JSONShape_RoundTripsTags(t *testing.T) {
105 tags := []string{"v1.2.0", "v1.1.0"}105 tags := []string{"v1.2.0", "v1.1.0"}
106 payload, err := json.Marshal(tags)106 payload, err := json.Marshal(tags)
107 require.NoError(t, err)107 require.NoError(t, err)
M internal/adapters/in/cli/parity_matrix_test.go +2 -2
@@ -40,7 +40,8 @@ func TestLocalParityMatrix(t *testing.T) {
4040 command string
4141 }{
4242 {file: "push.go", legacyText: "push requires remote mode", command: "gordon push"},
43- {file: "rollback.go", legacyText: "rollback requires remote mode", command: "gordon rollback"},
43+ // Guards against reintroducing the old rollback-only remote-mode error text during the command rename.
44+ {file: "pin.go", legacyText: "rollback requires remote mode", command: "gordon pin"},
4445 {file: "backup.go", legacyText: "backup commands require a configured remote target", command: "gordon backups"},
4546 {file: "routes.go", legacyText: "status command requires --remote flag or GORDON_REMOTE env var", command: "gordon status"},
4647 {file: "restart.go", legacyText: "local restart does not support --with-attachments; use --remote", command: "gordon restart --with-attachments"},
@@ -59,7 +60,6 @@ func TestLocalParityMatrix(t *testing.T) {
5960
6061 func TestUIAdoptionMatrixCoverage(t *testing.T) {
6162 for _, expect := range uiAdoptionExpectations {
62- expect := expect
6363 t.Run(expect.family, func(t *testing.T) {
6464 if len(expect.functions) == 0 {
6565 t.Fatalf("ui adoption matrix for %s is empty", expect.family)
@@ -40,7 +40,8 @@ func TestLocalParityMatrix(t *testing.T) {
40 command string40 command string
41 }{41 }{
42 {file: "push.go", legacyText: "push requires remote mode", command: "gordon push"},42 {file: "push.go", legacyText: "push requires remote mode", command: "gordon push"},
43- {file: "rollback.go", legacyText: "rollback requires remote mode", command: "gordon rollback"},43+ // Guards against reintroducing the old rollback-only remote-mode error text during the command rename.
44+ {file: "pin.go", legacyText: "rollback requires remote mode", command: "gordon pin"},
44 {file: "backup.go", legacyText: "backup commands require a configured remote target", command: "gordon backups"},45 {file: "backup.go", legacyText: "backup commands require a configured remote target", command: "gordon backups"},
45 {file: "routes.go", legacyText: "status command requires --remote flag or GORDON_REMOTE env var", command: "gordon status"},46 {file: "routes.go", legacyText: "status command requires --remote flag or GORDON_REMOTE env var", command: "gordon status"},
46 {file: "restart.go", legacyText: "local restart does not support --with-attachments; use --remote", command: "gordon restart --with-attachments"},47 {file: "restart.go", legacyText: "local restart does not support --with-attachments; use --remote", command: "gordon restart --with-attachments"},
@@ -59,7 +60,6 @@ func TestLocalParityMatrix(t *testing.T) {
59 60
60 func TestUIAdoptionMatrixCoverage(t *testing.T) {61 func TestUIAdoptionMatrixCoverage(t *testing.T) {
61 for _, expect := range uiAdoptionExpectations {62 for _, expect := range uiAdoptionExpectations {
62- expect := expect
63 t.Run(expect.family, func(t *testing.T) {63 t.Run(expect.family, func(t *testing.T) {
64 if len(expect.functions) == 0 {64 if len(expect.functions) == 0 {
65 t.Fatalf("ui adoption matrix for %s is empty", expect.family)65 t.Fatalf("ui adoption matrix for %s is empty", expect.family)
R internal/adapters/in/cli/rollback.go → internal/adapters/in/cli/pin.go +43 -45 renamed, 66% similar
@@ -4,7 +4,7 @@ import (
44 "context"
55 "fmt"
66 "io"
7- "os"
7+ "slices"
88 "sort"
99 "strings"
1010
@@ -16,40 +16,40 @@ import (
1616 "github.com/bnema/gordon/internal/domain"
1717 )
1818
19-func newRollbackCmd() *cobra.Command {
19+func newPinCmd() *cobra.Command {
2020 var targetTag string
2121
2222 cmd := &cobra.Command{
23- Use: "rollback <domain>",
24- Short: "Roll back to a previous image version",
23+ Use: "pin <domain>",
24+ Short: "Pin a route to a specific image tag",
2525 Long: `Lists available image tags for a domain and deploys the selected version.
2626 Tags are read from the Gordon registry.
2727
2828 Examples:
29- gordon rollback myapp.example.com --remote ...
30- gordon rollback myapp.example.com --tag v1.0.0 --remote ...
31- gordon rollback list myapp.example.com --remote ...`,
29+ gordon pin myapp.example.com --remote ...
30+ gordon pin myapp.example.com --tag v1.0.0 --remote ...
31+ gordon pin list myapp.example.com --remote ...`,
3232 Args: cobra.ExactArgs(1),
3333 RunE: func(cmd *cobra.Command, args []string) error {
34- return runRollback(cmd.Context(), args[0], targetTag)
34+ return runPin(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), args[0], targetTag)
3535 },
3636 }
3737
3838 cmd.Flags().StringVar(&targetTag, "tag", "", "Target tag (skips interactive selection)")
39- cmd.AddCommand(newRollbackListCmd())
39+ cmd.AddCommand(newPinListCmd())
4040
4141 return cmd
4242 }
4343
44-func newRollbackListCmd() *cobra.Command {
44+func newPinListCmd() *cobra.Command {
4545 var jsonOut bool
4646
4747 cmd := &cobra.Command{
4848 Use: "list <domain>",
49- Short: "List available rollback tags",
49+ Short: "List available tags",
5050 Args: cobra.ExactArgs(1),
5151 RunE: func(cmd *cobra.Command, args []string) error {
52- return runRollbackList(cmd.Context(), cmd.OutOrStdout(), args[0], jsonOut)
52+ return runPinList(cmd.Context(), cmd.OutOrStdout(), args[0], jsonOut)
5353 },
5454 }
5555
@@ -58,14 +58,14 @@ func newRollbackListCmd() *cobra.Command {
5858 return cmd
5959 }
6060
61-func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
61+func runPin(ctx context.Context, out, ew io.Writer, pinDomain, targetTag string) error {
6262 handle, err := resolveControlPlane(configPath)
6363 if err != nil {
64- return err
64+ return fmt.Errorf("failed to resolve control plane: %w", err)
6565 }
6666 defer handle.close()
6767
68- route, err := handle.plane.GetRoute(ctx, rollbackDomain)
68+ route, err := handle.plane.GetRoute(ctx, pinDomain)
6969 if err != nil {
7070 return fmt.Errorf("failed to get route: %w", err)
7171 }
@@ -77,30 +77,32 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
7777
7878 tags, err := fetchAndSortTags(ctx, handle.plane, imageName)
7979 if err != nil {
80- return err
80+ return fmt.Errorf("failed to fetch tags: %w", err)
8181 }
8282
83- selectedTag, err := selectTag(targetTag, tags, currentTag, rollbackDomain)
84- if err != nil || selectedTag == "" {
85- return err
83+ selectedTag, err := selectTag(targetTag, tags, currentTag, pinDomain, out)
84+ if err != nil {
85+ return fmt.Errorf("failed to select tag: %w", err)
86+ }
87+ if selectedTag == "" {
88+ return nil
8689 }
8790
8891 if selectedTag == currentTag {
89- fmt.Println(styles.RenderWarning(fmt.Sprintf("Already running %s", selectedTag)))
90- return nil
92+ return cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Already running %s", selectedTag)))
9193 }
9294
93- return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag)
95+ return deploySelectedTag(ctx, handle.plane, route, out, ew, pinDomain, imageName, selectedTag)
9496 }
9597
96-func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string, jsonOut bool) error {
98+func runPinList(ctx context.Context, w io.Writer, pinDomain string, jsonOut bool) error {
9799 handle, err := resolveControlPlane(configPath)
98100 if err != nil {
99- return err
101+ return fmt.Errorf("failed to resolve control plane: %w", err)
100102 }
101103 defer handle.close()
102104
103- route, err := handle.plane.GetRoute(ctx, rollbackDomain)
105+ route, err := handle.plane.GetRoute(ctx, pinDomain)
104106 if err != nil {
105107 return fmt.Errorf("failed to get route: %w", err)
106108 }
@@ -112,22 +114,22 @@ func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string, js
112114
113115 tags, err := fetchAndSortTags(ctx, handle.plane, imageName)
114116 if err != nil {
115- return err
117+ return fmt.Errorf("failed to fetch tags: %w", err)
116118 }
117119
118- return printRollbackTags(w, rollbackDomain, currentTag, tags, jsonOut)
120+ return printPinTags(w, pinDomain, currentTag, tags, jsonOut)
119121 }
120122
121-func printRollbackTags(w io.Writer, rollbackDomain, currentTag string, tags []string, jsonOut bool) error {
123+func printPinTags(w io.Writer, pinDomain, currentTag string, tags []string, jsonOut bool) error {
122124 if jsonOut {
123125 return writeJSON(w, map[string]any{
124- "domain": rollbackDomain,
126+ "domain": pinDomain,
125127 "current_tag": currentTag,
126128 "tags": tags,
127129 })
128130 }
129131
130- if _, err := fmt.Fprintf(w, "Available tags for %s:\n", styles.Theme.Bold.Render(rollbackDomain)); err != nil {
132+ if _, err := fmt.Fprintf(w, "Available tags for %s:\n", styles.Theme.Bold.Render(pinDomain)); err != nil {
131133 return err
132134 }
133135 for _, tag := range tags {
@@ -192,7 +194,7 @@ func sortSemverTags(tags []string) []string {
192194 return append(semverTags, otherTags...)
193195 }
194196
195-func selectTag(targetTag string, tags []string, currentTag, rollbackDomain string) (string, error) {
197+func selectTag(targetTag string, tags []string, currentTag, pinDomain string, out io.Writer) (string, error) {
196198 if targetTag != "" {
197199 if !validateTagExists(targetTag, tags) {
198200 return "", fmt.Errorf("tag %s not found. Available: %s", targetTag, strings.Join(tags, ", "))
@@ -201,7 +203,7 @@ func selectTag(targetTag string, tags []string, currentTag, rollbackDomain strin
201203 }
202204
203205 selectedTag, err := components.RunSelector(
204- fmt.Sprintf("Select version for %s:", rollbackDomain),
206+ fmt.Sprintf("Select version for %s:", pinDomain),
205207 tags,
206208 currentTag,
207209 )
@@ -209,42 +211,38 @@ func selectTag(targetTag string, tags []string, currentTag, rollbackDomain strin
209211 return "", err
210212 }
211213 if selectedTag == "" {
212- fmt.Println("Cancelled.")
214+ _ = cliWriteLine(out, "Cancelled.")
213215 }
214216 return selectedTag, nil
215217 }
216218
217219 func validateTagExists(targetTag string, tags []string) bool {
218- for _, t := range tags {
219- if t == targetTag {
220- return true
221- }
222- }
223- return false
220+ return slices.Contains(tags, targetTag)
224221 }
225222
226-func deploySelectedTag(ctx context.Context, cp ControlPlane, route *domain.Route, rollbackDomain, imageName, selectedTag string) error {
223+func deploySelectedTag(ctx context.Context, cp ControlPlane, route *domain.Route, out, ew io.Writer, pinDomain, imageName, selectedTag string) error {
227224 registry, _, _ := parseImageRef(route.Image)
228225 oldImage := route.Image
229226 route.Image = fmt.Sprintf("%s/%s:%s", registry, imageName, selectedTag)
230227
231- fmt.Printf("Rolling back to %s...\n", styles.Theme.Bold.Render(selectedTag))
228+ if err := cliWritef(out, "Pinning to %s...\n", styles.Theme.Bold.Render(selectedTag)); err != nil {
229+ return err
230+ }
232231
233232 if err := cp.UpdateRoute(ctx, *route); err != nil {
234233 return fmt.Errorf("failed to update route: %w", err)
235234 }
236235
237- result, err := cp.Deploy(ctx, rollbackDomain)
236+ result, err := cp.Deploy(ctx, pinDomain)
238237 if err != nil {
239238 // Attempt to revert route to previous image
240239 route.Image = oldImage
241240 if revertErr := cp.UpdateRoute(ctx, *route); revertErr != nil {
242- fmt.Fprintf(os.Stderr, "WARNING: deploy failed and could not revert route: %v\n", revertErr)
241+ _ = cliWritef(ew, "WARNING: deploy failed and could not revert route: %v\n", revertErr)
243242 return fmt.Errorf("failed to deploy; revert failed: %v; deploy error: %w", revertErr, err)
244243 }
245244 return fmt.Errorf("failed to deploy (route reverted): %w", err)
246245 }
247246 containerID := shortContainerID(result.ContainerID)
248- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Rolled back %s to %s (container: %s)", rollbackDomain, selectedTag, containerID)))
249- return nil
247+ return cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Pinned %s to %s (container: %s)", pinDomain, selectedTag, containerID)))
250248 }
@@ -4,7 +4,7 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 "io"6 "io"
7- "os"7+ "slices"
8 "sort"8 "sort"
9 "strings"9 "strings"
10 10
@@ -16,40 +16,40 @@ import (
16 "github.com/bnema/gordon/internal/domain"16 "github.com/bnema/gordon/internal/domain"
17 )17 )
18 18
19-func newRollbackCmd() *cobra.Command {19+func newPinCmd() *cobra.Command {
20 var targetTag string20 var targetTag string
21 21
22 cmd := &cobra.Command{22 cmd := &cobra.Command{
23- Use: "rollback <domain>",23+ Use: "pin <domain>",
24- Short: "Roll back to a previous image version",24+ Short: "Pin a route to a specific image tag",
25 Long: `Lists available image tags for a domain and deploys the selected version.25 Long: `Lists available image tags for a domain and deploys the selected version.
26 Tags are read from the Gordon registry.26 Tags are read from the Gordon registry.
27 27
28 Examples:28 Examples:
29- gordon rollback myapp.example.com --remote ...29+ gordon pin myapp.example.com --remote ...
30- gordon rollback myapp.example.com --tag v1.0.0 --remote ...30+ gordon pin myapp.example.com --tag v1.0.0 --remote ...
31- gordon rollback list myapp.example.com --remote ...`,31+ gordon pin list myapp.example.com --remote ...`,
32 Args: cobra.ExactArgs(1),32 Args: cobra.ExactArgs(1),
33 RunE: func(cmd *cobra.Command, args []string) error {33 RunE: func(cmd *cobra.Command, args []string) error {
34- return runRollback(cmd.Context(), args[0], targetTag)34+ return runPin(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), args[0], targetTag)
35 },35 },
36 }36 }
37 37
38 cmd.Flags().StringVar(&targetTag, "tag", "", "Target tag (skips interactive selection)")38 cmd.Flags().StringVar(&targetTag, "tag", "", "Target tag (skips interactive selection)")
39- cmd.AddCommand(newRollbackListCmd())39+ cmd.AddCommand(newPinListCmd())
40 40
41 return cmd41 return cmd
42 }42 }
43 43
44-func newRollbackListCmd() *cobra.Command {44+func newPinListCmd() *cobra.Command {
45 var jsonOut bool45 var jsonOut bool
46 46
47 cmd := &cobra.Command{47 cmd := &cobra.Command{
48 Use: "list <domain>",48 Use: "list <domain>",
49- Short: "List available rollback tags",49+ Short: "List available tags",
50 Args: cobra.ExactArgs(1),50 Args: cobra.ExactArgs(1),
51 RunE: func(cmd *cobra.Command, args []string) error {51 RunE: func(cmd *cobra.Command, args []string) error {
52- return runRollbackList(cmd.Context(), cmd.OutOrStdout(), args[0], jsonOut)52+ return runPinList(cmd.Context(), cmd.OutOrStdout(), args[0], jsonOut)
53 },53 },
54 }54 }
55 55
@@ -58,14 +58,14 @@ func newRollbackListCmd() *cobra.Command {
58 return cmd58 return cmd
59 }59 }
60 60
61-func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {61+func runPin(ctx context.Context, out, ew io.Writer, pinDomain, targetTag string) error {
62 handle, err := resolveControlPlane(configPath)62 handle, err := resolveControlPlane(configPath)
63 if err != nil {63 if err != nil {
64- return err64+ return fmt.Errorf("failed to resolve control plane: %w", err)
65 }65 }
66 defer handle.close()66 defer handle.close()
67 67
68- route, err := handle.plane.GetRoute(ctx, rollbackDomain)68+ route, err := handle.plane.GetRoute(ctx, pinDomain)
69 if err != nil {69 if err != nil {
70 return fmt.Errorf("failed to get route: %w", err)70 return fmt.Errorf("failed to get route: %w", err)
71 }71 }
@@ -77,30 +77,32 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
77 77
78 tags, err := fetchAndSortTags(ctx, handle.plane, imageName)78 tags, err := fetchAndSortTags(ctx, handle.plane, imageName)
79 if err != nil {79 if err != nil {
80- return err80+ return fmt.Errorf("failed to fetch tags: %w", err)
81 }81 }
82 82
83- selectedTag, err := selectTag(targetTag, tags, currentTag, rollbackDomain)83+ selectedTag, err := selectTag(targetTag, tags, currentTag, pinDomain, out)
84- if err != nil || selectedTag == "" {84+ if err != nil {
85- return err85+ return fmt.Errorf("failed to select tag: %w", err)
86+ }
87+ if selectedTag == "" {
88+ return nil
86 }89 }
87 90
88 if selectedTag == currentTag {91 if selectedTag == currentTag {
89- fmt.Println(styles.RenderWarning(fmt.Sprintf("Already running %s", selectedTag)))92+ return cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Already running %s", selectedTag)))
90- return nil
91 }93 }
92 94
93- return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag)95+ return deploySelectedTag(ctx, handle.plane, route, out, ew, pinDomain, imageName, selectedTag)
94 }96 }
95 97
96-func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string, jsonOut bool) error {98+func runPinList(ctx context.Context, w io.Writer, pinDomain string, jsonOut bool) error {
97 handle, err := resolveControlPlane(configPath)99 handle, err := resolveControlPlane(configPath)
98 if err != nil {100 if err != nil {
99- return err101+ return fmt.Errorf("failed to resolve control plane: %w", err)
100 }102 }
101 defer handle.close()103 defer handle.close()
102 104
103- route, err := handle.plane.GetRoute(ctx, rollbackDomain)105+ route, err := handle.plane.GetRoute(ctx, pinDomain)
104 if err != nil {106 if err != nil {
105 return fmt.Errorf("failed to get route: %w", err)107 return fmt.Errorf("failed to get route: %w", err)
106 }108 }
@@ -112,22 +114,22 @@ func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string, js
112 114
113 tags, err := fetchAndSortTags(ctx, handle.plane, imageName)115 tags, err := fetchAndSortTags(ctx, handle.plane, imageName)
114 if err != nil {116 if err != nil {
115- return err117+ return fmt.Errorf("failed to fetch tags: %w", err)
116 }118 }
117 119
118- return printRollbackTags(w, rollbackDomain, currentTag, tags, jsonOut)120+ return printPinTags(w, pinDomain, currentTag, tags, jsonOut)
119 }121 }
120 122
121-func printRollbackTags(w io.Writer, rollbackDomain, currentTag string, tags []string, jsonOut bool) error {123+func printPinTags(w io.Writer, pinDomain, currentTag string, tags []string, jsonOut bool) error {
122 if jsonOut {124 if jsonOut {
123 return writeJSON(w, map[string]any{125 return writeJSON(w, map[string]any{
124- "domain": rollbackDomain,126+ "domain": pinDomain,
125 "current_tag": currentTag,127 "current_tag": currentTag,
126 "tags": tags,128 "tags": tags,
127 })129 })
128 }130 }
129 131
130- if _, err := fmt.Fprintf(w, "Available tags for %s:\n", styles.Theme.Bold.Render(rollbackDomain)); err != nil {132+ if _, err := fmt.Fprintf(w, "Available tags for %s:\n", styles.Theme.Bold.Render(pinDomain)); err != nil {
131 return err133 return err
132 }134 }
133 for _, tag := range tags {135 for _, tag := range tags {
@@ -192,7 +194,7 @@ func sortSemverTags(tags []string) []string {
192 return append(semverTags, otherTags...)194 return append(semverTags, otherTags...)
193 }195 }
194 196
195-func selectTag(targetTag string, tags []string, currentTag, rollbackDomain string) (string, error) {197+func selectTag(targetTag string, tags []string, currentTag, pinDomain string, out io.Writer) (string, error) {
196 if targetTag != "" {198 if targetTag != "" {
197 if !validateTagExists(targetTag, tags) {199 if !validateTagExists(targetTag, tags) {
198 return "", fmt.Errorf("tag %s not found. Available: %s", targetTag, strings.Join(tags, ", "))200 return "", fmt.Errorf("tag %s not found. Available: %s", targetTag, strings.Join(tags, ", "))
@@ -201,7 +203,7 @@ func selectTag(targetTag string, tags []string, currentTag, rollbackDomain strin
201 }203 }
202 204
203 selectedTag, err := components.RunSelector(205 selectedTag, err := components.RunSelector(
204- fmt.Sprintf("Select version for %s:", rollbackDomain),206+ fmt.Sprintf("Select version for %s:", pinDomain),
205 tags,207 tags,
206 currentTag,208 currentTag,
207 )209 )
@@ -209,42 +211,38 @@ func selectTag(targetTag string, tags []string, currentTag, rollbackDomain strin
209 return "", err211 return "", err
210 }212 }
211 if selectedTag == "" {213 if selectedTag == "" {
212- fmt.Println("Cancelled.")214+ _ = cliWriteLine(out, "Cancelled.")
213 }215 }
214 return selectedTag, nil216 return selectedTag, nil
215 }217 }
216 218
217 func validateTagExists(targetTag string, tags []string) bool {219 func validateTagExists(targetTag string, tags []string) bool {
218- for _, t := range tags {220+ return slices.Contains(tags, targetTag)
219- if t == targetTag {
220- return true
221- }
222- }
223- return false
224 }221 }
225 222
226-func deploySelectedTag(ctx context.Context, cp ControlPlane, route *domain.Route, rollbackDomain, imageName, selectedTag string) error {223+func deploySelectedTag(ctx context.Context, cp ControlPlane, route *domain.Route, out, ew io.Writer, pinDomain, imageName, selectedTag string) error {
227 registry, _, _ := parseImageRef(route.Image)224 registry, _, _ := parseImageRef(route.Image)
228 oldImage := route.Image225 oldImage := route.Image
229 route.Image = fmt.Sprintf("%s/%s:%s", registry, imageName, selectedTag)226 route.Image = fmt.Sprintf("%s/%s:%s", registry, imageName, selectedTag)
230 227
231- fmt.Printf("Rolling back to %s...\n", styles.Theme.Bold.Render(selectedTag))228+ if err := cliWritef(out, "Pinning to %s...\n", styles.Theme.Bold.Render(selectedTag)); err != nil {
229+ return err
230+ }
232 231
233 if err := cp.UpdateRoute(ctx, *route); err != nil {232 if err := cp.UpdateRoute(ctx, *route); err != nil {
234 return fmt.Errorf("failed to update route: %w", err)233 return fmt.Errorf("failed to update route: %w", err)
235 }234 }
236 235
237- result, err := cp.Deploy(ctx, rollbackDomain)236+ result, err := cp.Deploy(ctx, pinDomain)
238 if err != nil {237 if err != nil {
239 // Attempt to revert route to previous image238 // Attempt to revert route to previous image
240 route.Image = oldImage239 route.Image = oldImage
241 if revertErr := cp.UpdateRoute(ctx, *route); revertErr != nil {240 if revertErr := cp.UpdateRoute(ctx, *route); revertErr != nil {
242- fmt.Fprintf(os.Stderr, "WARNING: deploy failed and could not revert route: %v\n", revertErr)241+ _ = cliWritef(ew, "WARNING: deploy failed and could not revert route: %v\n", revertErr)
243 return fmt.Errorf("failed to deploy; revert failed: %v; deploy error: %w", revertErr, err)242 return fmt.Errorf("failed to deploy; revert failed: %v; deploy error: %w", revertErr, err)
244 }243 }
245 return fmt.Errorf("failed to deploy (route reverted): %w", err)244 return fmt.Errorf("failed to deploy (route reverted): %w", err)
246 }245 }
247 containerID := shortContainerID(result.ContainerID)246 containerID := shortContainerID(result.ContainerID)
248- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Rolled back %s to %s (container: %s)", rollbackDomain, selectedTag, containerID)))247+ return cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Pinned %s to %s (container: %s)", pinDomain, selectedTag, containerID)))
249- return nil
250 }248 }
R internal/adapters/in/cli/rollback_test.go → internal/adapters/in/cli/pin_test.go +4 -4 renamed, 85% similar
@@ -60,10 +60,10 @@ func TestValidateTagExists(t *testing.T) {
6060 assert.False(t, validateTagExists("v3.0.0", []string{"v1.0.0", "v2.0.0"}))
6161 }
6262
63-func TestPrintRollbackTags_WritesToProvidedWriter(t *testing.T) {
63+func TestPrintPinTags_WritesToProvidedWriter(t *testing.T) {
6464 var buf bytes.Buffer
6565
66- err := printRollbackTags(&buf, "myapp.example.com", "v1.2.0", []string{"v1.2.0", "v1.1.0"}, false)
66+ err := printPinTags(&buf, "myapp.example.com", "v1.2.0", []string{"v1.2.0", "v1.1.0"}, false)
6767 assert.NoError(t, err)
6868
6969 output := buf.String()
@@ -73,8 +73,8 @@ func TestPrintRollbackTags_WritesToProvidedWriter(t *testing.T) {
7373 assert.Contains(t, output, "- v1.1.0")
7474 }
7575
76-func TestPrintRollbackTags_ReturnsWriteError(t *testing.T) {
77- err := printRollbackTags(failingWriter{}, "myapp.example.com", "v1.2.0", []string{"v1.2.0"}, false)
76+func TestPrintPinTags_ReturnsWriteError(t *testing.T) {
77+ err := printPinTags(failingWriter{}, "myapp.example.com", "v1.2.0", []string{"v1.2.0"}, false)
7878 assert.Error(t, err)
7979 assert.Contains(t, err.Error(), "write failed")
8080 }
@@ -60,10 +60,10 @@ func TestValidateTagExists(t *testing.T) {
60 assert.False(t, validateTagExists("v3.0.0", []string{"v1.0.0", "v2.0.0"}))60 assert.False(t, validateTagExists("v3.0.0", []string{"v1.0.0", "v2.0.0"}))
61 }61 }
62 62
63-func TestPrintRollbackTags_WritesToProvidedWriter(t *testing.T) {63+func TestPrintPinTags_WritesToProvidedWriter(t *testing.T) {
64 var buf bytes.Buffer64 var buf bytes.Buffer
65 65
66- err := printRollbackTags(&buf, "myapp.example.com", "v1.2.0", []string{"v1.2.0", "v1.1.0"}, false)66+ err := printPinTags(&buf, "myapp.example.com", "v1.2.0", []string{"v1.2.0", "v1.1.0"}, false)
67 assert.NoError(t, err)67 assert.NoError(t, err)
68 68
69 output := buf.String()69 output := buf.String()
@@ -73,8 +73,8 @@ func TestPrintRollbackTags_WritesToProvidedWriter(t *testing.T) {
73 assert.Contains(t, output, "- v1.1.0")73 assert.Contains(t, output, "- v1.1.0")
74 }74 }
75 75
76-func TestPrintRollbackTags_ReturnsWriteError(t *testing.T) {76+func TestPrintPinTags_ReturnsWriteError(t *testing.T) {
77- err := printRollbackTags(failingWriter{}, "myapp.example.com", "v1.2.0", []string{"v1.2.0"}, false)77+ err := printPinTags(failingWriter{}, "myapp.example.com", "v1.2.0", []string{"v1.2.0"}, false)
78 assert.Error(t, err)78 assert.Error(t, err)
79 assert.Contains(t, err.Error(), "write failed")79 assert.Contains(t, err.Error(), "write failed")
80 }80 }
M internal/adapters/in/cli/root.go +3 -3
@@ -101,9 +101,9 @@ Commands are organized by where they run:
101101 pushCmd.GroupID = groupManage
102102 rootCmd.AddCommand(pushCmd)
103103
104- rollbackCmd := newRollbackCmd()
105- rollbackCmd.GroupID = groupManage
106- rootCmd.AddCommand(rollbackCmd)
104+ pinCmd := newPinCmd()
105+ pinCmd.GroupID = groupManage
106+ rootCmd.AddCommand(pinCmd)
107107
108108 reloadCmd := newReloadCmd()
109109 reloadCmd.GroupID = groupManage
@@ -101,9 +101,9 @@ Commands are organized by where they run:
101 pushCmd.GroupID = groupManage101 pushCmd.GroupID = groupManage
102 rootCmd.AddCommand(pushCmd)102 rootCmd.AddCommand(pushCmd)
103 103
104- rollbackCmd := newRollbackCmd()104+ pinCmd := newPinCmd()
105- rollbackCmd.GroupID = groupManage105+ pinCmd.GroupID = groupManage
106- rootCmd.AddCommand(rollbackCmd)106+ rootCmd.AddCommand(pinCmd)
107 107
108 reloadCmd := newReloadCmd()108 reloadCmd := newReloadCmd()
109 reloadCmd.GroupID = groupManage109 reloadCmd.GroupID = groupManage

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 7

Tags 106

↑↓ moveenter openesc close