push_image_ops.go RawHistoryFolder

1package cli
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"io"
8	"net/http"
9	"os"
10	"os/exec"
11	"strings"
12
13	cerrdefs "github.com/containerd/errdefs"
14	"github.com/moby/moby/client"
15
16	"github.com/bnema/gordon/internal/adapters/in/cli/remote"
17	"github.com/bnema/gordon/pkg/registrypush"
18)
19
20// pushImageOps abstracts container image operations needed by push commands.
21// The real implementation uses Docker SDK + registrypush; tests inject fakes.
22type pushImageOps interface {
23	// Tag creates a new tag for an existing local image.
24	Tag(ctx context.Context, sourceRef, targetRef string) error
25	// Push uploads a local image to its registry using chunked blob uploads.
26	Push(ctx context.Context, ref string) error
27	// Exists checks whether a local image exists.
28	Exists(ctx context.Context, imageRef string) (bool, error)
29	// Build runs a container image build. Only Docker with buildx is supported;
30	// other runtimes return an actionable error.
31	Build(ctx context.Context, args []string) error
32}
33
34// dockerImageOps implements pushImageOps using the Docker SDK and registrypush.
35type dockerImageOps struct {
36	cli          client.APIClient
37	insecureTLS  bool
38	plainHTTP    bool
39	progress     io.Writer
40	remoteClient *remote.Client // nil when local
41}
42
43// newDockerImageOps creates a dockerImageOps from the Docker environment.
44func newDockerImageOps(insecureTLS bool, progress io.Writer) (*dockerImageOps, error) {
45	cli, err := client.New(client.FromEnv)
46	if err != nil {
47		return nil, fmt.Errorf("failed to create Docker client: %w", err)
48	}
49	return &dockerImageOps{
50		cli:         cli,
51		insecureTLS: insecureTLS,
52		progress:    progress,
53	}, nil
54}
55
56// newImageOpsFromFlags resolves TLS settings from CLI flags/config and creates ops.
57func newImageOpsFromFlags() (pushImageOps, error) {
58	resolved, _, err := remote.ResolveStrict(remoteFlag, tokenFlag, insecureTLSFlag)
59	if err != nil {
60		return nil, err
61	}
62	return newImageOpsForResolvedRemote(resolved)
63}
64
65func newImageOpsForResolvedRemote(resolved *remote.ResolvedRemote) (pushImageOps, error) {
66	insecureTLS := false
67	if resolved != nil {
68		insecureTLS = resolved.InsecureTLS
69	}
70	ops, err := newDockerImageOps(insecureTLS, os.Stderr)
71	if err != nil {
72		return nil, err
73	}
74	if resolved != nil {
75		ops.plainHTTP = strings.HasPrefix(strings.ToLower(resolved.URL), "http://")
76		if resolved.Token != "" {
77			ops.remoteClient = remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)
78		}
79	}
80	return ops, nil
81}
82
83func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) error {
84	if _, err := d.cli.ImageTag(ctx, client.ImageTagOptions{Source: sourceRef, Target: targetRef}); err != nil {
85		return fmt.Errorf("failed to tag %s as %s: %w", sourceRef, targetRef, err)
86	}
87	return nil
88}
89
90func (d *dockerImageOps) Push(ctx context.Context, ref string) error {
91	opts := []registrypush.Option{
92		registrypush.WithProgress(d.progress),
93		registrypush.WithInsecureTLS(d.insecureTLS),
94		registrypush.WithPlainHTTP(d.plainHTTP),
95	}
96
97	if d.remoteClient != nil {
98		authHeader, err := d.exchangeRegistryAuth(ctx)
99		if err != nil {
100			return fmt.Errorf("failed to obtain registry credentials: %w", err)
101		}
102		opts = append(opts, registrypush.WithAuth(authHeader))
103
104		pusher := registrypush.New(opts...)
105		err = pusher.Push(ctx, ref)
106		if err != nil && isAuthError(err) {
107			// Token may have expired mid-push; refresh once and retry.
108			authHeader, refreshErr := d.exchangeRegistryAuth(ctx)
109			if refreshErr != nil {
110				return fmt.Errorf("failed to refresh registry credentials after auth error: %w (original: %v)", refreshErr, err)
111			}
112			retryOpts := []registrypush.Option{
113				registrypush.WithProgress(d.progress),
114				registrypush.WithInsecureTLS(d.insecureTLS),
115				registrypush.WithPlainHTTP(d.plainHTTP),
116				registrypush.WithAuth(authHeader),
117			}
118			pusher = registrypush.New(retryOpts...)
119			return pusher.Push(ctx, ref)
120		}
121		return err
122	}
123
124	pusher := registrypush.New(opts...)
125	return pusher.Push(ctx, ref)
126}
127
128// isAuthError returns true if the error indicates an HTTP 401 authentication failure.
129// Prefers typed HTTPError from the remote client; falls back to string matching for
130// errors from registrypush which uses its own error format.
131func isAuthError(err error) bool {
132	if err == nil {
133		return false
134	}
135	if httpErr, ok := errors.AsType[*remote.HTTPError](err); ok {
136		return httpErr.StatusCode == http.StatusUnauthorized
137	}
138	msg := err.Error()
139	return strings.Contains(msg, "status 401") || strings.Contains(msg, "401 Unauthorized")
140}
141
142// exchangeRegistryAuth exchanges the long-lived Gordon token for a short-lived
143// registry access token. Returns the Authorization header value (e.g. "Bearer <token>").
144func (d *dockerImageOps) exchangeRegistryAuth(ctx context.Context) (string, error) {
145	// Step 1: Get subject from server (validates token is still active)
146	verifyResp, err := d.remoteClient.VerifyAuth(ctx)
147	if err != nil {
148		return "", fmt.Errorf("failed to verify token: %w", err)
149	}
150	if !verifyResp.Valid {
151		return "", fmt.Errorf("token is not valid")
152	}
153	if verifyResp.Subject == "" {
154		return "", fmt.Errorf("empty subject in verified token")
155	}
156
157	// Step 2: Exchange long-lived token for short-lived registry token
158	shortToken, err := d.remoteClient.ExchangeRegistryToken(ctx, verifyResp.Subject)
159	if err != nil {
160		return "", fmt.Errorf("failed to exchange registry token: %w", err)
161	}
162
163	return "Bearer " + shortToken, nil
164}
165
166func (d *dockerImageOps) Exists(ctx context.Context, imageRef string) (bool, error) {
167	_, err := d.cli.ImageInspect(ctx, imageRef)
168	if err != nil {
169		if cerrdefs.IsNotFound(err) {
170			return false, nil
171		}
172		return false, fmt.Errorf("failed to inspect image %s: %w", imageRef, err)
173	}
174	return true, nil
175}
176
177func (d *dockerImageOps) Build(ctx context.Context, args []string) error {
178	if _, err := exec.LookPath("docker"); err != nil {
179		return fmt.Errorf(
180			"image build requires Docker with buildx\n\n" +
181				"Podman and other runtimes are not supported for --build.\n" +
182				"Build the image manually and push with:\n" +
183				"  podman build -t <image> .\n" +
184				"  gordon images push <image>",
185		)
186	}
187	cmd := exec.CommandContext(ctx, "docker", args...) // #nosec G204
188	cmd.Env = os.Environ()
189	cmd.Stdout = os.Stdout
190	cmd.Stderr = os.Stderr
191	if err := cmd.Run(); err != nil {
192		return fmt.Errorf("docker buildx build failed: %w", err)
193	}
194	return nil
195}

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