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