v2.7.1 b3a38c8

attachments.go RawHistoryFolder

1package cli
2
3import (
4	"context"
5	"fmt"
6	"sort"
7
8	"gordon/internal/adapters/in/cli/remote"
9	"gordon/internal/adapters/in/cli/ui/components"
10	"gordon/internal/adapters/in/cli/ui/styles"
11
12	"github.com/spf13/cobra"
13)
14
15// newAttachmentsCmd creates the attachments command group.
16func newAttachmentsCmd() *cobra.Command {
17	cmd := &cobra.Command{
18		Use:     "attachments",
19		Aliases: []string{"attach"},
20		Short:   "Manage container attachments",
21		Long: `Manage container attachments (databases, caches, queues) in the configuration.
22
23Attachments are service dependencies that run alongside your application containers.
24They are defined per-domain or per-network-group in the configuration.
25
26When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
27these commands operate on the remote server. Otherwise, they require access to
28the local Gordon configuration.`,
29	}
30
31	cmd.AddCommand(newAttachmentsListCmd())
32	cmd.AddCommand(newAttachmentsAddCmd())
33	cmd.AddCommand(newAttachmentsRemoveCmd())
34
35	return cmd
36}
37
38// newAttachmentsListCmd creates the attachments list command.
39func newAttachmentsListCmd() *cobra.Command {
40	return &cobra.Command{
41		Use:   "list [domain-or-group]",
42		Short: "List configured attachments",
43		Long: `List all configured attachments, or attachments for a specific domain/group.
44
45Examples:
46  gordon attachments list                    # List all attachments
47  gordon attachments list app.example.com    # List attachments for domain
48  gordon attachments list backend            # List attachments for network group`,
49		Args: cobra.MaximumNArgs(1),
50		RunE: func(cmd *cobra.Command, args []string) error {
51			ctx := context.Background()
52			target := ""
53			if len(args) > 0 {
54				target = args[0]
55			}
56
57			client, isRemote := GetRemoteClient()
58			if isRemote {
59				return runAttachmentsListRemote(ctx, client, target)
60			}
61			return runAttachmentsListLocal(ctx, configPath, target)
62		},
63	}
64}
65
66// runAttachmentsListRemote lists attachments from a remote Gordon instance.
67func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string) error {
68	if target != "" {
69		// List for specific target
70		images, err := client.GetAttachmentsConfig(ctx, target)
71		if err != nil {
72			return fmt.Errorf("failed to get attachments: %w", err)
73		}
74
75		if len(images) == 0 {
76			fmt.Printf("No attachments configured for '%s'\n", target)
77			return nil
78		}
79
80		fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target)))
81		fmt.Println()
82		for _, img := range images {
83			fmt.Printf("  %s\n", img)
84		}
85		return nil
86	}
87
88	// List all attachments
89	attachments, err := client.GetAllAttachmentsConfig(ctx)
90	if err != nil {
91		return fmt.Errorf("failed to list attachments: %w", err)
92	}
93
94	if len(attachments) == 0 {
95		fmt.Println(styles.Theme.Muted.Render("No attachments configured"))
96		return nil
97	}
98
99	// Sort targets for consistent output
100	targets := make([]string, 0, len(attachments))
101	for t := range attachments {
102		targets = append(targets, t)
103	}
104	sort.Strings(targets)
105
106	fmt.Println(styles.Theme.Title.Render("Attachments"))
107	fmt.Println()
108
109	for _, t := range targets {
110		images := attachments[t]
111		fmt.Printf("%s\n", styles.Theme.Bold.Render(t))
112		for _, img := range images {
113			fmt.Printf("  %s\n", img)
114		}
115	}
116
117	return nil
118}
119
120// runAttachmentsListLocal lists attachments from local configuration.
121func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string) error {
122	local, err := GetLocalServices(cfgPath)
123	if err != nil {
124		return fmt.Errorf("failed to initialize local services: %w", err)
125	}
126
127	if target != "" {
128		// List for specific target
129		images, err := local.GetConfigService().GetAttachmentsFor(ctx, target)
130		if err != nil {
131			return fmt.Errorf("failed to get attachments: %w", err)
132		}
133
134		if len(images) == 0 {
135			fmt.Printf("No attachments configured for '%s'\n", target)
136			return nil
137		}
138
139		fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s (local)", target)))
140		fmt.Println()
141		for _, img := range images {
142			fmt.Printf("  %s\n", img)
143		}
144		return nil
145	}
146
147	// List all attachments
148	attachments := local.GetConfigService().GetAllAttachments(ctx)
149
150	if len(attachments) == 0 {
151		fmt.Println(styles.Theme.Muted.Render("No attachments configured"))
152		return nil
153	}
154
155	// Sort targets for consistent output
156	targets := make([]string, 0, len(attachments))
157	for t := range attachments {
158		targets = append(targets, t)
159	}
160	sort.Strings(targets)
161
162	fmt.Println(styles.Theme.Title.Render("Attachments (local)"))
163	fmt.Println()
164
165	for _, t := range targets {
166		images := attachments[t]
167		fmt.Printf("%s\n", styles.Theme.Bold.Render(t))
168		for _, img := range images {
169			fmt.Printf("  %s\n", img)
170		}
171	}
172
173	return nil
174}
175
176// newAttachmentsAddCmd creates the attachments add command.
177func newAttachmentsAddCmd() *cobra.Command {
178	cmd := &cobra.Command{
179		Use:   "add <domain-or-group> <image>",
180		Short: "Add an attachment",
181		Long: `Add a container attachment to a domain or network group.
182
183Note: Attachments require network_isolation.enabled = true in your configuration.
184Without network isolation, containers use Docker's default bridge network which
185does not provide DNS resolution - your app won't be able to reach attachments
186by hostname (e.g., 'postgres:5432').
187
188Examples:
189  gordon attachments add app.example.com postgres:18
190  gordon attachments add backend redis:7-alpine
191  gordon --remote https://gordon.mydomain.com attachments add api.mydomain.com memcached:latest`,
192		Args: cobra.ExactArgs(2),
193		RunE: func(cmd *cobra.Command, args []string) error {
194			ctx := context.Background()
195			target := args[0]
196			image := args[1]
197
198			client, isRemote := GetRemoteClient()
199			if isRemote {
200				if err := client.AddAttachment(ctx, target, image); err != nil {
201					return fmt.Errorf("failed to add attachment: %w", err)
202				}
203			} else {
204				local, err := GetLocalServices(configPath)
205				if err != nil {
206					return fmt.Errorf("failed to initialize local services: %w", err)
207				}
208
209				// Warn if network isolation is disabled
210				if !local.GetConfigService().IsNetworkIsolationEnabled() {
211					fmt.Println(styles.RenderWarning("network_isolation.enabled = false"))
212					fmt.Println(styles.Theme.Muted.Render("  Attachments require network isolation for DNS resolution."))
213					fmt.Println(styles.Theme.Muted.Render("  Without it, your app cannot reach attachments by hostname."))
214					fmt.Println(styles.Theme.Muted.Render("  Enable with: [network_isolation] enabled = true"))
215					fmt.Println()
216				}
217
218				if err := local.GetConfigService().AddAttachment(ctx, target, image); err != nil {
219					return fmt.Errorf("failed to add attachment: %w", err)
220				}
221			}
222
223			fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment added: %s -> %s", target, image)))
224			return nil
225		},
226	}
227
228	return cmd
229}
230
231// newAttachmentsRemoveCmd creates the attachments remove command.
232func newAttachmentsRemoveCmd() *cobra.Command {
233	var force bool
234
235	cmd := &cobra.Command{
236		Use:   "remove <domain-or-group> <image>",
237		Short: "Remove an attachment",
238		Long: `Remove a container attachment from a domain or network group.
239
240Examples:
241  gordon attachments remove app.example.com postgres:15
242  gordon attachments remove backend redis:7-alpine --force`,
243		Args: cobra.ExactArgs(2),
244		RunE: func(cmd *cobra.Command, args []string) error {
245			ctx := context.Background()
246			target := args[0]
247			image := args[1]
248
249			// Confirm unless --force
250			if !force {
251				confirmed, err := components.RunConfirm(
252					fmt.Sprintf("Remove attachment '%s' from '%s'?", image, target),
253					components.WithDescription("This will remove the attachment from the configuration. The container will be stopped on next reload."),
254				)
255				if err != nil {
256					return err
257				}
258				if !confirmed {
259					fmt.Println(styles.Theme.Muted.Render("Cancelled"))
260					return nil
261				}
262			}
263
264			client, isRemote := GetRemoteClient()
265			if isRemote {
266				if err := client.RemoveAttachment(ctx, target, image); err != nil {
267					return fmt.Errorf("failed to remove attachment: %w", err)
268				}
269			} else {
270				local, err := GetLocalServices(configPath)
271				if err != nil {
272					return fmt.Errorf("failed to initialize local services: %w", err)
273				}
274				if err := local.GetConfigService().RemoveAttachment(ctx, target, image); err != nil {
275					return fmt.Errorf("failed to remove attachment: %w", err)
276				}
277			}
278
279			fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment removed: %s -> %s", target, image)))
280			return nil
281		},
282	}
283
284	cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
285
286	return cmd
287}

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