feat(cli): wire preview commands — build/push, list, delete, extend
Connect the preview CLI scaffold to the server. The `preview create`
command reuses push infrastructure (build, tag with preview-{name},
push to registry) and lets the server-side autopreview handler create
the route and container. List/delete/extend call the admin API via
the remote client.- commit
- 13bd0f09a8e5b08532d33cf47bae19f953abb536
- author
- brice · · 2026-03-19 05:39 UTC
- parent
- a4b282f
- signature
- unsigned
2 files · +211 -20
M internal/adapters/in/cli/preview.go +155 -20
| @@ -1,12 +1,18 @@ | |||
| 1 | 1 | package cli | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "encoding/json" | |
| 4 | 5 | "fmt" | |
| 6 | + | "os" | |
| 5 | 7 | "os/exec" | |
| 6 | 8 | "strings" | |
| 9 | + | "text/tabwriter" | |
| 10 | + | "time" | |
| 7 | 11 | ||
| 8 | - | "github.com/bnema/gordon/internal/usecase/auto/preview" | |
| 9 | 12 | "github.com/spf13/cobra" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 15 | + | "github.com/bnema/gordon/internal/usecase/auto/preview" | |
| 10 | 16 | ) | |
| 11 | 17 | ||
| 12 | 18 | func newPreviewCmd() *cobra.Command { | |
| @@ -34,15 +40,22 @@ func newPreviewCmd() *cobra.Command { | |||
| 34 | 40 | } | |
| 35 | 41 | ||
| 36 | 42 | func newPreviewCreateCmd() *cobra.Command { | |
| 37 | - | var ttl string | |
| 38 | - | var noBuild bool | |
| 39 | - | var noData bool | |
| 43 | + | var ( | |
| 44 | + | ttl string | |
| 45 | + | noBuild bool | |
| 46 | + | noData bool | |
| 47 | + | platform string | |
| 48 | + | ) | |
| 40 | 49 | ||
| 41 | 50 | cmd := &cobra.Command{ | |
| 42 | 51 | Use: "create [name]", | |
| 43 | 52 | Short: "Create a preview environment", | |
| 44 | 53 | Args: cobra.MaximumNArgs(1), | |
| 45 | 54 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 55 | + | ctx := cmd.Context() | |
| 56 | + | out := cmd.OutOrStdout() | |
| 57 | + | ||
| 58 | + | // Resolve preview name from arg or git branch. | |
| 46 | 59 | name := "" | |
| 47 | 60 | if len(args) > 0 { | |
| 48 | 61 | name = args[0] | |
| @@ -54,12 +67,71 @@ func newPreviewCreateCmd() *cobra.Command { | |||
| 54 | 67 | name = preview.SanitizeBranchName(branch) | |
| 55 | 68 | } | |
| 56 | 69 | ||
| 57 | - | fmt.Fprintf(cmd.OutOrStdout(), "Creating preview: %s\n", name) | |
| 58 | - | // TODO: Reuse push mechanics (build, tag with preview-{name}, push) | |
| 59 | - | // TODO: Wire to remote API or local service | |
| 60 | - | _ = ttl | |
| 61 | - | _ = noBuild | |
| 62 | - | _ = noData | |
| 70 | + | previewTag := "preview-" + name | |
| 71 | + | ||
| 72 | + | fmt.Fprintf(out, "Creating preview: %s\n", styles.Theme.Bold.Render(name)) | |
| 73 | + | ||
| 74 | + | // Resolve control plane (remote or local) — same as push. | |
| 75 | + | handle, err := resolveControlPlane(configPath) | |
| 76 | + | if err != nil { | |
| 77 | + | return err | |
| 78 | + | } | |
| 79 | + | defer handle.close() | |
| 80 | + | ||
| 81 | + | // Detect image name and registry using the same logic as push. | |
| 82 | + | dockerfile := "Dockerfile" | |
| 83 | + | imageName, err := detectImageName(dockerfile) | |
| 84 | + | if err != nil { | |
| 85 | + | return err | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | fmt.Fprintf(out, "Image: %s\n", styles.Theme.Bold.Render(imageName)) | |
| 89 | + | ||
| 90 | + | // Resolve registry from routes. | |
| 91 | + | registry, _, _, err := resolveRoute(ctx, handle.plane, "", "", dockerfile) | |
| 92 | + | if err != nil { | |
| 93 | + | return fmt.Errorf("failed to resolve registry: %w", err) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // Build the image ref with the preview tag. | |
| 97 | + | previewRef := fmt.Sprintf("%s/%s:%s", registry, imageName, previewTag) | |
| 98 | + | fmt.Fprintf(out, "Tag: %s\n", styles.Theme.Bold.Render(previewRef)) | |
| 99 | + | ||
| 100 | + | // Build and push. | |
| 101 | + | imageOps, err := newImageOpsFn() | |
| 102 | + | if err != nil { | |
| 103 | + | return err | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | if !noBuild { | |
| 107 | + | if _, statErr := os.Stat(dockerfile); os.IsNotExist(statErr) { | |
| 108 | + | return fmt.Errorf("dockerfile not found: %s", dockerfile) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | buildPlatform := platform | |
| 112 | + | buildArgs := buildImageArgs(ctx, previewTag, buildPlatform, dockerfile, nil, previewRef, previewRef) | |
| 113 | + | fmt.Fprintln(out, "\nBuilding image...") | |
| 114 | + | if err := imageOps.Build(ctx, buildArgs); err != nil { | |
| 115 | + | return err | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | fmt.Fprintln(out, "Pushing...") | |
| 120 | + | if err := imageOps.Push(ctx, previewRef); err != nil { | |
| 121 | + | return fmt.Errorf("failed to push %s: %w", previewRef, err) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | fmt.Fprintln(out, styles.RenderSuccess("Push complete")) | |
| 125 | + | fmt.Fprintf(out, "Preview %s will be created by the server's autopreview handler.\n", | |
| 126 | + | styles.Theme.Bold.Render(name)) | |
| 127 | + | ||
| 128 | + | if ttl != "" { | |
| 129 | + | fmt.Fprintf(out, "Requested TTL: %s (server config controls actual TTL)\n", ttl) | |
| 130 | + | } | |
| 131 | + | if noData { | |
| 132 | + | fmt.Fprintf(out, "Data copy: skipped (--no-data)\n") | |
| 133 | + | } | |
| 134 | + | ||
| 63 | 135 | return nil | |
| 64 | 136 | }, | |
| 65 | 137 | } | |
| @@ -67,6 +139,7 @@ func newPreviewCreateCmd() *cobra.Command { | |||
| 67 | 139 | cmd.Flags().StringVar(&ttl, "ttl", "", "Override TTL (e.g., 72h)") | |
| 68 | 140 | cmd.Flags().BoolVar(&noBuild, "no-build", false, "Skip image build") | |
| 69 | 141 | cmd.Flags().BoolVar(&noData, "no-data", false, "Skip volume cloning") | |
| 142 | + | cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform for build") | |
| 70 | 143 | return cmd | |
| 71 | 144 | } | |
| 72 | 145 | ||
| @@ -76,14 +149,52 @@ func newPreviewListCmd() *cobra.Command { | |||
| 76 | 149 | Use: "list", | |
| 77 | 150 | Short: "List active preview environments", | |
| 78 | 151 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 79 | - | // TODO: Wire to local service or admin API | |
| 152 | + | ctx := cmd.Context() | |
| 153 | + | out := cmd.OutOrStdout() | |
| 154 | + | ||
| 155 | + | client, isRemote := GetRemoteClient() | |
| 156 | + | if !isRemote { | |
| 157 | + | return fmt.Errorf("preview list requires --remote (local preview listing is not yet supported)") | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | previews, err := client.ListPreviews(ctx) | |
| 161 | + | if err != nil { | |
| 162 | + | return fmt.Errorf("failed to list previews: %w", err) | |
| 163 | + | } | |
| 164 | + | ||
| 80 | 165 | if jsonOutput { | |
| 81 | - | // Output empty JSON array for now (will be wired later) | |
| 82 | - | fmt.Fprintln(cmd.OutOrStdout(), "[]") | |
| 83 | - | } else { | |
| 84 | - | fmt.Fprintln(cmd.OutOrStdout(), "No active previews") | |
| 166 | + | enc := json.NewEncoder(out) | |
| 167 | + | enc.SetIndent("", " ") | |
| 168 | + | return enc.Encode(previews) | |
| 85 | 169 | } | |
| 86 | - | return nil | |
| 170 | + | ||
| 171 | + | if len(previews) == 0 { | |
| 172 | + | fmt.Fprintln(out, "No active previews") | |
| 173 | + | return nil | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) | |
| 177 | + | if _, err := fmt.Fprintln(w, "NAME\tDOMAIN\tIMAGE\tCREATED\tEXPIRES"); err != nil { | |
| 178 | + | return err | |
| 179 | + | } | |
| 180 | + | now := time.Now() | |
| 181 | + | for _, p := range previews { | |
| 182 | + | remaining := time.Until(p.ExpiresAt).Truncate(time.Minute) | |
| 183 | + | expiresStr := remaining.String() | |
| 184 | + | if p.IsExpired(now) { | |
| 185 | + | expiresStr = "expired" | |
| 186 | + | } | |
| 187 | + | if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", | |
| 188 | + | p.Name, | |
| 189 | + | p.Domain, | |
| 190 | + | truncateImage(p.Image, 40), | |
| 191 | + | p.CreatedAt.Format(time.DateTime), | |
| 192 | + | expiresStr, | |
| 193 | + | ); err != nil { | |
| 194 | + | return err | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | return w.Flush() | |
| 87 | 198 | }, | |
| 88 | 199 | } | |
| 89 | 200 | cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") | |
| @@ -96,8 +207,20 @@ func newPreviewDeleteCmd() *cobra.Command { | |||
| 96 | 207 | Short: "Delete a preview environment", | |
| 97 | 208 | Args: cobra.ExactArgs(1), | |
| 98 | 209 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 99 | - | // TODO: Wire to local service or admin API | |
| 100 | - | fmt.Fprintf(cmd.OutOrStdout(), "Deleting preview: %s\n", args[0]) | |
| 210 | + | ctx := cmd.Context() | |
| 211 | + | out := cmd.OutOrStdout() | |
| 212 | + | name := args[0] | |
| 213 | + | ||
| 214 | + | client, isRemote := GetRemoteClient() | |
| 215 | + | if !isRemote { | |
| 216 | + | return fmt.Errorf("preview delete requires --remote (local preview deletion is not yet supported)") | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | if err := client.DeletePreview(ctx, name); err != nil { | |
| 220 | + | return fmt.Errorf("failed to delete preview %q: %w", name, err) | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | fmt.Fprintln(out, styles.RenderSuccess(fmt.Sprintf("Preview %q deleted", name))) | |
| 101 | 224 | return nil | |
| 102 | 225 | }, | |
| 103 | 226 | } | |
| @@ -110,8 +233,20 @@ func newPreviewExtendCmd() *cobra.Command { | |||
| 110 | 233 | Short: "Extend a preview environment's TTL", | |
| 111 | 234 | Args: cobra.ExactArgs(1), | |
| 112 | 235 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 113 | - | // TODO: Wire to local service or admin API | |
| 114 | - | fmt.Fprintf(cmd.OutOrStdout(), "Extending preview %s by %s\n", args[0], ttl) | |
| 236 | + | ctx := cmd.Context() | |
| 237 | + | out := cmd.OutOrStdout() | |
| 238 | + | name := args[0] | |
| 239 | + | ||
| 240 | + | client, isRemote := GetRemoteClient() | |
| 241 | + | if !isRemote { | |
| 242 | + | return fmt.Errorf("preview extend requires --remote (local preview extend is not yet supported)") | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | if err := client.ExtendPreview(ctx, name, ttl); err != nil { | |
| 246 | + | return fmt.Errorf("failed to extend preview %q: %w", name, err) | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | fmt.Fprintln(out, styles.RenderSuccess(fmt.Sprintf("Preview %q extended by %s", name, ttl))) | |
| 115 | 250 | return nil | |
| 116 | 251 | }, | |
| 117 | 252 | } | |
| @@ -1,12 +1,18 @@ | |||||
| 1 | package cli | 1 | package cli | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "encoding/json" | |||
| 4 | "fmt" | 5 | "fmt" | ||
| 6 | + | "os" | |||
| 5 | "os/exec" | 7 | "os/exec" | ||
| 6 | "strings" | 8 | "strings" | ||
| 9 | + | "text/tabwriter" | |||
| 10 | + | "time" | |||
| 7 | 11 | ||||
| 8 | - | "github.com/bnema/gordon/internal/usecase/auto/preview" | |||
| 9 | "github.com/spf13/cobra" | 12 | "github.com/spf13/cobra" | ||
| 13 | + | ||||
| 14 | + | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |||
| 15 | + | "github.com/bnema/gordon/internal/usecase/auto/preview" | |||
| 10 | ) | 16 | ) | ||
| 11 | 17 | ||||
| 12 | func newPreviewCmd() *cobra.Command { | 18 | func newPreviewCmd() *cobra.Command { | ||
| @@ -34,15 +40,22 @@ func newPreviewCmd() *cobra.Command { | |||||
| 34 | } | 40 | } | ||
| 35 | 41 | ||||
| 36 | func newPreviewCreateCmd() *cobra.Command { | 42 | func newPreviewCreateCmd() *cobra.Command { | ||
| 37 | - | var ttl string | 43 | + | var ( |
| 38 | - | var noBuild bool | 44 | + | ttl string |
| 39 | - | var noData bool | 45 | + | noBuild bool |
| 46 | + | noData bool | |||
| 47 | + | platform string | |||
| 48 | + | ) | |||
| 40 | 49 | ||||
| 41 | cmd := &cobra.Command{ | 50 | cmd := &cobra.Command{ | ||
| 42 | Use: "create [name]", | 51 | Use: "create [name]", | ||
| 43 | Short: "Create a preview environment", | 52 | Short: "Create a preview environment", | ||
| 44 | Args: cobra.MaximumNArgs(1), | 53 | Args: cobra.MaximumNArgs(1), | ||
| 45 | RunE: func(cmd *cobra.Command, args []string) error { | 54 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 55 | + | ctx := cmd.Context() | |||
| 56 | + | out := cmd.OutOrStdout() | |||
| 57 | + | ||||
| 58 | + | // Resolve preview name from arg or git branch. | |||
| 46 | name := "" | 59 | name := "" | ||
| 47 | if len(args) > 0 { | 60 | if len(args) > 0 { | ||
| 48 | name = args[0] | 61 | name = args[0] | ||
| @@ -54,12 +67,71 @@ func newPreviewCreateCmd() *cobra.Command { | |||||
| 54 | name = preview.SanitizeBranchName(branch) | 67 | name = preview.SanitizeBranchName(branch) | ||
| 55 | } | 68 | } | ||
| 56 | 69 | ||||
| 57 | - | fmt.Fprintf(cmd.OutOrStdout(), "Creating preview: %s\n", name) | 70 | + | previewTag := "preview-" + name |
| 58 | - | // TODO: Reuse push mechanics (build, tag with preview-{name}, push) | 71 | + | |
| 59 | - | // TODO: Wire to remote API or local service | 72 | + | fmt.Fprintf(out, "Creating preview: %s\n", styles.Theme.Bold.Render(name)) |
| 60 | - | _ = ttl | 73 | + | |
| 61 | - | _ = noBuild | 74 | + | // Resolve control plane (remote or local) — same as push. |
| 62 | - | _ = noData | 75 | + | handle, err := resolveControlPlane(configPath) |
| 76 | + | if err != nil { | |||
| 77 | + | return err | |||
| 78 | + | } | |||
| 79 | + | defer handle.close() | |||
| 80 | + | ||||
| 81 | + | // Detect image name and registry using the same logic as push. | |||
| 82 | + | dockerfile := "Dockerfile" | |||
| 83 | + | imageName, err := detectImageName(dockerfile) | |||
| 84 | + | if err != nil { | |||
| 85 | + | return err | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | fmt.Fprintf(out, "Image: %s\n", styles.Theme.Bold.Render(imageName)) | |||
| 89 | + | ||||
| 90 | + | // Resolve registry from routes. | |||
| 91 | + | registry, _, _, err := resolveRoute(ctx, handle.plane, "", "", dockerfile) | |||
| 92 | + | if err != nil { | |||
| 93 | + | return fmt.Errorf("failed to resolve registry: %w", err) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // Build the image ref with the preview tag. | |||
| 97 | + | previewRef := fmt.Sprintf("%s/%s:%s", registry, imageName, previewTag) | |||
| 98 | + | fmt.Fprintf(out, "Tag: %s\n", styles.Theme.Bold.Render(previewRef)) | |||
| 99 | + | ||||
| 100 | + | // Build and push. | |||
| 101 | + | imageOps, err := newImageOpsFn() | |||
| 102 | + | if err != nil { | |||
| 103 | + | return err | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | if !noBuild { | |||
| 107 | + | if _, statErr := os.Stat(dockerfile); os.IsNotExist(statErr) { | |||
| 108 | + | return fmt.Errorf("dockerfile not found: %s", dockerfile) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | buildPlatform := platform | |||
| 112 | + | buildArgs := buildImageArgs(ctx, previewTag, buildPlatform, dockerfile, nil, previewRef, previewRef) | |||
| 113 | + | fmt.Fprintln(out, "\nBuilding image...") | |||
| 114 | + | if err := imageOps.Build(ctx, buildArgs); err != nil { | |||
| 115 | + | return err | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | fmt.Fprintln(out, "Pushing...") | |||
| 120 | + | if err := imageOps.Push(ctx, previewRef); err != nil { | |||
| 121 | + | return fmt.Errorf("failed to push %s: %w", previewRef, err) | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | fmt.Fprintln(out, styles.RenderSuccess("Push complete")) | |||
| 125 | + | fmt.Fprintf(out, "Preview %s will be created by the server's autopreview handler.\n", | |||
| 126 | + | styles.Theme.Bold.Render(name)) | |||
| 127 | + | ||||
| 128 | + | if ttl != "" { | |||
| 129 | + | fmt.Fprintf(out, "Requested TTL: %s (server config controls actual TTL)\n", ttl) | |||
| 130 | + | } | |||
| 131 | + | if noData { | |||
| 132 | + | fmt.Fprintf(out, "Data copy: skipped (--no-data)\n") | |||
| 133 | + | } | |||
| 134 | + | ||||
| 63 | return nil | 135 | return nil | ||
| 64 | }, | 136 | }, | ||
| 65 | } | 137 | } | ||
| @@ -67,6 +139,7 @@ func newPreviewCreateCmd() *cobra.Command { | |||||
| 67 | cmd.Flags().StringVar(&ttl, "ttl", "", "Override TTL (e.g., 72h)") | 139 | cmd.Flags().StringVar(&ttl, "ttl", "", "Override TTL (e.g., 72h)") | ||
| 68 | cmd.Flags().BoolVar(&noBuild, "no-build", false, "Skip image build") | 140 | cmd.Flags().BoolVar(&noBuild, "no-build", false, "Skip image build") | ||
| 69 | cmd.Flags().BoolVar(&noData, "no-data", false, "Skip volume cloning") | 141 | cmd.Flags().BoolVar(&noData, "no-data", false, "Skip volume cloning") | ||
| 142 | + | cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform for build") | |||
| 70 | return cmd | 143 | return cmd | ||
| 71 | } | 144 | } | ||
| 72 | 145 | ||||
| @@ -76,14 +149,52 @@ func newPreviewListCmd() *cobra.Command { | |||||
| 76 | Use: "list", | 149 | Use: "list", | ||
| 77 | Short: "List active preview environments", | 150 | Short: "List active preview environments", | ||
| 78 | RunE: func(cmd *cobra.Command, args []string) error { | 151 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 79 | - | // TODO: Wire to local service or admin API | 152 | + | ctx := cmd.Context() |
| 153 | + | out := cmd.OutOrStdout() | |||
| 154 | + | ||||
| 155 | + | client, isRemote := GetRemoteClient() | |||
| 156 | + | if !isRemote { | |||
| 157 | + | return fmt.Errorf("preview list requires --remote (local preview listing is not yet supported)") | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | previews, err := client.ListPreviews(ctx) | |||
| 161 | + | if err != nil { | |||
| 162 | + | return fmt.Errorf("failed to list previews: %w", err) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 80 | if jsonOutput { | 165 | if jsonOutput { | ||
| 81 | - | // Output empty JSON array for now (will be wired later) | 166 | + | enc := json.NewEncoder(out) |
| 82 | - | fmt.Fprintln(cmd.OutOrStdout(), "[]") | 167 | + | enc.SetIndent("", " ") |
| 83 | - | } else { | 168 | + | return enc.Encode(previews) |
| 84 | - | fmt.Fprintln(cmd.OutOrStdout(), "No active previews") | |||
| 85 | } | 169 | } | ||
| 86 | - | return nil | 170 | + | |
| 171 | + | if len(previews) == 0 { | |||
| 172 | + | fmt.Fprintln(out, "No active previews") | |||
| 173 | + | return nil | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) | |||
| 177 | + | if _, err := fmt.Fprintln(w, "NAME\tDOMAIN\tIMAGE\tCREATED\tEXPIRES"); err != nil { | |||
| 178 | + | return err | |||
| 179 | + | } | |||
| 180 | + | now := time.Now() | |||
| 181 | + | for _, p := range previews { | |||
| 182 | + | remaining := time.Until(p.ExpiresAt).Truncate(time.Minute) | |||
| 183 | + | expiresStr := remaining.String() | |||
| 184 | + | if p.IsExpired(now) { | |||
| 185 | + | expiresStr = "expired" | |||
| 186 | + | } | |||
| 187 | + | if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", | |||
| 188 | + | p.Name, | |||
| 189 | + | p.Domain, | |||
| 190 | + | truncateImage(p.Image, 40), | |||
| 191 | + | p.CreatedAt.Format(time.DateTime), | |||
| 192 | + | expiresStr, | |||
| 193 | + | ); err != nil { | |||
| 194 | + | return err | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | return w.Flush() | |||
| 87 | }, | 198 | }, | ||
| 88 | } | 199 | } | ||
| 89 | cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") | 200 | cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") | ||
| @@ -96,8 +207,20 @@ func newPreviewDeleteCmd() *cobra.Command { | |||||
| 96 | Short: "Delete a preview environment", | 207 | Short: "Delete a preview environment", | ||
| 97 | Args: cobra.ExactArgs(1), | 208 | Args: cobra.ExactArgs(1), | ||
| 98 | RunE: func(cmd *cobra.Command, args []string) error { | 209 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 99 | - | // TODO: Wire to local service or admin API | 210 | + | ctx := cmd.Context() |
| 100 | - | fmt.Fprintf(cmd.OutOrStdout(), "Deleting preview: %s\n", args[0]) | 211 | + | out := cmd.OutOrStdout() |
| 212 | + | name := args[0] | |||
| 213 | + | ||||
| 214 | + | client, isRemote := GetRemoteClient() | |||
| 215 | + | if !isRemote { | |||
| 216 | + | return fmt.Errorf("preview delete requires --remote (local preview deletion is not yet supported)") | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | if err := client.DeletePreview(ctx, name); err != nil { | |||
| 220 | + | return fmt.Errorf("failed to delete preview %q: %w", name, err) | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | fmt.Fprintln(out, styles.RenderSuccess(fmt.Sprintf("Preview %q deleted", name))) | |||
| 101 | return nil | 224 | return nil | ||
| 102 | }, | 225 | }, | ||
| 103 | } | 226 | } | ||
| @@ -110,8 +233,20 @@ func newPreviewExtendCmd() *cobra.Command { | |||||
| 110 | Short: "Extend a preview environment's TTL", | 233 | Short: "Extend a preview environment's TTL", | ||
| 111 | Args: cobra.ExactArgs(1), | 234 | Args: cobra.ExactArgs(1), | ||
| 112 | RunE: func(cmd *cobra.Command, args []string) error { | 235 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 113 | - | // TODO: Wire to local service or admin API | 236 | + | ctx := cmd.Context() |
| 114 | - | fmt.Fprintf(cmd.OutOrStdout(), "Extending preview %s by %s\n", args[0], ttl) | 237 | + | out := cmd.OutOrStdout() |
| 238 | + | name := args[0] | |||
| 239 | + | ||||
| 240 | + | client, isRemote := GetRemoteClient() | |||
| 241 | + | if !isRemote { | |||
| 242 | + | return fmt.Errorf("preview extend requires --remote (local preview extend is not yet supported)") | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | if err := client.ExtendPreview(ctx, name, ttl); err != nil { | |||
| 246 | + | return fmt.Errorf("failed to extend preview %q: %w", name, err) | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | fmt.Fprintln(out, styles.RenderSuccess(fmt.Sprintf("Preview %q extended by %s", name, ttl))) | |||
| 115 | return nil | 250 | return nil | ||
| 116 | }, | 251 | }, | ||
| 117 | } | 252 | } | ||
A internal/adapters/in/cli/remote/preview.go +56 -0
| @@ -0,0 +1,56 @@ | |||
| 1 | + | package remote | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "net/http" | |
| 7 | + | "net/url" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/gordon/internal/domain" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // ListPreviews returns all active preview environments. | |
| 13 | + | func (c *Client) ListPreviews(ctx context.Context) ([]domain.PreviewRoute, error) { | |
| 14 | + | resp, err := c.request(ctx, http.MethodGet, "/previews", nil) | |
| 15 | + | if err != nil { | |
| 16 | + | return nil, err | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | var result struct { | |
| 20 | + | Previews []domain.PreviewRoute `json:"previews"` | |
| 21 | + | } | |
| 22 | + | if err := parseResponse(resp, &result); err != nil { | |
| 23 | + | return nil, err | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | return result.Previews, nil | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // DeletePreview tears down a preview environment by name. | |
| 30 | + | func (c *Client) DeletePreview(ctx context.Context, name string) error { | |
| 31 | + | if name == "" { | |
| 32 | + | return fmt.Errorf("preview name cannot be empty") | |
| 33 | + | } | |
| 34 | + | resp, err := c.request(ctx, http.MethodDelete, "/preview/"+url.PathEscape(name), nil) | |
| 35 | + | if err != nil { | |
| 36 | + | return err | |
| 37 | + | } | |
| 38 | + | return parseResponse(resp, nil) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // ExtendPreview extends the TTL of a preview environment. | |
| 42 | + | func (c *Client) ExtendPreview(ctx context.Context, name string, ttl string) error { | |
| 43 | + | if name == "" { | |
| 44 | + | return fmt.Errorf("preview name cannot be empty") | |
| 45 | + | } | |
| 46 | + | if ttl == "" { | |
| 47 | + | return fmt.Errorf("ttl cannot be empty") | |
| 48 | + | } | |
| 49 | + | resp, err := c.request(ctx, http.MethodPatch, "/preview/"+url.PathEscape(name), struct { | |
| 50 | + | TTL string `json:"ttl"` | |
| 51 | + | }{TTL: ttl}) | |
| 52 | + | if err != nil { | |
| 53 | + | return err | |
| 54 | + | } | |
| 55 | + | return parseResponse(resp, nil) | |
| 56 | + | } | |
| @@ -0,0 +1,56 @@ | |||||
| 1 | + | package remote | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "net/http" | |||
| 7 | + | "net/url" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/gordon/internal/domain" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // ListPreviews returns all active preview environments. | |||
| 13 | + | func (c *Client) ListPreviews(ctx context.Context) ([]domain.PreviewRoute, error) { | |||
| 14 | + | resp, err := c.request(ctx, http.MethodGet, "/previews", nil) | |||
| 15 | + | if err != nil { | |||
| 16 | + | return nil, err | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | var result struct { | |||
| 20 | + | Previews []domain.PreviewRoute `json:"previews"` | |||
| 21 | + | } | |||
| 22 | + | if err := parseResponse(resp, &result); err != nil { | |||
| 23 | + | return nil, err | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | return result.Previews, nil | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // DeletePreview tears down a preview environment by name. | |||
| 30 | + | func (c *Client) DeletePreview(ctx context.Context, name string) error { | |||
| 31 | + | if name == "" { | |||
| 32 | + | return fmt.Errorf("preview name cannot be empty") | |||
| 33 | + | } | |||
| 34 | + | resp, err := c.request(ctx, http.MethodDelete, "/preview/"+url.PathEscape(name), nil) | |||
| 35 | + | if err != nil { | |||
| 36 | + | return err | |||
| 37 | + | } | |||
| 38 | + | return parseResponse(resp, nil) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // ExtendPreview extends the TTL of a preview environment. | |||
| 42 | + | func (c *Client) ExtendPreview(ctx context.Context, name string, ttl string) error { | |||
| 43 | + | if name == "" { | |||
| 44 | + | return fmt.Errorf("preview name cannot be empty") | |||
| 45 | + | } | |||
| 46 | + | if ttl == "" { | |||
| 47 | + | return fmt.Errorf("ttl cannot be empty") | |||
| 48 | + | } | |||
| 49 | + | resp, err := c.request(ctx, http.MethodPatch, "/preview/"+url.PathEscape(name), struct { | |||
| 50 | + | TTL string `json:"ttl"` | |||
| 51 | + | }{TTL: ttl}) | |||
| 52 | + | if err != nil { | |||
| 53 | + | return err | |||
| 54 | + | } | |||
| 55 | + | return parseResponse(resp, nil) | |||
| 56 | + | } | |||