/
docker.go
267 lines (243 loc) · 8.94 KB
/
docker.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
// Copyright 2020-2022 Buf Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package bufplugindocker
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"github.com/bufbuild/buf/private/bufpkg/bufplugin/bufpluginconfig"
"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/jsonmessage"
"github.com/docker/docker/pkg/stringid"
"go.uber.org/multierr"
"go.uber.org/zap"
)
const (
// PluginsImagePrefix is used to prefix all image names with the correct path for pushing to the OCI registry.
PluginsImagePrefix = "plugins."
// Setting this value on the buf docker client allows us to propagate a custom
// value to the OCI registry. This is a useful property that enables registries
// to differentiate between the buf cli vs other tools like docker cli.
// Note, this does not override the final User-Agent entirely, but instead adds
// the value to the final outgoing User-Agent value in the form: [docker client's UA] UpstreamClient(buf-cli-1.11.0)
//
// Example: User-Agent = [docker/20.10.21 go/go1.18.7 git-commit/3056208 kernel/5.15.49-linuxkit os/linux arch/arm64 UpstreamClient(buf-cli-1.11.0)]
BufUpstreamClientUserAgentPrefix = "buf-cli-"
)
// Client is a small abstraction over a Docker API client, providing the basic APIs we need to build plugins.
// It ensures that we pass the appropriate parameters to build images (i.e. platform 'linux/amd64').
type Client interface {
// Load imports a Docker image into the local Docker Engine.
Load(ctx context.Context, image io.Reader) (*LoadResponse, error)
// Push the Docker image to the remote registry.
Push(ctx context.Context, image string, auth *RegistryAuthConfig) (*PushResponse, error)
// Delete removes the Docker image from local Docker Engine.
Delete(ctx context.Context, image string) (*DeleteResponse, error)
// Tag creates a Docker image tag from an existing image and plugin config.
Tag(ctx context.Context, image string, config *bufpluginconfig.Config) (*TagResponse, error)
// Inspect inspects an image and returns the image id.
Inspect(ctx context.Context, image string) (*InspectResponse, error)
// Close releases any resources used by the underlying Docker client.
Close() error
}
// LoadResponse returns details of a successful load image call.
type LoadResponse struct {
// ImageID specifies the Docker image id in the format <hash_algorithm>:<hash>.
// Example: sha256:65001659f150f085e0b37b697a465a95cbfd885d9315b61960883b9ac588744e
ImageID string
}
// PushResponse is a placeholder for data to be returned from a successful image push call.
type PushResponse struct {
// Digest specifies the Docker image digest in the format <hash_algorithm>:<hash>.
// The digest returned from Client.Push differs from the image id returned in Client.Build.
Digest string
}
// TagResponse returns details of a successful image tag call.
type TagResponse struct {
// Image contains the Docker image name in the local Docker engine including the tag.
// It is created from the bufpluginconfig.Config's Name.IdentityString() and a unique id.
Image string
}
// DeleteResponse is a placeholder for data to be returned from a successful image delete call.
type DeleteResponse struct{}
// InspectResponse returns the image id for a given image.
type InspectResponse struct {
// ImageID contains the Docker image's ID.
ImageID string
}
type dockerAPIClient struct {
cli *client.Client
logger *zap.Logger
}
var _ Client = (*dockerAPIClient)(nil)
func (d *dockerAPIClient) Load(ctx context.Context, image io.Reader) (_ *LoadResponse, retErr error) {
response, err := d.cli.ImageLoad(ctx, image, true)
if err != nil {
return nil, err
}
defer func(Body io.ReadCloser) {
if err := Body.Close(); err != nil {
retErr = multierr.Append(retErr, err)
}
}(response.Body)
imageID := ""
responseScanner := bufio.NewScanner(response.Body)
for responseScanner.Scan() {
var jsonMessage jsonmessage.JSONMessage
if err := json.Unmarshal(responseScanner.Bytes(), &jsonMessage); err == nil {
_, loadedImageID, found := strings.Cut(strings.TrimSpace(jsonMessage.Stream), "Loaded image ID: ")
if !found {
continue
}
if !strings.HasPrefix(loadedImageID, "sha256:") {
d.logger.Warn("Unsupported image digest", zap.String("imageID", loadedImageID))
continue
}
if err := stringid.ValidateID(strings.TrimPrefix(loadedImageID, "sha256:")); err != nil {
d.logger.Warn("Invalid image id", zap.String("imageID", loadedImageID))
continue
}
imageID = loadedImageID
}
}
if err := responseScanner.Err(); err != nil {
return nil, err
}
if imageID == "" {
return nil, fmt.Errorf("failed to determine image ID of loaded image")
}
return &LoadResponse{ImageID: imageID}, nil
}
func (d *dockerAPIClient) Tag(ctx context.Context, image string, config *bufpluginconfig.Config) (*TagResponse, error) {
buildID := stringid.GenerateRandomID()
imageName := config.Name.IdentityString() + ":" + buildID
if !strings.HasPrefix(imageName, PluginsImagePrefix) {
imageName = PluginsImagePrefix + imageName
}
if err := d.cli.ImageTag(ctx, image, imageName); err != nil {
return nil, err
}
return &TagResponse{Image: imageName}, nil
}
func (d *dockerAPIClient) Push(ctx context.Context, image string, auth *RegistryAuthConfig) (response *PushResponse, retErr error) {
registryAuth, err := auth.ToHeader()
if err != nil {
return nil, err
}
pushReader, err := d.cli.ImagePush(ctx, image, types.ImagePushOptions{
RegistryAuth: registryAuth,
})
if err != nil {
return nil, err
}
defer func() {
retErr = multierr.Append(retErr, pushReader.Close())
}()
var imageDigest string
pushScanner := bufio.NewScanner(pushReader)
for pushScanner.Scan() {
d.logger.Debug(pushScanner.Text())
var message jsonmessage.JSONMessage
if err := json.Unmarshal([]byte(pushScanner.Text()), &message); err == nil {
if message.Error != nil {
return nil, message.Error
}
if message.Aux != nil {
var pushResult types.PushResult
if err := json.Unmarshal(*message.Aux, &pushResult); err == nil {
imageDigest = pushResult.Digest
}
}
}
}
if err := pushScanner.Err(); err != nil {
return nil, err
}
if len(imageDigest) == 0 {
return nil, fmt.Errorf("failed to determine image digest after push")
}
return &PushResponse{Digest: imageDigest}, nil
}
func (d *dockerAPIClient) Delete(ctx context.Context, image string) (*DeleteResponse, error) {
_, err := d.cli.ImageRemove(ctx, image, types.ImageRemoveOptions{})
if err != nil {
return nil, err
}
return &DeleteResponse{}, nil
}
func (d *dockerAPIClient) Inspect(ctx context.Context, image string) (*InspectResponse, error) {
inspect, _, err := d.cli.ImageInspectWithRaw(ctx, image)
if err != nil {
return nil, err
}
return &InspectResponse{ImageID: inspect.ID}, nil
}
func (d *dockerAPIClient) Close() error {
return d.cli.Close()
}
// NewClient creates a new Client to use to build Docker plugins.
func NewClient(logger *zap.Logger, cliVersion string, options ...ClientOption) (Client, error) {
if logger == nil {
return nil, errors.New("logger required")
}
opts := &clientOptions{}
for _, option := range options {
option(opts)
}
dockerClientOpts := []client.Opt{
client.FromEnv,
client.WithHTTPHeaders(map[string]string{
"User-Agent": BufUpstreamClientUserAgentPrefix + cliVersion,
}),
}
if len(opts.host) > 0 {
dockerClientOpts = append(dockerClientOpts, client.WithHost(opts.host))
}
if len(opts.version) > 0 {
dockerClientOpts = append(dockerClientOpts, client.WithVersion(opts.version))
}
cli, err := client.NewClientWithOpts(dockerClientOpts...)
if err != nil {
return nil, err
}
return &dockerAPIClient{
cli: cli,
logger: logger,
}, nil
}
type clientOptions struct {
host string
version string
}
// ClientOption defines options for the NewClient call to customize the underlying Docker client.
type ClientOption func(options *clientOptions)
// WithHost allows specifying a Docker engine host to connect to (instead of the default lookup using DOCKER_HOST env var).
// This makes it suitable for use by parallel tests.
func WithHost(host string) ClientOption {
return func(options *clientOptions) {
options.host = host
}
}
// WithVersion allows specifying a Docker API client version instead of using the default version negotiation algorithm.
// This allows tests to implement the Docker engine API using stable URLs.
func WithVersion(version string) ClientOption {
return func(options *clientOptions) {
options.version = version
}
}