cuelabs.dev/go/oci/ociregistry@v0.0.0-20240906074133-82eb438dd565/ociclient/writer.go (about) 1 // Copyright 2023 CUE Labs AG 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package ociclient 16 17 import ( 18 "bytes" 19 "context" 20 "fmt" 21 "io" 22 "net/http" 23 "net/url" 24 "strconv" 25 "strings" 26 "sync" 27 28 "github.com/opencontainers/go-digest" 29 30 "cuelabs.dev/go/oci/ociregistry" 31 "cuelabs.dev/go/oci/ociregistry/internal/ocirequest" 32 "cuelabs.dev/go/oci/ociregistry/ociauth" 33 ) 34 35 // This file implements the ociregistry.Writer methods. 36 37 func (c *client) PushManifest(ctx context.Context, repo string, tag string, contents []byte, mediaType string) (ociregistry.Descriptor, error) { 38 if mediaType == "" { 39 return ociregistry.Descriptor{}, fmt.Errorf("PushManifest called with empty mediaType") 40 } 41 desc := ociregistry.Descriptor{ 42 Digest: digest.FromBytes(contents), 43 Size: int64(len(contents)), 44 MediaType: mediaType, 45 } 46 47 rreq := &ocirequest.Request{ 48 Kind: ocirequest.ReqManifestPut, 49 Repo: repo, 50 Tag: tag, 51 Digest: string(desc.Digest), 52 } 53 req, err := newRequest(ctx, rreq, bytes.NewReader(contents)) 54 if err != nil { 55 return ociregistry.Descriptor{}, err 56 } 57 req.Header.Set("Content-Type", mediaType) 58 req.ContentLength = desc.Size 59 resp, err := c.do(req, http.StatusCreated) 60 if err != nil { 61 return ociregistry.Descriptor{}, err 62 } 63 resp.Body.Close() 64 return desc, nil 65 } 66 67 func (c *client) MountBlob(ctx context.Context, fromRepo, toRepo string, dig ociregistry.Digest) (ociregistry.Descriptor, error) { 68 rreq := &ocirequest.Request{ 69 Kind: ocirequest.ReqBlobMount, 70 Repo: toRepo, 71 FromRepo: fromRepo, 72 Digest: string(dig), 73 } 74 resp, err := c.doRequest(ctx, rreq, http.StatusCreated, http.StatusAccepted) 75 if err != nil { 76 return ociregistry.Descriptor{}, err 77 } 78 resp.Body.Close() 79 if resp.StatusCode == http.StatusAccepted { 80 // Mount isn't supported and technically the upload session has begun, 81 // but we aren't in a great position to be able to continue it, so let's just 82 // return Unsupported. 83 return ociregistry.Descriptor{}, fmt.Errorf("registry does not support mounts: %w", ociregistry.ErrUnsupported) 84 } 85 // TODO: is it OK to omit the size from the returned descriptor here? 86 return descriptorFromResponse(resp, dig, requireDigest) 87 } 88 89 func (c *client) PushBlob(ctx context.Context, repo string, desc ociregistry.Descriptor, r io.Reader) (_ ociregistry.Descriptor, _err error) { 90 // TODO use the single-post blob-upload method (ReqBlobUploadBlob) 91 // See: 92 // https://github.com/distribution/distribution/issues/4065 93 // https://github.com/golang/go/issues/63152 94 rreq := &ocirequest.Request{ 95 Kind: ocirequest.ReqBlobStartUpload, 96 Repo: repo, 97 } 98 req, err := newRequest(ctx, rreq, nil) 99 if err != nil { 100 return ociregistry.Descriptor{}, err 101 } 102 resp, err := c.do(req, http.StatusAccepted) 103 if err != nil { 104 return ociregistry.Descriptor{}, err 105 } 106 resp.Body.Close() 107 location, err := locationFromResponse(resp) 108 if err != nil { 109 return ociregistry.Descriptor{}, err 110 } 111 112 // We've got the upload location. Now PUT the content. 113 114 ctx = ociauth.ContextWithRequestInfo(ctx, ociauth.RequestInfo{ 115 RequiredScope: scopeForRequest(rreq), 116 }) 117 // Note: we can't use ocirequest.Request here because that's 118 // specific to the ociserver implementation in this case. 119 req, err = http.NewRequestWithContext(ctx, "PUT", "", r) 120 if err != nil { 121 return ociregistry.Descriptor{}, err 122 } 123 req.URL = urlWithDigest(location, string(desc.Digest)) 124 req.ContentLength = desc.Size 125 req.Header.Set("Content-Type", "application/octet-stream") 126 // TODO: per the spec, the content-range header here is unnecessary. 127 req.Header.Set("Content-Range", ocirequest.RangeString(0, desc.Size)) 128 resp, err = c.do(req, http.StatusCreated) 129 if err != nil { 130 return ociregistry.Descriptor{}, err 131 } 132 defer closeOnError(&_err, resp.Body) 133 resp.Body.Close() 134 return desc, nil 135 } 136 137 // TODO is this a reasonable default? We have to 138 // weigh up in-memory cost vs round-trip overhead. 139 // TODO: make this default configurable. 140 const defaultChunkSize = 64 * 1024 141 142 func (c *client) PushBlobChunked(ctx context.Context, repo string, chunkSize int) (ociregistry.BlobWriter, error) { 143 if chunkSize <= 0 { 144 chunkSize = defaultChunkSize 145 } 146 resp, err := c.doRequest(ctx, &ocirequest.Request{ 147 Kind: ocirequest.ReqBlobStartUpload, 148 Repo: repo, 149 }, http.StatusAccepted) 150 if err != nil { 151 return nil, err 152 } 153 resp.Body.Close() 154 location, err := locationFromResponse(resp) 155 if err != nil { 156 return nil, err 157 } 158 ctx = ociauth.ContextWithRequestInfo(ctx, ociauth.RequestInfo{ 159 RequiredScope: ociauth.NewScope(ociauth.ResourceScope{ 160 ResourceType: "repository", 161 Resource: repo, 162 Action: "push", 163 }), 164 }) 165 return &blobWriter{ 166 ctx: ctx, 167 client: c, 168 chunkSize: chunkSizeFromResponse(resp, chunkSize), 169 chunk: make([]byte, 0, chunkSize), 170 location: location, 171 }, nil 172 } 173 174 func (c *client) PushBlobChunkedResume(ctx context.Context, repo string, id string, offset int64, chunkSize int) (ociregistry.BlobWriter, error) { 175 if id == "" { 176 return nil, fmt.Errorf("id must be non-empty to resume a chunked upload") 177 } 178 if chunkSize <= 0 { 179 chunkSize = defaultChunkSize 180 } 181 var location *url.URL 182 switch { 183 case offset == -1: 184 // Try to find what offset we're meant to be writing at 185 // by doing a GET to the location. 186 // TODO does resuming an upload require push or pull scope or both? 187 ctx := ociauth.ContextWithRequestInfo(ctx, ociauth.RequestInfo{ 188 RequiredScope: ociauth.NewScope(ociauth.ResourceScope{ 189 ResourceType: "repository", 190 Resource: repo, 191 Action: "push", 192 }, ociauth.ResourceScope{ 193 ResourceType: "repository", 194 Resource: repo, 195 Action: "pull", 196 }), 197 }) 198 req, err := http.NewRequestWithContext(ctx, "GET", id, nil) 199 if err != nil { 200 return nil, err 201 } 202 resp, err := c.do(req, http.StatusNoContent) 203 if err != nil { 204 return nil, fmt.Errorf("cannot recover chunk offset: %v", err) 205 } 206 location, err = locationFromResponse(resp) 207 if err != nil { 208 return nil, fmt.Errorf("cannot get location from response: %v", err) 209 } 210 rangeStr := resp.Header.Get("Range") 211 p0, p1, ok := ocirequest.ParseRange(rangeStr) 212 if !ok { 213 return nil, fmt.Errorf("invalid range %q in response", rangeStr) 214 } 215 if p0 != 0 { 216 return nil, fmt.Errorf("range %q does not start with 0", rangeStr) 217 } 218 chunkSize = chunkSizeFromResponse(resp, chunkSize) 219 offset = p1 220 case offset < 0: 221 return nil, fmt.Errorf("invalid offset; must be -1 or non-negative") 222 default: 223 var err error 224 location, err = url.Parse(id) // Note that this mirrors [BlobWriter.ID]. 225 if err != nil { 226 return nil, fmt.Errorf("provided ID is not a valid location URL") 227 } 228 if !strings.HasPrefix(location.Path, "/") { 229 // Our BlobWriter.ID method always returns a fully 230 // qualified absolute URL, so this must be a mistake 231 // on the part of the caller. 232 // We allow a relative URL even though we don't 233 // ever return one to make things a bit easier for tests. 234 return nil, fmt.Errorf("provided upload ID %q has unexpected relative URL path", id) 235 } 236 } 237 ctx = ociauth.ContextWithRequestInfo(ctx, ociauth.RequestInfo{ 238 RequiredScope: ociauth.NewScope(ociauth.ResourceScope{ 239 ResourceType: "repository", 240 Resource: repo, 241 Action: "push", 242 }), 243 }) 244 return &blobWriter{ 245 ctx: ctx, 246 client: c, 247 chunkSize: chunkSize, 248 size: offset, 249 flushed: offset, 250 location: location, 251 }, nil 252 } 253 254 type blobWriter struct { 255 client *client 256 chunkSize int 257 ctx context.Context 258 259 // mu guards the fields below it. 260 mu sync.Mutex 261 closed bool 262 chunk []byte 263 closeErr error 264 265 // size holds the size of the entire upload as seen from the 266 // client perspective. Each call to Write increases this immediately. 267 size int64 268 269 // flushed holds the size of the upload as flushed to the server. 270 // Each successfully flushed chunk increases this. 271 flushed int64 272 location *url.URL 273 } 274 275 func (w *blobWriter) Write(buf []byte) (int, error) { 276 w.mu.Lock() 277 defer w.mu.Unlock() 278 279 // We use > rather than >= here so that using a chunk size of 100 280 // and writing 100 bytes does not actually flush, which would result in a PATCH 281 // then followed by an empty-bodied PUT with the call to Commit. 282 // Instead, we want the writes to not flush at all, and Commit to PUT the entire chunk. 283 if len(w.chunk)+len(buf) > w.chunkSize { 284 if err := w.flush(buf, ""); err != nil { 285 return 0, err 286 } 287 } else { 288 if w.chunk == nil { 289 w.chunk = make([]byte, 0, w.chunkSize) 290 } 291 w.chunk = append(w.chunk, buf...) 292 } 293 w.size += int64(len(buf)) 294 return len(buf), nil 295 } 296 297 // flush flushes any outstanding upload data to the server. 298 // If commitDigest is non-empty, this is the final segment of data in the blob: 299 // the blob is being committed and the digest should hold the digest of the entire blob content. 300 func (w *blobWriter) flush(buf []byte, commitDigest ociregistry.Digest) error { 301 if commitDigest == "" && len(buf)+len(w.chunk) == 0 { 302 return nil 303 } 304 // Start a new PATCH request to send the currently outstanding data. 305 method := "PATCH" 306 expect := http.StatusAccepted 307 reqURL := w.location 308 if commitDigest != "" { 309 // This is the final piece of data, so send it as the final PUT request 310 // (committing the whole blob) which avoids an extra round trip. 311 method = "PUT" 312 expect = http.StatusCreated 313 reqURL = urlWithDigest(reqURL, string(commitDigest)) 314 } 315 req, err := http.NewRequestWithContext(w.ctx, method, "", concatBody(w.chunk, buf)) 316 if err != nil { 317 return fmt.Errorf("cannot make PATCH request: %v", err) 318 } 319 req.URL = reqURL 320 req.ContentLength = int64(len(w.chunk) + len(buf)) 321 // TODO: per the spec, the content-range header here is unnecessary 322 // if we are doing a final PUT without a body. 323 req.Header.Set("Content-Range", ocirequest.RangeString(w.flushed, w.flushed+req.ContentLength)) 324 resp, err := w.client.do(req, expect) 325 if err != nil { 326 return err 327 } 328 resp.Body.Close() 329 location, err := locationFromResponse(resp) 330 if err != nil { 331 return fmt.Errorf("bad Location in response: %v", err) 332 } 333 // TODO is there something we could be doing with the Range header in the response? 334 w.location = location 335 w.flushed += req.ContentLength 336 w.chunk = w.chunk[:0] 337 return nil 338 } 339 340 func concatBody(b1, b2 []byte) io.Reader { 341 if len(b1)+len(b2) == 0 { 342 return nil // note that net/http treats a nil request body differently 343 } 344 if len(b1) == 0 { 345 return bytes.NewReader(b2) 346 } 347 if len(b2) == 0 { 348 return bytes.NewReader(b1) 349 } 350 return io.MultiReader( 351 bytes.NewReader(b1), 352 bytes.NewReader(b2), 353 ) 354 } 355 356 func (w *blobWriter) Close() error { 357 w.mu.Lock() 358 defer w.mu.Unlock() 359 if w.closed { 360 return w.closeErr 361 } 362 err := w.flush(nil, "") 363 w.closed = true 364 w.closeErr = err 365 return err 366 } 367 368 func (w *blobWriter) Size() int64 { 369 w.mu.Lock() 370 defer w.mu.Unlock() 371 return w.size 372 } 373 374 func (w *blobWriter) ChunkSize() int { 375 return w.chunkSize 376 } 377 378 func (w *blobWriter) ID() string { 379 w.mu.Lock() 380 defer w.mu.Unlock() 381 return w.location.String() 382 } 383 384 func (w *blobWriter) Commit(digest ociregistry.Digest) (ociregistry.Descriptor, error) { 385 if digest == "" { 386 return ociregistry.Descriptor{}, fmt.Errorf("cannot commit with an empty digest") 387 } 388 w.mu.Lock() 389 defer w.mu.Unlock() 390 if err := w.flush(nil, digest); err != nil { 391 return ociregistry.Descriptor{}, fmt.Errorf("cannot flush data before commit: %w", err) 392 } 393 return ociregistry.Descriptor{ 394 MediaType: "application/octet-stream", 395 Size: w.size, 396 Digest: digest, 397 }, nil 398 } 399 400 func (w *blobWriter) Cancel() error { 401 return nil 402 } 403 404 // urlWithDigest returns u with the digest query parameter set, taking care not 405 // to disrupt the initial URL (thus avoiding the charge of "manually 406 // assembing the location; see [here]. 407 // 408 // [here]: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#post-then-put 409 func urlWithDigest(u0 *url.URL, digest string) *url.URL { 410 u := *u0 411 digest = url.QueryEscape(digest) 412 switch { 413 case u.ForceQuery: 414 // The URL already ended in a "?" with no actual query parameters. 415 u.RawQuery = "digest=" + digest 416 u.ForceQuery = false 417 case u.RawQuery != "": 418 // There's already a query parameter present. 419 u.RawQuery += "&digest=" + digest 420 default: 421 u.RawQuery = "digest=" + digest 422 } 423 return &u 424 } 425 426 // See https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks 427 func chunkSizeFromResponse(resp *http.Response, chunkSize int) int { 428 minChunkSize, err := strconv.Atoi(resp.Header.Get("OCI-Chunk-Min-Length")) 429 if err == nil && minChunkSize > chunkSize { 430 return minChunkSize 431 } 432 return chunkSize 433 }