package cli import ( "context" "fmt" "io" "sort" "github.com/bnema/gordon/internal/adapters/in/cli/remote" "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" "github.com/bnema/gordon/internal/domain" "github.com/spf13/cobra" ) // newAttachmentsCmd creates the attachments command group. func newAttachmentsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "attachments", Aliases: []string{"attach"}, Short: "Manage container attachments", Long: `Manage container attachments (databases, caches, queues) in the configuration. Attachments are service dependencies that run alongside your application containers. They are defined per-domain or per-network-group in the configuration. When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var), these commands operate on the remote server. Otherwise, they require access to the local Gordon configuration.`, } cmd.AddCommand(newAttachmentsListCmd()) cmd.AddCommand(newAttachmentsAddCmd()) cmd.AddCommand(newAttachmentsRemoveCmd()) cmd.AddCommand(newAttachmentsOrphansCmd()) cmd.AddCommand(newAttachmentsPruneCmd()) cmd.AddCommand(newAttachmentsPushCmd()) return cmd } // newAttachmentsListCmd creates the attachments list command. func newAttachmentsListCmd() *cobra.Command { var jsonOut bool cmd := &cobra.Command{ Use: "list [domain-or-group]", Short: "List configured attachments", Long: `List all configured attachments, or attachments for a specific domain/group. Examples: gordon attachments list # List all attachments gordon attachments list app.example.com # List attachments for domain gordon attachments list backend # List attachments for network group`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() target := "" if len(args) > 0 { target = args[0] } if target != "" { handle, err := resolveControlPlaneForAttachmentTarget(ctx, target) if err != nil { return err } defer handle.close() return runAttachmentsList(ctx, handle.plane, target, jsonOut, cmd.OutOrStdout()) } client, isRemote := GetRemoteClient() if isRemote { return runAttachmentsListRemote(ctx, client, target, jsonOut, cmd.OutOrStdout()) } return runAttachmentsListLocal(ctx, configPath, target, jsonOut, cmd.OutOrStdout()) }, } cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") return cmd } func runAttachmentsList(ctx context.Context, cp ControlPlane, target string, jsonOut bool, out io.Writer) error { if target != "" { images, err := cp.GetAttachmentsConfig(ctx, target) if err != nil { return fmt.Errorf("failed to get attachments: %w", err) } if len(images) == 0 { _, err := fmt.Fprintf(out, "No attachments configured for '%s'\n", target) return err } if jsonOut { return writeJSON(out, map[string]any{"target": target, "images": images}) } return renderAttachmentTargetList(out, fmt.Sprintf("Attachments for %s", target), images) } attachments, err := cp.GetAllAttachmentsConfig(ctx) if err != nil { return fmt.Errorf("failed to list attachments: %w", err) } if len(attachments) == 0 { return cliWriteLine(out, styles.Theme.Muted.Render("No attachments configured")) } if jsonOut { return writeJSON(out, map[string]any{"attachments": attachments}) } return renderAllAttachmentsList(out, "Attachments", attachments) } func renderAttachmentTargetList(out io.Writer, title string, images []string) error { if err := cliWriteLine(out, cliRenderTitle(title)); err != nil { return err } if err := cliWriteLine(out, ""); err != nil { return err } for _, img := range images { if err := cliWritef(out, " %s\n", img); err != nil { return err } } return nil } func renderAllAttachmentsList(out io.Writer, title string, attachments map[string][]string) error { targets := make([]string, 0, len(attachments)) for t := range attachments { targets = append(targets, t) } sort.Strings(targets) if err := cliWriteLine(out, cliRenderTitle(title)); err != nil { return err } if err := cliWriteLine(out, ""); err != nil { return err } for _, t := range targets { images := attachments[t] if err := cliWritef(out, "%s\n", styles.Theme.Bold.Render(t)); err != nil { return err } for _, img := range images { if err := cliWritef(out, " %s\n", img); err != nil { return err } } } return nil } // runAttachmentsListRemote lists attachments from a remote Gordon instance. func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string, jsonOut bool, out io.Writer) error { if target != "" { // List for specific target images, err := client.GetAttachmentsConfig(ctx, target) if err != nil { return fmt.Errorf("failed to get attachments: %w", err) } if len(images) == 0 { fmt.Printf("No attachments configured for '%s'\n", target) return nil } if jsonOut { return writeJSON(out, map[string]any{"target": target, "images": images}) } fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target))) fmt.Println() for _, img := range images { fmt.Printf(" %s\n", img) } return nil } // List all attachments attachments, err := client.GetAllAttachmentsConfig(ctx) if err != nil { return fmt.Errorf("failed to list attachments: %w", err) } if len(attachments) == 0 { fmt.Println(styles.Theme.Muted.Render("No attachments configured")) return nil } if jsonOut { return writeJSON(out, map[string]any{"attachments": attachments}) } // Sort targets for consistent output targets := make([]string, 0, len(attachments)) for t := range attachments { targets = append(targets, t) } sort.Strings(targets) fmt.Println(styles.Theme.Title.Render("Attachments")) fmt.Println() for _, t := range targets { images := attachments[t] fmt.Printf("%s\n", styles.Theme.Bold.Render(t)) for _, img := range images { fmt.Printf(" %s\n", img) } } return nil } // runAttachmentsListLocal lists attachments from local configuration. func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string, jsonOut bool, out io.Writer) error { local, err := GetLocalServices(cfgPath) if err != nil { return fmt.Errorf("failed to initialize local services: %w", err) } if target != "" { // List for specific target images, err := local.GetConfigService().GetAttachmentsFor(ctx, target) if err != nil { return fmt.Errorf("failed to get attachments: %w", err) } if len(images) == 0 { fmt.Printf("No attachments configured for '%s'\n", target) return nil } if jsonOut { return writeJSON(out, map[string]any{"target": target, "images": images}) } fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s (local)", target))) fmt.Println() for _, img := range images { fmt.Printf(" %s\n", img) } return nil } // List all attachments attachments := local.GetConfigService().GetAllAttachments(ctx) if len(attachments) == 0 { fmt.Println(styles.Theme.Muted.Render("No attachments configured")) return nil } if jsonOut { return writeJSON(out, map[string]any{"attachments": attachments}) } // Sort targets for consistent output targets := make([]string, 0, len(attachments)) for t := range attachments { targets = append(targets, t) } sort.Strings(targets) fmt.Println(styles.Theme.Title.Render("Attachments (local)")) fmt.Println() for _, t := range targets { images := attachments[t] fmt.Printf("%s\n", styles.Theme.Bold.Render(t)) for _, img := range images { fmt.Printf(" %s\n", img) } } return nil } func newAttachmentsOrphansCmd() *cobra.Command { var jsonOut bool cmd := &cobra.Command{ Use: "orphans", Short: "List orphaned attachment containers", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { handle, err := resolveControlPlane(configPath) if err != nil { return err } defer handle.close() lister, ok := handle.plane.(interface { ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error) }) if !ok { return fmt.Errorf("attachment orphan listing is unavailable") } attachments, err := lister.ListOrphanedAttachments(cmd.Context()) if err != nil { return fmt.Errorf("failed to list orphaned attachments: %w", err) } if jsonOut { return writeJSON(cmd.OutOrStdout(), map[string]any{"attachments": attachments}) } return writeAttachmentOrphansText(cmd.OutOrStdout(), attachments) }, } cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") return cmd } func newAttachmentsPruneCmd() *cobra.Command { var jsonOut bool var stop bool cmd := &cobra.Command{ Use: "prune", Short: "Review or stop orphaned attachment containers", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { handle, err := resolveControlPlane(configPath) if err != nil { return err } defer handle.close() cleaner, ok := handle.plane.(interface { CleanupOrphanedAttachments(context.Context, string, bool) (*domain.CleanupReport, error) }) if !ok { return fmt.Errorf("attachment orphan cleanup is unavailable") } report, err := cleaner.CleanupOrphanedAttachments(cmd.Context(), "", stop) if err != nil { return fmt.Errorf("failed to cleanup orphaned attachments: %w", err) } if jsonOut { return writeJSON(cmd.OutOrStdout(), report) } return writeAttachmentPruneText(cmd.OutOrStdout(), report, stop) }, } cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") cmd.Flags().BoolVar(&stop, "stop", false, "Stop and remove orphaned attachment containers while preserving volumes") return cmd } func writeAttachmentOrphansText(out io.Writer, attachments []domain.CleanupAttachment) error { if len(attachments) == 0 { return cliWriteLine(out, styles.Theme.Muted.Render("No orphaned attachments found")) } if err := cliWriteLine(out, cliRenderTitle("Orphaned attachments")); err != nil { return err } for _, a := range attachments { label := a.Name if label == "" { label = a.ContainerID } if a.Image != "" { label += " -> " + a.Image } if err := cliWriteLine(out, " "+label); err != nil { return err } } return nil } func writeAttachmentPruneText(out io.Writer, report *domain.CleanupReport, stop bool) error { if report == nil { return nil } if !stop { if err := writeAttachmentOrphansText(out, report.PreservedAttachments); err != nil { return err } return writeCleanupMessages(out, report.Warnings, report.Hints) } if len(report.RemovedContainers) == 0 { if err := cliWriteLine(out, styles.Theme.Muted.Render("No orphaned attachments removed")); err != nil { return err } } else { if err := cliWriteLine(out, cliRenderSuccess("Removed orphaned attachment containers:")); err != nil { return err } for _, c := range report.RemovedContainers { label := c.Name if label == "" { label = c.ID } if err := cliWriteLine(out, " "+label); err != nil { return err } } } for _, failure := range report.PartialFailures { if err := cliWriteLine(out, cliRenderWarning(fmt.Sprintf("%s %s failed: %s", failure.Action, failure.Name, failure.Error))); err != nil { return err } } return writeCleanupMessages(out, report.Warnings, report.Hints) } // newAttachmentsAddCmd creates the attachments add command. func newAttachmentsAddCmd() *cobra.Command { cmd := &cobra.Command{ Use: "add ", Short: "Add an attachment", Long: `Add a container attachment to a domain or network group. Note: Attachments require network_isolation.enabled = true in your configuration. Without network isolation, containers use Docker's default bridge network which does not provide DNS resolution - your app won't be able to reach attachments by hostname (e.g., 'postgres:5432'). Examples: gordon attachments add app.example.com postgres:18 gordon attachments add backend redis:7-alpine gordon --remote https://gordon.mydomain.com attachments add api.mydomain.com memcached:latest`, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() target := args[0] image := args[1] handle, err := resolveControlPlaneForAttachmentTarget(ctx, target) if err != nil { return err } defer handle.close() if !handle.isRemote { local, err := GetLocalServices(configPath) if err != nil { return fmt.Errorf("failed to initialize local services: %w", err) } // Warn if network isolation is disabled if !local.GetConfigService().IsNetworkIsolationEnabled() { fmt.Println(styles.RenderWarning("network_isolation.enabled = false")) fmt.Println(styles.Theme.Muted.Render(" Attachments require network isolation for DNS resolution.")) fmt.Println(styles.Theme.Muted.Render(" Without it, your app cannot reach attachments by hostname.")) fmt.Println(styles.Theme.Muted.Render(" Enable with: [network_isolation] enabled = true")) fmt.Println() } } if err := handle.plane.AddAttachment(ctx, target, image); err != nil { return fmt.Errorf("failed to add attachment: %w", err) } fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment added: %s -> %s", target, image))) return nil }, } return cmd } // newAttachmentsRemoveCmd creates the attachments remove command. func newAttachmentsRemoveCmd() *cobra.Command { var force bool cmd := &cobra.Command{ Use: "remove ", Short: "Remove an attachment", Long: `Remove a container attachment from a domain or network group. Examples: gordon attachments remove app.example.com postgres:15 gordon attachments remove backend redis:7-alpine --force`, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() target := args[0] image := args[1] // Confirm unless --force if !force { confirmed, err := components.RunConfirm( fmt.Sprintf("Remove attachment '%s' from '%s'?", image, target), components.WithDescription("This will remove the attachment from the configuration. The container will be stopped on next reload."), ) if err != nil { return err } if !confirmed { fmt.Println(styles.Theme.Muted.Render("Cancelled")) return nil } } handle, err := resolveControlPlaneForAttachmentTarget(ctx, target) if err != nil { return err } defer handle.close() if err := handle.plane.RemoveAttachment(ctx, target, image); err != nil { return fmt.Errorf("failed to remove attachment: %w", err) } fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment removed: %s -> %s", target, image))) return nil }, } cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation") return cmd }