v2.31.0-dev22 8d342c4

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	"github.com/bnema/gordon/internal/domain"
13
14	"github.com/spf13/cobra"
15)
16
17// newAttachmentsCmd creates the attachments command group.
18func newAttachmentsCmd() *cobra.Command {
19	cmd := &cobra.Command{
20		Use:     "attachments",
21		Aliases: []string{"attach"},
22		Short:   "Manage container attachments",
23		Long: `Manage container attachments (databases, caches, queues) in the configuration.
24
25Attachments are service dependencies that run alongside your application containers.
26They are defined per-domain or per-network-group in the configuration.
27
28When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
29these commands operate on the remote server. Otherwise, they require access to
30the local Gordon configuration.`,
31	}
32
33	cmd.AddCommand(newAttachmentsListCmd())
34	cmd.AddCommand(newAttachmentsAddCmd())
35	cmd.AddCommand(newAttachmentsRemoveCmd())
36	cmd.AddCommand(newAttachmentsOrphansCmd())
37	cmd.AddCommand(newAttachmentsPruneCmd())
38	cmd.AddCommand(newAttachmentsPushCmd())
39
40	return cmd
41}
42
43// newAttachmentsListCmd creates the attachments list command.
44func newAttachmentsListCmd() *cobra.Command {
45	var jsonOut bool
46
47	cmd := &cobra.Command{
48		Use:   "list [domain-or-group]",
49		Short: "List configured attachments",
50		Long: `List all configured attachments, or attachments for a specific domain/group.
51
52Examples:
53  gordon attachments list                    # List all attachments
54  gordon attachments list app.example.com    # List attachments for domain
55  gordon attachments list backend            # List attachments for network group`,
56		Args: cobra.MaximumNArgs(1),
57		RunE: func(cmd *cobra.Command, args []string) error {
58			ctx := cmd.Context()
59			target := ""
60			if len(args) > 0 {
61				target = args[0]
62			}
63
64			if target != "" {
65				handle, err := resolveControlPlaneForAttachmentTarget(ctx, target)
66				if err != nil {
67					return err
68				}
69				defer handle.close()
70				return runAttachmentsList(ctx, handle.plane, target, jsonOut, cmd.OutOrStdout())
71			}
72
73			client, isRemote, err := GetRemoteClient()
74			if err != nil {
75				return err
76			}
77			if isRemote {
78				return runAttachmentsListRemote(ctx, client, target, jsonOut, cmd.OutOrStdout())
79			}
80			return runAttachmentsListLocal(ctx, configPath, target, jsonOut, cmd.OutOrStdout())
81		},
82	}
83
84	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
85
86	return cmd
87}
88
89func runAttachmentsList(ctx context.Context, cp ControlPlane, target string, jsonOut bool, out io.Writer) error {
90	if target != "" {
91		images, err := cp.GetAttachmentsConfig(ctx, target)
92		if err != nil {
93			return fmt.Errorf("failed to get attachments: %w", err)
94		}
95		if len(images) == 0 {
96			_, err := fmt.Fprintf(out, "No attachments configured for '%s'\n", target)
97			return err
98		}
99		if jsonOut {
100			return writeJSON(out, map[string]any{"target": target, "images": images})
101		}
102		return renderAttachmentTargetList(out, fmt.Sprintf("Attachments for %s", target), images)
103	}
104
105	attachments, err := cp.GetAllAttachmentsConfig(ctx)
106	if err != nil {
107		return fmt.Errorf("failed to list attachments: %w", err)
108	}
109	if len(attachments) == 0 {
110		return cliWriteLine(out, styles.Theme.Muted.Render("No attachments configured"))
111	}
112	if jsonOut {
113		return writeJSON(out, map[string]any{"attachments": attachments})
114	}
115	return renderAllAttachmentsList(out, "Attachments", attachments)
116}
117
118func renderAttachmentTargetList(out io.Writer, title string, images []string) error {
119	if err := cliWriteLine(out, cliRenderTitle(title)); err != nil {
120		return err
121	}
122	if err := cliWriteLine(out, ""); err != nil {
123		return err
124	}
125	for _, img := range images {
126		if err := cliWritef(out, "  %s\n", img); err != nil {
127			return err
128		}
129	}
130	return nil
131}
132
133func renderAllAttachmentsList(out io.Writer, title string, attachments map[string][]string) error {
134	targets := make([]string, 0, len(attachments))
135	for t := range attachments {
136		targets = append(targets, t)
137	}
138	sort.Strings(targets)
139
140	if err := cliWriteLine(out, cliRenderTitle(title)); err != nil {
141		return err
142	}
143	if err := cliWriteLine(out, ""); err != nil {
144		return err
145	}
146	for _, t := range targets {
147		images := attachments[t]
148		if err := cliWritef(out, "%s\n", styles.Theme.Bold.Render(t)); err != nil {
149			return err
150		}
151		for _, img := range images {
152			if err := cliWritef(out, "  %s\n", img); err != nil {
153				return err
154			}
155		}
156	}
157	return nil
158}
159
160// runAttachmentsListRemote lists attachments from a remote Gordon instance.
161func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string, jsonOut bool, out io.Writer) error {
162	if target != "" {
163		// List for specific target
164		images, err := client.GetAttachmentsConfig(ctx, target)
165		if err != nil {
166			return fmt.Errorf("failed to get attachments: %w", err)
167		}
168
169		if len(images) == 0 {
170			fmt.Printf("No attachments configured for '%s'\n", target)
171			return nil
172		}
173
174		if jsonOut {
175			return writeJSON(out, map[string]any{"target": target, "images": images})
176		}
177
178		fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target)))
179		fmt.Println()
180		for _, img := range images {
181			fmt.Printf("  %s\n", img)
182		}
183		return nil
184	}
185
186	// List all attachments
187	attachments, err := client.GetAllAttachmentsConfig(ctx)
188	if err != nil {
189		return fmt.Errorf("failed to list attachments: %w", err)
190	}
191
192	if len(attachments) == 0 {
193		fmt.Println(styles.Theme.Muted.Render("No attachments configured"))
194		return nil
195	}
196
197	if jsonOut {
198		return writeJSON(out, map[string]any{"attachments": attachments})
199	}
200
201	// Sort targets for consistent output
202	targets := make([]string, 0, len(attachments))
203	for t := range attachments {
204		targets = append(targets, t)
205	}
206	sort.Strings(targets)
207
208	fmt.Println(styles.Theme.Title.Render("Attachments"))
209	fmt.Println()
210
211	for _, t := range targets {
212		images := attachments[t]
213		fmt.Printf("%s\n", styles.Theme.Bold.Render(t))
214		for _, img := range images {
215			fmt.Printf("  %s\n", img)
216		}
217	}
218
219	return nil
220}
221
222// runAttachmentsListLocal lists attachments from local configuration.
223func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string, jsonOut bool, out io.Writer) error {
224	local, err := GetLocalServices(cfgPath)
225	if err != nil {
226		return fmt.Errorf("failed to initialize local services: %w", err)
227	}
228
229	if target != "" {
230		// List for specific target
231		images, err := local.GetConfigService().GetAttachmentsFor(ctx, target)
232		if err != nil {
233			return fmt.Errorf("failed to get attachments: %w", err)
234		}
235
236		if len(images) == 0 {
237			fmt.Printf("No attachments configured for '%s'\n", target)
238			return nil
239		}
240
241		if jsonOut {
242			return writeJSON(out, map[string]any{"target": target, "images": images})
243		}
244
245		fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s (local)", target)))
246		fmt.Println()
247		for _, img := range images {
248			fmt.Printf("  %s\n", img)
249		}
250		return nil
251	}
252
253	// List all attachments
254	attachments := local.GetConfigService().GetAllAttachments(ctx)
255
256	if len(attachments) == 0 {
257		fmt.Println(styles.Theme.Muted.Render("No attachments configured"))
258		return nil
259	}
260
261	if jsonOut {
262		return writeJSON(out, map[string]any{"attachments": attachments})
263	}
264
265	// Sort targets for consistent output
266	targets := make([]string, 0, len(attachments))
267	for t := range attachments {
268		targets = append(targets, t)
269	}
270	sort.Strings(targets)
271
272	fmt.Println(styles.Theme.Title.Render("Attachments (local)"))
273	fmt.Println()
274
275	for _, t := range targets {
276		images := attachments[t]
277		fmt.Printf("%s\n", styles.Theme.Bold.Render(t))
278		for _, img := range images {
279			fmt.Printf("  %s\n", img)
280		}
281	}
282
283	return nil
284}
285
286func newAttachmentsOrphansCmd() *cobra.Command {
287	var jsonOut bool
288	cmd := &cobra.Command{
289		Use:   "orphans",
290		Short: "List orphaned attachment containers",
291		Args:  cobra.NoArgs,
292		RunE: func(cmd *cobra.Command, args []string) error {
293			handle, err := resolveControlPlane(configPath)
294			if err != nil {
295				return err
296			}
297			defer handle.close()
298			lister, ok := handle.plane.(interface {
299				ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error)
300			})
301			if !ok {
302				return fmt.Errorf("attachment orphan listing is unavailable")
303			}
304			attachments, err := lister.ListOrphanedAttachments(cmd.Context())
305			if err != nil {
306				return fmt.Errorf("failed to list orphaned attachments: %w", err)
307			}
308			if jsonOut {
309				return writeJSON(cmd.OutOrStdout(), map[string]any{"attachments": attachments})
310			}
311			return writeAttachmentOrphansText(cmd.OutOrStdout(), attachments)
312		},
313	}
314	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
315	return cmd
316}
317
318func newAttachmentsPruneCmd() *cobra.Command {
319	var jsonOut bool
320	var stop bool
321	cmd := &cobra.Command{
322		Use:   "prune",
323		Short: "Review or stop orphaned attachment containers",
324		Args:  cobra.NoArgs,
325		RunE: func(cmd *cobra.Command, args []string) error {
326			handle, err := resolveControlPlane(configPath)
327			if err != nil {
328				return err
329			}
330			defer handle.close()
331			cleaner, ok := handle.plane.(interface {
332				CleanupOrphanedAttachments(context.Context, string, bool) (*domain.CleanupReport, error)
333			})
334			if !ok {
335				return fmt.Errorf("attachment orphan cleanup is unavailable")
336			}
337			report, err := cleaner.CleanupOrphanedAttachments(cmd.Context(), "", stop)
338			if err != nil {
339				return fmt.Errorf("failed to cleanup orphaned attachments: %w", err)
340			}
341			if jsonOut {
342				return writeJSON(cmd.OutOrStdout(), report)
343			}
344			return writeAttachmentPruneText(cmd.OutOrStdout(), report, stop)
345		},
346	}
347	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
348	cmd.Flags().BoolVar(&stop, "stop", false, "Stop and remove orphaned attachment containers while preserving volumes")
349	return cmd
350}
351
352func writeAttachmentOrphansText(out io.Writer, attachments []domain.CleanupAttachment) error {
353	if len(attachments) == 0 {
354		return cliWriteLine(out, styles.Theme.Muted.Render("No orphaned attachments found"))
355	}
356	if err := cliWriteLine(out, cliRenderTitle("Orphaned attachments")); err != nil {
357		return err
358	}
359	for _, a := range attachments {
360		label := a.Name
361		if label == "" {
362			label = a.ContainerID
363		}
364		if a.Image != "" {
365			label += " -> " + a.Image
366		}
367		if err := cliWriteLine(out, "  "+label); err != nil {
368			return err
369		}
370	}
371	return nil
372}
373
374func writeAttachmentPruneText(out io.Writer, report *domain.CleanupReport, stop bool) error {
375	if report == nil {
376		return nil
377	}
378	if !stop {
379		if err := writeAttachmentOrphansText(out, report.PreservedAttachments); err != nil {
380			return err
381		}
382		return writeCleanupMessages(out, report.Warnings, report.Hints)
383	}
384	if len(report.RemovedContainers) == 0 {
385		if err := cliWriteLine(out, styles.Theme.Muted.Render("No orphaned attachments removed")); err != nil {
386			return err
387		}
388	} else {
389		if err := cliWriteLine(out, cliRenderSuccess("Removed orphaned attachment containers:")); err != nil {
390			return err
391		}
392		for _, c := range report.RemovedContainers {
393			label := c.Name
394			if label == "" {
395				label = c.ID
396			}
397			if err := cliWriteLine(out, "  "+label); err != nil {
398				return err
399			}
400		}
401	}
402	for _, failure := range report.PartialFailures {
403		if err := cliWriteLine(out, cliRenderWarning(fmt.Sprintf("%s %s failed: %s", failure.Action, failure.Name, failure.Error))); err != nil {
404			return err
405		}
406	}
407	return writeCleanupMessages(out, report.Warnings, report.Hints)
408}
409
410// newAttachmentsAddCmd creates the attachments add command.
411func newAttachmentsAddCmd() *cobra.Command {
412	cmd := &cobra.Command{
413		Use:   "add <domain-or-group> <image>",
414		Short: "Add an attachment",
415		Long: `Add a container attachment to a domain or network group.
416
417Note: Attachments require network_isolation.enabled = true in your configuration.
418Without network isolation, containers use Docker's default bridge network which
419does not provide DNS resolution - your app won't be able to reach attachments
420by hostname (e.g., 'postgres:5432').
421
422Examples:
423  gordon attachments add app.example.com postgres:18
424  gordon attachments add backend redis:7-alpine
425  gordon --remote https://gordon.mydomain.com attachments add api.mydomain.com memcached:latest`,
426		Args: cobra.ExactArgs(2),
427		RunE: func(cmd *cobra.Command, args []string) error {
428			ctx := cmd.Context()
429			target := args[0]
430			image := args[1]
431
432			handle, err := resolveControlPlaneForAttachmentTarget(ctx, target)
433			if err != nil {
434				return err
435			}
436			defer handle.close()
437
438			if !handle.isRemote {
439				local, err := GetLocalServices(configPath)
440				if err != nil {
441					return fmt.Errorf("failed to initialize local services: %w", err)
442				}
443
444				// Warn if network isolation is disabled
445				if !local.GetConfigService().IsNetworkIsolationEnabled() {
446					fmt.Println(styles.RenderWarning("network_isolation.enabled = false"))
447					fmt.Println(styles.Theme.Muted.Render("  Attachments require network isolation for DNS resolution."))
448					fmt.Println(styles.Theme.Muted.Render("  Without it, your app cannot reach attachments by hostname."))
449					fmt.Println(styles.Theme.Muted.Render("  Enable with: [network_isolation] enabled = true"))
450					fmt.Println()
451				}
452			}
453
454			if err := handle.plane.AddAttachment(ctx, target, image); err != nil {
455				return fmt.Errorf("failed to add attachment: %w", err)
456			}
457
458			fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment added: %s -> %s", target, image)))
459			return nil
460		},
461	}
462
463	return cmd
464}
465
466// newAttachmentsRemoveCmd creates the attachments remove command.
467func newAttachmentsRemoveCmd() *cobra.Command {
468	var force bool
469
470	cmd := &cobra.Command{
471		Use:   "remove <domain-or-group> <image>",
472		Short: "Remove an attachment",
473		Long: `Remove a container attachment from a domain or network group.
474
475Examples:
476  gordon attachments remove app.example.com postgres:15
477  gordon attachments remove backend redis:7-alpine --force`,
478		Args: cobra.ExactArgs(2),
479		RunE: func(cmd *cobra.Command, args []string) error {
480			ctx := cmd.Context()
481			target := args[0]
482			image := args[1]
483
484			// Confirm unless --force
485			if !force {
486				confirmed, err := components.RunConfirm(
487					fmt.Sprintf("Remove attachment '%s' from '%s'?", image, target),
488					components.WithDescription("This will remove the attachment from the configuration. The container will be stopped on next reload."),
489				)
490				if err != nil {
491					return err
492				}
493				if !confirmed {
494					fmt.Println(styles.Theme.Muted.Render("Cancelled"))
495					return nil
496				}
497			}
498
499			handle, err := resolveControlPlaneForAttachmentTarget(ctx, target)
500			if err != nil {
501				return err
502			}
503			defer handle.close()
504			if err := handle.plane.RemoveAttachment(ctx, target, image); err != nil {
505				return fmt.Errorf("failed to remove attachment: %w", err)
506			}
507
508			fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment removed: %s -> %s", target, image)))
509			return nil
510		},
511	}
512
513	cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
514
515	return cmd
516}

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