1package cli
2
3import (
4 "context"
5 "fmt"
6 "sort"
7
8 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
9 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
10 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
11
12 "github.com/spf13/cobra"
13)
14
15// newAttachmentsCmd creates the attachments command group.
16func newAttachmentsCmd() *cobra.Command {
17 cmd := &cobra.Command{
18 Use: "attachments",
19 Aliases: []string{"attach"},
20 Short: "Manage container attachments",
21 Long: `Manage container attachments (databases, caches, queues) in the configuration.
22
23Attachments are service dependencies that run alongside your application containers.
24They are defined per-domain or per-network-group in the configuration.
25
26When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
27these commands operate on the remote server. Otherwise, they require access to
28the local Gordon configuration.`,
29 }
30
31 cmd.AddCommand(newAttachmentsListCmd())
32 cmd.AddCommand(newAttachmentsAddCmd())
33 cmd.AddCommand(newAttachmentsRemoveCmd())
34
35 return cmd
36}
37
38// newAttachmentsListCmd creates the attachments list command.
39func newAttachmentsListCmd() *cobra.Command {
40 return &cobra.Command{
41 Use: "list [domain-or-group]",
42 Short: "List configured attachments",
43 Long: `List all configured attachments, or attachments for a specific domain/group.
44
45Examples:
46 gordon attachments list # List all attachments
47 gordon attachments list app.example.com # List attachments for domain
48 gordon attachments list backend # List attachments for network group`,
49 Args: cobra.MaximumNArgs(1),
50 RunE: func(cmd *cobra.Command, args []string) error {
51 ctx := context.Background()
52 target := ""
53 if len(args) > 0 {
54 target = args[0]
55 }
56
57 client, isRemote := GetRemoteClient()
58 if isRemote {
59 return runAttachmentsListRemote(ctx, client, target)
60 }
61 return runAttachmentsListLocal(ctx, configPath, target)
62 },
63 }
64}
65
66// runAttachmentsListRemote lists attachments from a remote Gordon instance.
67func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string) error {
68 if target != "" {
69 // List for specific target
70 images, err := client.GetAttachmentsConfig(ctx, target)
71 if err != nil {
72 return fmt.Errorf("failed to get attachments: %w", err)
73 }
74
75 if len(images) == 0 {
76 fmt.Printf("No attachments configured for '%s'\n", target)
77 return nil
78 }
79
80 fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target)))
81 fmt.Println()
82 for _, img := range images {
83 fmt.Printf(" %s\n", img)
84 }
85 return nil
86 }
87
88 // List all attachments
89 attachments, err := client.GetAllAttachmentsConfig(ctx)
90 if err != nil {
91 return fmt.Errorf("failed to list attachments: %w", err)
92 }
93
94 if len(attachments) == 0 {
95 fmt.Println(styles.Theme.Muted.Render("No attachments configured"))
96 return nil
97 }
98
99 // Sort targets for consistent output
100 targets := make([]string, 0, len(attachments))
101 for t := range attachments {
102 targets = append(targets, t)
103 }
104 sort.Strings(targets)
105
106 fmt.Println(styles.Theme.Title.Render("Attachments"))
107 fmt.Println()
108
109 for _, t := range targets {
110 images := attachments[t]
111 fmt.Printf("%s\n", styles.Theme.Bold.Render(t))
112 for _, img := range images {
113 fmt.Printf(" %s\n", img)
114 }
115 }
116
117 return nil
118}
119
120// runAttachmentsListLocal lists attachments from local configuration.
121func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string) error {
122 local, err := GetLocalServices(cfgPath)
123 if err != nil {
124 return fmt.Errorf("failed to initialize local services: %w", err)
125 }
126
127 if target != "" {
128 // List for specific target
129 images, err := local.GetConfigService().GetAttachmentsFor(ctx, target)
130 if err != nil {
131 return fmt.Errorf("failed to get attachments: %w", err)
132 }
133
134 if len(images) == 0 {
135 fmt.Printf("No attachments configured for '%s'\n", target)
136 return nil
137 }
138
139 fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s (local)", target)))
140 fmt.Println()
141 for _, img := range images {
142 fmt.Printf(" %s\n", img)
143 }
144 return nil
145 }
146
147 // List all attachments
148 attachments := local.GetConfigService().GetAllAttachments(ctx)
149
150 if len(attachments) == 0 {
151 fmt.Println(styles.Theme.Muted.Render("No attachments configured"))
152 return nil
153 }
154
155 // Sort targets for consistent output
156 targets := make([]string, 0, len(attachments))
157 for t := range attachments {
158 targets = append(targets, t)
159 }
160 sort.Strings(targets)
161
162 fmt.Println(styles.Theme.Title.Render("Attachments (local)"))
163 fmt.Println()
164
165 for _, t := range targets {
166 images := attachments[t]
167 fmt.Printf("%s\n", styles.Theme.Bold.Render(t))
168 for _, img := range images {
169 fmt.Printf(" %s\n", img)
170 }
171 }
172
173 return nil
174}
175
176// newAttachmentsAddCmd creates the attachments add command.
177func newAttachmentsAddCmd() *cobra.Command {
178 cmd := &cobra.Command{
179 Use: "add <domain-or-group> <image>",
180 Short: "Add an attachment",
181 Long: `Add a container attachment to a domain or network group.
182
183Note: Attachments require network_isolation.enabled = true in your configuration.
184Without network isolation, containers use Docker's default bridge network which
185does not provide DNS resolution - your app won't be able to reach attachments
186by hostname (e.g., 'postgres:5432').
187
188Examples:
189 gordon attachments add app.example.com postgres:18
190 gordon attachments add backend redis:7-alpine
191 gordon --remote https://gordon.mydomain.com attachments add api.mydomain.com memcached:latest`,
192 Args: cobra.ExactArgs(2),
193 RunE: func(cmd *cobra.Command, args []string) error {
194 ctx := context.Background()
195 target := args[0]
196 image := args[1]
197
198 client, isRemote := GetRemoteClient()
199 if isRemote {
200 if err := client.AddAttachment(ctx, target, image); err != nil {
201 return fmt.Errorf("failed to add attachment: %w", err)
202 }
203 } else {
204 local, err := GetLocalServices(configPath)
205 if err != nil {
206 return fmt.Errorf("failed to initialize local services: %w", err)
207 }
208
209 // Warn if network isolation is disabled
210 if !local.GetConfigService().IsNetworkIsolationEnabled() {
211 fmt.Println(styles.RenderWarning("network_isolation.enabled = false"))
212 fmt.Println(styles.Theme.Muted.Render(" Attachments require network isolation for DNS resolution."))
213 fmt.Println(styles.Theme.Muted.Render(" Without it, your app cannot reach attachments by hostname."))
214 fmt.Println(styles.Theme.Muted.Render(" Enable with: [network_isolation] enabled = true"))
215 fmt.Println()
216 }
217
218 if err := local.GetConfigService().AddAttachment(ctx, target, image); err != nil {
219 return fmt.Errorf("failed to add attachment: %w", err)
220 }
221 }
222
223 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment added: %s -> %s", target, image)))
224 return nil
225 },
226 }
227
228 return cmd
229}
230
231// newAttachmentsRemoveCmd creates the attachments remove command.
232func newAttachmentsRemoveCmd() *cobra.Command {
233 var force bool
234
235 cmd := &cobra.Command{
236 Use: "remove <domain-or-group> <image>",
237 Short: "Remove an attachment",
238 Long: `Remove a container attachment from a domain or network group.
239
240Examples:
241 gordon attachments remove app.example.com postgres:15
242 gordon attachments remove backend redis:7-alpine --force`,
243 Args: cobra.ExactArgs(2),
244 RunE: func(cmd *cobra.Command, args []string) error {
245 ctx := context.Background()
246 target := args[0]
247 image := args[1]
248
249 // Confirm unless --force
250 if !force {
251 confirmed, err := components.RunConfirm(
252 fmt.Sprintf("Remove attachment '%s' from '%s'?", image, target),
253 components.WithDescription("This will remove the attachment from the configuration. The container will be stopped on next reload."),
254 )
255 if err != nil {
256 return err
257 }
258 if !confirmed {
259 fmt.Println(styles.Theme.Muted.Render("Cancelled"))
260 return nil
261 }
262 }
263
264 client, isRemote := GetRemoteClient()
265 if isRemote {
266 if err := client.RemoveAttachment(ctx, target, image); err != nil {
267 return fmt.Errorf("failed to remove attachment: %w", err)
268 }
269 } else {
270 local, err := GetLocalServices(configPath)
271 if err != nil {
272 return fmt.Errorf("failed to initialize local services: %w", err)
273 }
274 if err := local.GetConfigService().RemoveAttachment(ctx, target, image); err != nil {
275 return fmt.Errorf("failed to remove attachment: %w", err)
276 }
277 }
278
279 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment removed: %s -> %s", target, image)))
280 return nil
281 },
282 }
283
284 cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
285
286 return cmd
287}