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}