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}