v2.31.0-dev18 7b9923f

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

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