package cli import ( "context" "fmt" "strings" "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/spf13/cobra" ) // newSecretsCmd creates the secrets command group. func newSecretsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "secrets", Short: "Manage secrets", Long: `Manage secrets (environment variables) for routes and attachments. Secrets are stored per-domain and injected into containers as environment variables. Use --attachment to target attachment containers (databases, caches, etc.). When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var), these commands operate on the remote server.`, } cmd.AddCommand(newSecretsListCmd()) cmd.AddCommand(newSecretsSetCmd()) cmd.AddCommand(newSecretsRemoveCmd()) return cmd } // newSecretsListCmd creates the secrets list command. func newSecretsListCmd() *cobra.Command { var jsonOut bool cmd := &cobra.Command{ Use: "list ", Short: "List secrets for a domain", Long: `List all secret keys configured for a domain. Note: Only secret keys are shown, not values (for security). Attachment secrets (for services like databases) are also displayed. Examples: gordon secrets list app.mydomain.com gordon --remote https://gordon.mydomain.com secrets list api.mydomain.com`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runSecretsListCmd(cmd, args, jsonOut) }, } cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") return cmd } // runSecretsListCmd executes the secrets list command. func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error { ctx := cmd.Context() secretDomain := args[0] handle, err := resolveControlPlaneForRouteDomain(ctx, secretDomain) if err != nil { return err } defer handle.close() keys, attachments, err := fetchSecretsWithAttachments(ctx, handle.plane, secretDomain) if err != nil { return err } totalSecrets := len(keys) for _, att := range attachments { totalSecrets += len(att.Keys) } if totalSecrets == 0 { if jsonOut { return writeJSON(cmd.OutOrStdout(), map[string]any{ "domain": secretDomain, "keys": []string{}, "attachments": []any{}, }) } fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", secretDomain))) return nil } if jsonOut { if attachments == nil { attachments = []remote.AttachmentSecrets{} } return writeJSON(cmd.OutOrStdout(), map[string]any{ "domain": secretDomain, "keys": keys, "attachments": attachments, }) } title := fmt.Sprintf("Secrets for %s", secretDomain) if !handle.isRemote { title = fmt.Sprintf("Secrets for %s (local)", secretDomain) } fmt.Println(styles.Theme.Title.Render(title)) fmt.Println() rows := buildSecretsTableRows(keys, attachments) table := components.NewTable( components.WithColumns([]components.TableColumn{ {Title: "Key", Width: 45}, {Title: "Value", Width: 10}, }), components.WithRows(rows), ) fmt.Println(table.View()) return nil } // fetchSecretsWithAttachments retrieves secrets from the selected control plane. func fetchSecretsWithAttachments(ctx context.Context, cp ControlPlane, secretDomain string) ([]string, []remote.AttachmentSecrets, error) { result, err := cp.ListSecretsWithAttachments(ctx, secretDomain) if err != nil { return nil, nil, fmt.Errorf("failed to list secrets: %w", err) } return result.Keys, result.Attachments, nil } // buildSecretsTableRows builds table rows with tree structure for attachments. func buildSecretsTableRows(keys []string, attachments []remote.AttachmentSecrets) [][]string { var rows [][]string // Domain secrets first for _, key := range keys { rows = append(rows, []string{key, styles.Theme.Muted.Render("(hidden)")}) } // Attachment secrets with tree structure for i, att := range attachments { isLastAttachment := i == len(attachments)-1 rows = append(rows, buildAttachmentRows(att, isLastAttachment)...) } return rows } // buildAttachmentRows builds table rows for a single attachment with tree structure. func buildAttachmentRows(att remote.AttachmentSecrets, isLastAttachment bool) [][]string { var rows [][]string // Attachment header with tree prefix prefix := styles.IconTreeBranch + styles.IconTreeLine if isLastAttachment { prefix = styles.IconTreeLast + styles.IconTreeLine } serviceName := extractServiceName(att.Service) attachmentHeader := fmt.Sprintf("%s %s", prefix, styles.Theme.Muted.Render(fmt.Sprintf("[%s]", serviceName))) rows = append(rows, []string{attachmentHeader, ""}) // Keys for this attachment with nested tree structure for j, key := range att.Keys { isLastKey := j == len(att.Keys)-1 keyPrefix := getKeyPrefix(isLastAttachment, isLastKey) rows = append(rows, []string{keyPrefix + " " + key, styles.Theme.Muted.Render("(hidden)")}) } return rows } // extractServiceName extracts a short service name from a container name. // e.g., "gordon-git-bnema-dev-gitea-postgres" → "gitea-postgres" func extractServiceName(containerName string) string { if !strings.HasPrefix(containerName, "gordon-") { return containerName } parts := strings.SplitN(containerName, "-", 2) if len(parts) <= 1 { return containerName } serviceName := parts[1] allParts := strings.Split(serviceName, "-") // Handle service names based on the number of segments explicitly if len(allParts) < 2 { // No additional segments; use the service name as-is. return serviceName } if len(allParts) == 2 { // Exactly two segments; the service name is already in the desired form. return strings.Join(allParts, "-") } // More than two segments: take the last two as the short service name (e.g., "gitea-postgres"). return strings.Join(allParts[len(allParts)-2:], "-") } // getKeyPrefix returns the tree prefix for a key based on its position. func getKeyPrefix(isLastAttachment, isLastKey bool) string { if isLastAttachment { // Parent is last, use space continuation if isLastKey { return " " + styles.IconTreeLast + styles.IconTreeLine } return " " + styles.IconTreeBranch + styles.IconTreeLine } // Parent has siblings, use vertical line continuation if isLastKey { return styles.IconTreeVert + " " + styles.IconTreeLast + styles.IconTreeLine } return styles.IconTreeVert + " " + styles.IconTreeBranch + styles.IconTreeLine } // newSecretsSetCmd creates the secrets set command. func newSecretsSetCmd() *cobra.Command { var attachment, fromFile string var jsonOut bool cmd := &cobra.Command{ Use: "set --from-file ", Short: "Set secrets for a domain or attachment", Long: `Set one or more secrets for a domain or an attachment container. Secrets are read from a mode 0600 file containing one KEY=value pair per line. Use --attachment to target an attachment service (e.g., postgres, redis) instead of the main domain container. Examples: gordon secrets set app.mydomain.com --from-file ./app.env gordon secrets set app.mydomain.com --attachment postgres --from-file ./postgres.env`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() secretDomain := args[0] content, err := readProtectedSecretFile(fromFile) if err != nil { return fmt.Errorf("read secrets: %w", err) } secrets := make(map[string]string) for _, pair := range strings.Split(content, "\n") { parts := strings.SplitN(pair, "=", 2) if len(parts) != 2 { return fmt.Errorf("invalid format: %s (expected KEY=value)", pair) } secrets[parts[0]] = parts[1] } handle, err := resolveControlPlaneForRouteDomain(ctx, secretDomain) if err != nil { return err } defer handle.close() if attachment != "" { if err := handle.plane.SetAttachmentSecrets(ctx, secretDomain, attachment, secrets); err != nil { return fmt.Errorf("failed to set secrets: %w", err) } } else { if err := handle.plane.SetSecrets(ctx, secretDomain, secrets); err != nil { return fmt.Errorf("failed to set secrets: %w", err) } } target := secretDomain if attachment != "" { target = fmt.Sprintf("%s [%s]", secretDomain, attachment) } if jsonOut { return writeJSON(cmd.OutOrStdout(), map[string]any{ "domain": secretDomain, "attachment": attachment, "count": len(secrets), "updated": true, }) } if len(secrets) == 1 { for key := range secrets { return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target))) } } return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target))) }, } cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)") cmd.Flags().StringVar(&fromFile, "from-file", "", "Read KEY=value lines from a mode 0600 file") cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") _ = cmd.MarkFlagRequired("from-file") return cmd } // newSecretsRemoveCmd creates the secrets remove command. func newSecretsRemoveCmd() *cobra.Command { var ( force bool attachment string ) cmd := &cobra.Command{ Use: "remove ", Short: "Remove a secret", Long: `Remove a secret from a domain or an attachment container. Use --attachment to target an attachment service (e.g., postgres, redis) instead of the main domain container. Examples: gordon secrets remove app.mydomain.com OLD_API_KEY gordon secrets remove app.mydomain.com OLD_API_KEY --force gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD`, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() secretDomain := args[0] key := args[1] target := secretDomain if attachment != "" { target = fmt.Sprintf("%s [%s]", secretDomain, attachment) } // Confirm unless --force if !force { confirmed, err := components.RunConfirm( fmt.Sprintf("Remove secret '%s' from %s?", key, target), ) if err != nil { return err } if !confirmed { fmt.Println(styles.Theme.Muted.Render("Cancelled")) return nil } } handle, err := resolveControlPlaneForRouteDomain(ctx, secretDomain) if err != nil { return err } defer handle.close() if attachment != "" { if err := handle.plane.DeleteAttachmentSecret(ctx, secretDomain, attachment, key); err != nil { return fmt.Errorf("failed to remove secret: %w", err) } } else { if err := handle.plane.DeleteSecret(ctx, secretDomain, key); err != nil { return fmt.Errorf("failed to remove secret: %w", err) } } fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed from %s: %s", target, key))) return nil }, } cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation") cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)") return cmd }