v2.31.0-dev22 8d342c4

preview.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close