1package cli
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "os"
8 "os/exec"
9 "strings"
10 "text/tabwriter"
11 "time"
12
13 tea "github.com/charmbracelet/bubbletea"
14 "github.com/spf13/cobra"
15
16 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
17 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
18 "github.com/bnema/gordon/internal/domain"
19 "github.com/bnema/gordon/internal/usecase/auto/preview"
20)
21
22func newPreviewCmd() *cobra.Command {
23 cmd := &cobra.Command{
24 Use: "preview",
25 Short: "Create or manage preview environments",
26 Long: "Build, push, and deploy an ephemeral preview environment. Use a subcommand (create, list, delete, extend).",
27 }
28
29 createCmd := newPreviewCreateCmd()
30
31 cmd.AddCommand(
32 createCmd,
33 newPreviewListCmd(),
34 newPreviewDeleteCmd(),
35 newPreviewExtendCmd(),
36 )
37
38 return cmd
39}
40
41func newPreviewCreateCmd() *cobra.Command {
42 var (
43 ttl string
44 noBuild bool
45 noData bool
46 platform string
47 )
48
49 cmd := &cobra.Command{
50 Use: "create [name]",
51 Short: "Create a preview environment",
52 Args: cobra.MaximumNArgs(1),
53 RunE: func(cmd *cobra.Command, args []string) error {
54 ctx := cmd.Context()
55 out := cmd.OutOrStdout()
56
57 // Resolve preview name from arg or git branch.
58 name, err := resolvePreviewName(ctx, args)
59 if err != nil {
60 return err
61 }
62
63 previewTag := "preview-" + name
64
65 if err := cliWriteLine(out, cliRenderTitle("Preview: "+name)); err != nil {
66 return err
67 }
68
69 // Resolve control plane (remote or local) — same as push.
70 handle, err := resolveControlPlane(configPath)
71 if err != nil {
72 return err
73 }
74 defer handle.close()
75
76 previewRef, err := resolvePreviewImageRef(ctx, handle.plane, out, previewTag)
77 if err != nil {
78 return err
79 }
80
81 if err := buildAndPushPreview(ctx, out, previewRef, previewTag, platform, noBuild); err != nil {
82 return err
83 }
84
85 return waitForPreview(ctx, out, name, ttl, noData)
86 },
87 }
88
89 cmd.Flags().StringVar(&ttl, "ttl", "", "Override TTL (e.g., 72h)")
90 cmd.Flags().BoolVar(&noBuild, "no-build", false, "Skip image build")
91 cmd.Flags().BoolVar(&noData, "no-data", false, "Skip volume cloning")
92 cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform for build")
93 return cmd
94}
95
96func newPreviewListCmd() *cobra.Command {
97 var jsonOutput bool
98 cmd := &cobra.Command{
99 Use: "list",
100 Short: "List active preview environments",
101 RunE: func(cmd *cobra.Command, args []string) error {
102 ctx := cmd.Context()
103 out := cmd.OutOrStdout()
104
105 client, isRemote, err := GetRemoteClient()
106 if err != nil {
107 return err
108 }
109 if !isRemote {
110 return fmt.Errorf("preview list requires --remote (local preview listing is not yet supported)")
111 }
112
113 previews, err := client.ListPreviews(ctx)
114 if err != nil {
115 return fmt.Errorf("failed to list previews: %w", err)
116 }
117
118 if jsonOutput {
119 return writeJSON(out, previews)
120 }
121
122 if len(previews) == 0 {
123 return cliWriteLine(out, cliRenderEmptyState("No active previews"))
124 }
125
126 return printPreviewTable(out, previews)
127 },
128 }
129 cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON")
130 return cmd
131}
132
133func newPreviewDeleteCmd() *cobra.Command {
134 return &cobra.Command{
135 Use: "delete <name>",
136 Short: "Delete a preview environment",
137 Args: cobra.ExactArgs(1),
138 RunE: func(cmd *cobra.Command, args []string) error {
139 ctx := cmd.Context()
140 out := cmd.OutOrStdout()
141 name := args[0]
142
143 client, isRemote, err := GetRemoteClient()
144 if err != nil {
145 return err
146 }
147 if !isRemote {
148 return fmt.Errorf("preview delete requires --remote (local preview deletion is not yet supported)")
149 }
150
151 if err := client.DeletePreview(ctx, name); err != nil {
152 return fmt.Errorf("failed to delete preview %q: %w", name, err)
153 }
154
155 return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Preview %q deleted", name)))
156 },
157 }
158}
159
160func newPreviewExtendCmd() *cobra.Command {
161 var ttl string
162 cmd := &cobra.Command{
163 Use: "extend <name>",
164 Short: "Extend a preview environment's TTL",
165 Args: cobra.ExactArgs(1),
166 RunE: func(cmd *cobra.Command, args []string) error {
167 ctx := cmd.Context()
168 out := cmd.OutOrStdout()
169 name := args[0]
170
171 client, isRemote, err := GetRemoteClient()
172 if err != nil {
173 return err
174 }
175 if !isRemote {
176 return fmt.Errorf("preview extend requires --remote (local preview extend is not yet supported)")
177 }
178
179 if err := client.ExtendPreview(ctx, name, ttl); err != nil {
180 return fmt.Errorf("failed to extend preview %q: %w", name, err)
181 }
182
183 return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Preview %q extended by %s", name, ttl)))
184 },
185 }
186 cmd.Flags().StringVar(&ttl, "ttl", "24h", "Additional TTL duration")
187 return cmd
188}
189
190func printPreviewTable(out io.Writer, previews []domain.PreviewRoute) error {
191 w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
192 if _, err := fmt.Fprintln(w, "NAME\tDOMAIN\tIMAGE\tCREATED\tEXPIRES"); err != nil {
193 return err
194 }
195 now := time.Now()
196 for _, p := range previews {
197 remaining := time.Until(p.ExpiresAt).Truncate(time.Minute)
198 expiresStr := remaining.String()
199 if p.IsExpired(now) {
200 expiresStr = "expired"
201 }
202 if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
203 p.Name,
204 p.Domain,
205 truncateImage(p.Image, 40),
206 p.CreatedAt.Format(time.DateTime),
207 expiresStr,
208 ); err != nil {
209 return err
210 }
211 }
212 return w.Flush()
213}
214
215func resolvePreviewName(ctx context.Context, args []string) (string, error) {
216 if len(args) > 0 {
217 name := preview.SanitizeBranchName(args[0])
218 if name == "" {
219 return "", fmt.Errorf("invalid preview name after sanitization: %q", args[0])
220 }
221 return name, nil
222 }
223 branch, err := detectGitBranch(ctx)
224 if err != nil {
225 return "", fmt.Errorf("no preview name provided and could not detect git branch: %w", err)
226 }
227 name := preview.SanitizeBranchName(branch)
228 if name == "" {
229 return "", fmt.Errorf("git branch %q produced empty preview name after sanitization", branch)
230 }
231 return name, nil
232}
233
234func resolvePreviewImageRef(ctx context.Context, cp ControlPlane, out io.Writer, previewTag string) (string, error) {
235 dockerfile := "Dockerfile"
236 imageName, err := detectImageName(dockerfile)
237 if err != nil {
238 return "", err
239 }
240 if err := cliWriteLine(out, cliRenderMeta("Image:", imageName)); err != nil {
241 return "", err
242 }
243
244 // Resolve registry from existing routes without showing the interactive
245 // selector — preview only needs the registry URL, not a target domain.
246 registry, err := resolveRegistryForImage(ctx, cp, imageName)
247 if err != nil {
248 return "", err
249 }
250
251 previewRef := fmt.Sprintf("%s/%s:%s", registry, imageName, previewTag)
252 if err := cliWriteLine(out, cliRenderMeta("Tag:", previewRef)); err != nil {
253 return "", err
254 }
255 return previewRef, nil
256}
257
258// resolveRegistryForImage finds the registry URL from any existing non-preview
259// route for the given image, without prompting.
260func resolveRegistryForImage(ctx context.Context, cp ControlPlane, imageName string) (string, error) {
261 routes, err := cp.FindRoutesByImage(ctx, imageName)
262 if err != nil {
263 return "", fmt.Errorf("failed to find routes for image %q: %w", imageName, err)
264 }
265
266 for _, r := range routes {
267 if strings.Contains(r.Domain, domain.DefaultPreviewSeparator) {
268 continue
269 }
270 reg, _, _ := parseImageRef(r.Image)
271 if reg != "" {
272 return reg, nil
273 }
274 }
275
276 return "", fmt.Errorf("no route configured for image %q", imageName)
277}
278
279func buildAndPushPreview(ctx context.Context, out io.Writer, previewRef, previewTag, platform string, noBuild bool) error {
280 imageOps, err := newImageOpsFn()
281 if err != nil {
282 return err
283 }
284
285 if !noBuild {
286 dockerfile := "Dockerfile"
287 if _, statErr := os.Stat(dockerfile); os.IsNotExist(statErr) {
288 return fmt.Errorf("dockerfile not found: %s", dockerfile)
289 }
290 buildArgs := buildImageArgs(ctx, previewTag, platform, dockerfile, nil, previewRef, previewRef)
291 if err := cliWriteLine(out, "\nBuilding image..."); err != nil {
292 return err
293 }
294 if err := imageOps.Build(ctx, buildArgs); err != nil {
295 return err
296 }
297 }
298
299 if err := cliWriteLine(out, "Pushing..."); err != nil {
300 return err
301 }
302 if err := imageOps.Push(ctx, previewRef); err != nil {
303 return fmt.Errorf("failed to push %s: %w", previewRef, err)
304 }
305 return nil
306}
307
308func printPreviewFlags(out io.Writer, ttl string, noData bool) error {
309 if ttl != "" {
310 if err := cliWritef(out, "Requested TTL: %s (server config controls actual TTL)\n", ttl); err != nil {
311 return err
312 }
313 }
314 if noData {
315 if err := cliWriteLine(out, cliRenderInfo("Data copy: skipped (--no-data)")); err != nil {
316 return err
317 }
318 }
319 return nil
320}
321
322func waitForPreview(ctx context.Context, out io.Writer, name, ttl string, noData bool) error {
323 if err := printPreviewFlags(out, ttl, noData); err != nil {
324 return err
325 }
326
327 client, isRemote, err := GetRemoteClient()
328 if err != nil {
329 return err
330 }
331 if !isRemote {
332 return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Push complete. Preview %q will be created by the server (use --remote to poll status).", name)))
333 }
334
335 result, err := pollPreviewWithSpinner(ctx, out, client, name)
336 if err != nil {
337 return err
338 }
339
340 switch result.Status {
341 case domain.PreviewStatusRunning:
342 scheme := "http"
343 if result.HTTPS {
344 scheme = "https"
345 }
346 previewURL := fmt.Sprintf("%s://%s", scheme, result.Domain)
347 if err := cliWriteLine(out, cliRenderSuccess("Preview deployed")); err != nil {
348 return err
349 }
350 return cliWriteLine(out, cliRenderMeta("URL:", previewURL))
351 case domain.PreviewStatusFailed:
352 return fmt.Errorf("preview deployment failed")
353 default:
354 return cliWriteLine(out, cliRenderInfo("Preview is still deploying. Check status with: gordon preview list --remote"))
355 }
356}
357
358type previewPollResult struct {
359 preview *domain.PreviewRoute
360 err error
361}
362
363type previewPollDoneMsg previewPollResult
364
365type previewSpinnerModel struct {
366 spinner components.SpinnerModel
367 done <-chan previewPollResult
368 outcome previewPollResult
369 finished bool
370}
371
372func newPreviewSpinnerModel(name string, done <-chan previewPollResult) previewSpinnerModel {
373 return previewSpinnerModel{
374 spinner: components.NewSpinner(
375 components.WithMessage(fmt.Sprintf("Deploying preview %s...", name)),
376 components.WithSpinnerType(components.SpinnerMiniDot),
377 ),
378 done: done,
379 }
380}
381
382func (m previewSpinnerModel) Init() tea.Cmd {
383 return tea.Batch(m.spinner.Init(), waitForPreviewPollDone(m.done))
384}
385
386func (m previewSpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
387 switch msg := msg.(type) {
388 case previewPollDoneMsg:
389 m.outcome = previewPollResult(msg)
390 m.finished = true
391 return m, tea.Quit
392 default:
393 updated, cmd := m.spinner.Update(msg)
394 if sm, ok := updated.(components.SpinnerModel); ok {
395 m.spinner = sm
396 }
397 return m, cmd
398 }
399}
400
401func (m previewSpinnerModel) View() string {
402 return m.spinner.View()
403}
404
405func waitForPreviewPollDone(done <-chan previewPollResult) tea.Cmd {
406 return func() tea.Msg {
407 return previewPollDoneMsg(<-done)
408 }
409}
410
411func pollPreviewWithSpinner(ctx context.Context, out io.Writer, client *remote.Client, name string) (*domain.PreviewRoute, error) {
412 done := make(chan previewPollResult, 1)
413 go func() {
414 result, err := pollPreviewStatus(ctx, client, name)
415 done <- previewPollResult{preview: result, err: err}
416 }()
417
418 if !isInteractiveTerminal() {
419 if err := cliWritef(out, "Waiting for preview %s...\n", name); err != nil {
420 return nil, err
421 }
422 r := <-done
423 return r.preview, r.err
424 }
425
426 model := newPreviewSpinnerModel(name, done)
427 final, err := tea.NewProgram(model, tea.WithContext(ctx)).Run()
428 fmt.Print("\r\033[K")
429 if err != nil {
430 return nil, err
431 }
432
433 m, ok := final.(previewSpinnerModel)
434 if !ok || !m.finished {
435 return nil, fmt.Errorf("preview spinner exited unexpectedly")
436 }
437 return m.outcome.preview, m.outcome.err
438}
439
440func pollPreviewStatus(ctx context.Context, client *remote.Client, name string) (*domain.PreviewRoute, error) {
441 ticker := time.NewTicker(2 * time.Second)
442 defer ticker.Stop()
443 timeout := time.After(3 * time.Minute)
444
445 for {
446 select {
447 case <-ctx.Done():
448 return nil, ctx.Err()
449 case <-timeout:
450 return &domain.PreviewRoute{Status: domain.PreviewStatusTimeout}, nil
451 case <-ticker.C:
452 p, err := client.GetPreview(ctx, name)
453 if err != nil {
454 continue
455 }
456 if p.Status == domain.PreviewStatusRunning || p.Status == domain.PreviewStatusFailed {
457 return p, nil
458 }
459 }
460 }
461}
462
463func detectGitBranch(ctx context.Context) (string, error) {
464 out, err := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD").Output() // #nosec G204
465 if err != nil {
466 return "", fmt.Errorf("detect git branch: %w", err)
467 }
468 return strings.TrimSpace(string(out)), nil
469}