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}