v2.31.0-dev10 f49cffc

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 := context.Background()
56			target := ""
57			if len(args) > 0 {
58				target = args[0]
59			}
60
61			client, isRemote := GetRemoteClient()
62			if isRemote {
63				return runAttachmentsListRemote(ctx, client, target, jsonOut, cmd.OutOrStdout())
64			}
65			return runAttachmentsListLocal(ctx, configPath, target, jsonOut, cmd.OutOrStdout())
66		},
67	}
68
69	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
70
71	return cmd
72}
73
74// runAttachmentsListRemote lists attachments from a remote Gordon instance.
75func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string, jsonOut bool, out io.Writer) error {
76	if target != "" {
77		// List for specific target
78		images, err := client.GetAttachmentsConfig(ctx, target)
79		if err != nil {
80			return fmt.Errorf("failed to get attachments: %w", err)
81		}
82
83		if len(images) == 0 {
84			fmt.Printf("No attachments configured for '%s'\n", target)
85			return nil
86		}
87
88		if jsonOut {
89			return writeJSON(out, map[string]any{"target": target, "images": images})
90		}
91
92		fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target)))
93		fmt.Println()
94		for _, img := range images {
95			fmt.Printf("  %s\n", img)
96		}
97		return nil
98	}
99
100	// List all attachments
101	attachments, err := client.GetAllAttachmentsConfig(ctx)
102	if err != nil {
103		return fmt.Errorf("failed to list attachments: %w", err)
104	}
105
106	if len(attachments) == 0 {
107		fmt.Println(styles.Theme.Muted.Render("No attachments configured"))
108		return nil
109	}
110
111	if jsonOut {
112		return writeJSON(out, map[string]any{"attachments": attachments})
113	}
114
115	// Sort targets for consistent output
116	targets := make([]string, 0, len(attachments))
117	for t := range attachments {
118		targets = append(targets, t)
119	}
120	sort.Strings(targets)
121
122	fmt.Println(styles.Theme.Title.Render("Attachments"))
123	fmt.Println()
124
125	for _, t := range targets {
126		images := attachments[t]
127		fmt.Printf("%s\n", styles.Theme.Bold.Render(t))
128		for _, img := range images {
129			fmt.Printf("  %s\n", img)
130		}
131	}
132
133	return nil
134}
135
136// runAttachmentsListLocal lists attachments from local configuration.
137func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string, jsonOut bool, out io.Writer) error {
138	local, err := GetLocalServices(cfgPath)
139	if err != nil {
140		return fmt.Errorf("failed to initialize local services: %w", err)
141	}
142
143	if target != "" {
144		// List for specific target
145		images, err := local.GetConfigService().GetAttachmentsFor(ctx, target)
146		if err != nil {
147			return fmt.Errorf("failed to get attachments: %w", err)
148		}
149
150		if len(images) == 0 {
151			fmt.Printf("No attachments configured for '%s'\n", target)
152			return nil
153		}
154
155		if jsonOut {
156			return writeJSON(out, map[string]any{"target": target, "images": images})
157		}
158
159		fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s (local)", target)))
160		fmt.Println()
161		for _, img := range images {
162			fmt.Printf("  %s\n", img)
163		}
164		return nil
165	}
166
167	// List all attachments
168	attachments := local.GetConfigService().GetAllAttachments(ctx)
169
170	if len(attachments) == 0 {
171		fmt.Println(styles.Theme.Muted.Render("No attachments configured"))
172		return nil
173	}
174
175	if jsonOut {
176		return writeJSON(out, map[string]any{"attachments": attachments})
177	}
178
179	// Sort targets for consistent output
180	targets := make([]string, 0, len(attachments))
181	for t := range attachments {
182		targets = append(targets, t)
183	}
184	sort.Strings(targets)
185
186	fmt.Println(styles.Theme.Title.Render("Attachments (local)"))
187	fmt.Println()
188
189	for _, t := range targets {
190		images := attachments[t]
191		fmt.Printf("%s\n", styles.Theme.Bold.Render(t))
192		for _, img := range images {
193			fmt.Printf("  %s\n", img)
194		}
195	}
196
197	return nil
198}
199
200// newAttachmentsAddCmd creates the attachments add command.
201func newAttachmentsAddCmd() *cobra.Command {
202	cmd := &cobra.Command{
203		Use:   "add <domain-or-group> <image>",
204		Short: "Add an attachment",
205		Long: `Add a container attachment to a domain or network group.
206
207Note: Attachments require network_isolation.enabled = true in your configuration.
208Without network isolation, containers use Docker's default bridge network which
209does not provide DNS resolution - your app won't be able to reach attachments
210by hostname (e.g., 'postgres:5432').
211
212Examples:
213  gordon attachments add app.example.com postgres:18
214  gordon attachments add backend redis:7-alpine
215  gordon --remote https://gordon.mydomain.com attachments add api.mydomain.com memcached:latest`,
216		Args: cobra.ExactArgs(2),
217		RunE: func(cmd *cobra.Command, args []string) error {
218			ctx := context.Background()
219			target := args[0]
220			image := args[1]
221
222			client, isRemote := GetRemoteClient()
223			if isRemote {
224				if err := client.AddAttachment(ctx, target, image); err != nil {
225					return fmt.Errorf("failed to add attachment: %w", err)
226				}
227			} else {
228				local, err := GetLocalServices(configPath)
229				if err != nil {
230					return fmt.Errorf("failed to initialize local services: %w", err)
231				}
232
233				// Warn if network isolation is disabled
234				if !local.GetConfigService().IsNetworkIsolationEnabled() {
235					fmt.Println(styles.RenderWarning("network_isolation.enabled = false"))
236					fmt.Println(styles.Theme.Muted.Render("  Attachments require network isolation for DNS resolution."))
237					fmt.Println(styles.Theme.Muted.Render("  Without it, your app cannot reach attachments by hostname."))
238					fmt.Println(styles.Theme.Muted.Render("  Enable with: [network_isolation] enabled = true"))
239					fmt.Println()
240				}
241
242				if err := local.GetConfigService().AddAttachment(ctx, target, image); err != nil {
243					return fmt.Errorf("failed to add attachment: %w", err)
244				}
245			}
246
247			fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment added: %s -> %s", target, image)))
248			return nil
249		},
250	}
251
252	return cmd
253}
254
255// newAttachmentsRemoveCmd creates the attachments remove command.
256func newAttachmentsRemoveCmd() *cobra.Command {
257	var force bool
258
259	cmd := &cobra.Command{
260		Use:   "remove <domain-or-group> <image>",
261		Short: "Remove an attachment",
262		Long: `Remove a container attachment from a domain or network group.
263
264Examples:
265  gordon attachments remove app.example.com postgres:15
266  gordon attachments remove backend redis:7-alpine --force`,
267		Args: cobra.ExactArgs(2),
268		RunE: func(cmd *cobra.Command, args []string) error {
269			ctx := context.Background()
270			target := args[0]
271			image := args[1]
272
273			// Confirm unless --force
274			if !force {
275				confirmed, err := components.RunConfirm(
276					fmt.Sprintf("Remove attachment '%s' from '%s'?", image, target),
277					components.WithDescription("This will remove the attachment from the configuration. The container will be stopped on next reload."),
278				)
279				if err != nil {
280					return err
281				}
282				if !confirmed {
283					fmt.Println(styles.Theme.Muted.Render("Cancelled"))
284					return nil
285				}
286			}
287
288			client, isRemote := GetRemoteClient()
289			if isRemote {
290				if err := client.RemoveAttachment(ctx, target, image); err != nil {
291					return fmt.Errorf("failed to remove attachment: %w", err)
292				}
293			} else {
294				local, err := GetLocalServices(configPath)
295				if err != nil {
296					return fmt.Errorf("failed to initialize local services: %w", err)
297				}
298				if err := local.GetConfigService().RemoveAttachment(ctx, target, image); err != nil {
299					return fmt.Errorf("failed to remove attachment: %w", err)
300				}
301			}
302
303			fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment removed: %s -> %s", target, image)))
304			return nil
305		},
306	}
307
308	cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
309
310	return cmd
311}

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