v2.30.0-pre2 f03ab25

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 := 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}

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