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}