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