v2.31.0-dev17 bd898f0

attachments.go RawHistoryFolder

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

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