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/docker/docker/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 progress io.Writer
39 remoteClient *remote.Client // nil when local
40}
41
42// newDockerImageOps creates a dockerImageOps from the Docker environment.
43func newDockerImageOps(insecureTLS bool, progress io.Writer) (*dockerImageOps, error) {
44 cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
45 if err != nil {
46 return nil, fmt.Errorf("failed to create Docker client: %w", err)
47 }
48 return &dockerImageOps{
49 cli: cli,
50 insecureTLS: insecureTLS,
51 progress: progress,
52 }, nil
53}
54
55// newImageOpsFromFlags resolves TLS settings from CLI flags/config and creates ops.
56func newImageOpsFromFlags() (pushImageOps, error) {
57 resolved, _ := remote.Resolve(remoteFlag, tokenFlag, insecureTLSFlag)
58 insecureTLS := false
59 if resolved != nil {
60 insecureTLS = resolved.InsecureTLS
61 }
62 ops, err := newDockerImageOps(insecureTLS, os.Stderr)
63 if err != nil {
64 return nil, err
65 }
66 if resolved != nil && resolved.Token != "" {
67 var clientOpts []remote.ClientOption
68 clientOpts = append(clientOpts, remote.WithToken(resolved.Token))
69 if resolved.InsecureTLS {
70 clientOpts = append(clientOpts, remote.WithInsecureTLS(true))
71 }
72 ops.remoteClient = remote.NewClient(resolved.URL, clientOpts...)
73 }
74 return ops, nil
75}
76
77func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) error {
78 if err := d.cli.ImageTag(ctx, sourceRef, targetRef); err != nil {
79 return fmt.Errorf("failed to tag %s as %s: %w", sourceRef, targetRef, err)
80 }
81 return nil
82}
83
84func (d *dockerImageOps) Push(ctx context.Context, ref string) error {
85 opts := []registrypush.Option{
86 registrypush.WithProgress(d.progress),
87 registrypush.WithInsecureTLS(d.insecureTLS),
88 }
89
90 if d.remoteClient != nil {
91 authHeader, err := d.exchangeRegistryAuth(ctx)
92 if err != nil {
93 return fmt.Errorf("failed to obtain registry credentials: %w", err)
94 }
95 opts = append(opts, registrypush.WithAuth(authHeader))
96
97 pusher := registrypush.New(opts...)
98 err = pusher.Push(ctx, ref)
99 if err != nil && isAuthError(err) {
100 // Token may have expired mid-push; refresh once and retry.
101 authHeader, refreshErr := d.exchangeRegistryAuth(ctx)
102 if refreshErr != nil {
103 return fmt.Errorf("failed to refresh registry credentials after auth error: %w (original: %v)", refreshErr, err)
104 }
105 retryOpts := []registrypush.Option{
106 registrypush.WithProgress(d.progress),
107 registrypush.WithInsecureTLS(d.insecureTLS),
108 registrypush.WithAuth(authHeader),
109 }
110 pusher = registrypush.New(retryOpts...)
111 return pusher.Push(ctx, ref)
112 }
113 return err
114 }
115
116 pusher := registrypush.New(opts...)
117 return pusher.Push(ctx, ref)
118}
119
120// isAuthError returns true if the error indicates an HTTP 401 authentication failure.
121// Prefers typed HTTPError from the remote client; falls back to string matching for
122// errors from registrypush which uses its own error format.
123func isAuthError(err error) bool {
124 if err == nil {
125 return false
126 }
127 var httpErr *remote.HTTPError
128 if errors.As(err, &httpErr) {
129 return httpErr.StatusCode == http.StatusUnauthorized
130 }
131 msg := err.Error()
132 return strings.Contains(msg, "status 401") || strings.Contains(msg, "401 Unauthorized")
133}
134
135// exchangeRegistryAuth exchanges the long-lived Gordon token for a short-lived
136// registry access token. Returns the Authorization header value (e.g. "Bearer <token>").
137func (d *dockerImageOps) exchangeRegistryAuth(ctx context.Context) (string, error) {
138 // Step 1: Get subject from server (validates token is still active)
139 verifyResp, err := d.remoteClient.VerifyAuth(ctx)
140 if err != nil {
141 return "", fmt.Errorf("failed to verify token: %w", err)
142 }
143 if !verifyResp.Valid {
144 return "", fmt.Errorf("token is not valid")
145 }
146 if verifyResp.Subject == "" {
147 return "", fmt.Errorf("empty subject in verified token")
148 }
149
150 // Step 2: Exchange long-lived token for short-lived registry token
151 shortToken, err := d.remoteClient.ExchangeRegistryToken(ctx, verifyResp.Subject)
152 if err != nil {
153 return "", fmt.Errorf("failed to exchange registry token: %w", err)
154 }
155
156 return "Bearer " + shortToken, nil
157}
158
159func (d *dockerImageOps) Exists(ctx context.Context, imageRef string) (bool, error) {
160 _, err := d.cli.ImageInspect(ctx, imageRef)
161 if err != nil {
162 if cerrdefs.IsNotFound(err) {
163 return false, nil
164 }
165 return false, fmt.Errorf("failed to inspect image %s: %w", imageRef, err)
166 }
167 return true, nil
168}
169
170func (d *dockerImageOps) Build(ctx context.Context, args []string) error {
171 if _, err := exec.LookPath("docker"); err != nil {
172 return fmt.Errorf(
173 "image build requires Docker with buildx\n\n" +
174 "Podman and other runtimes are not supported for --build.\n" +
175 "Build the image manually and push with:\n" +
176 " podman build -t <image> .\n" +
177 " gordon push <image>",
178 )
179 }
180 cmd := exec.CommandContext(ctx, "docker", args...) // #nosec G204
181 cmd.Env = os.Environ()
182 cmd.Stdout = os.Stdout
183 cmd.Stderr = os.Stderr
184 if err := cmd.Run(); err != nil {
185 return fmt.Errorf("docker buildx build failed: %w", err)
186 }
187 return nil
188}