github.com/wmuizelaar/kpt@v0.0.0-20221018115725-bd564717b2ed/pkg/wasm/client.go (about)

     1  // Copyright 2022 Google LLC
     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 wasm
    16  
    17  import (
    18  	"archive/tar"
    19  	"bytes"
    20  	"compress/gzip"
    21  	"context"
    22  	"errors"
    23  	"fmt"
    24  	"io"
    25  	"io/fs"
    26  	"net/http"
    27  	"os"
    28  	"path/filepath"
    29  	"time"
    30  
    31  	"github.com/google/go-containerregistry/pkg/v1/match"
    32  
    33  	"github.com/GoogleContainerTools/kpt/pkg/oci"
    34  	"github.com/google/go-containerregistry/pkg/gcrane"
    35  	"github.com/google/go-containerregistry/pkg/name"
    36  	v1 "github.com/google/go-containerregistry/pkg/v1"
    37  	"github.com/google/go-containerregistry/pkg/v1/empty"
    38  	"github.com/google/go-containerregistry/pkg/v1/mutate"
    39  	"github.com/google/go-containerregistry/pkg/v1/remote"
    40  	"github.com/google/go-containerregistry/pkg/v1/remote/transport"
    41  	"github.com/google/go-containerregistry/pkg/v1/stream"
    42  	"github.com/google/go-containerregistry/pkg/v1/types"
    43  )
    44  
    45  type Client struct {
    46  	*oci.Storage
    47  }
    48  
    49  func NewClient(cacheDir string) (*Client, error) {
    50  	store, err := oci.NewStorage(cacheDir)
    51  	if err != nil {
    52  		return nil, err
    53  	}
    54  	return &Client{Storage: store}, nil
    55  }
    56  
    57  func (r *Client) PushWasm(ctx context.Context, wasmFile string, imageName string) error {
    58  	tag, err := name.NewTag(imageName)
    59  	if err != nil {
    60  		return fmt.Errorf("unable to parse tag %q: %v", imageName, err)
    61  	}
    62  
    63  	options := []remote.Option{
    64  		remote.WithAuthFromKeychain(gcrane.Keychain),
    65  		remote.WithContext(ctx),
    66  	}
    67  
    68  	rmt, err := remote.Get(tag, options...)
    69  	if err != nil && !isManifestNotFoundErr(err) {
    70  		return fmt.Errorf("unexpected error when trying to check the remote registry: %w", err)
    71  	}
    72  
    73  	// If there is an existing remote image, we ensure its media type is an image index type.
    74  	// TODO: if the media type is not an image index type, we can do something smarter than
    75  	// simply failing.
    76  	if err == nil {
    77  		if rmt.MediaType != types.OCIImageIndex && rmt.MediaType != types.DockerManifestList {
    78  			return fmt.Errorf("unexpected media type: %s, expect either %s or %s", rmt.MediaType, types.OCIImageIndex, types.DockerManifestList)
    79  		}
    80  	}
    81  
    82  	// Compress the wasm file.
    83  	tarReader, err := wasmFileToTar(wasmFile)
    84  	if err != nil {
    85  		return fmt.Errorf("unable to compress %v: %w", wasmFile, err)
    86  	}
    87  
    88  	// Construct the image by building its layer.
    89  	layer := stream.NewLayer(io.NopCloser(tarReader), stream.WithCompressionLevel(gzip.BestCompression))
    90  	if err = remote.WriteLayer(tag.Repository, layer, options...); err != nil {
    91  		return fmt.Errorf("failed to write remote layer: %w", err)
    92  	}
    93  	img, err := mutate.AppendLayers(empty.Image, layer)
    94  	if err != nil {
    95  		return fmt.Errorf("failed to append image layers: %w", err)
    96  	}
    97  	img = mutate.MediaType(img, types.DockerManifestSchema2)
    98  	img, err = mutate.CreatedAt(img, v1.Time{Time: time.Now()})
    99  	if err != nil {
   100  		return fmt.Errorf("failed to set created time for the image: %w", err)
   101  	}
   102  	hash, err := img.Digest()
   103  	if err != nil {
   104  		return fmt.Errorf("failed to get digest of the image: %w", err)
   105  	}
   106  
   107  	// Push the image to remote
   108  	if err = remote.Write(tag.Repository.Digest(hash.String()), img, options...); err != nil {
   109  		return fmt.Errorf("failed to push image %s: %w", tag, err)
   110  	}
   111  	fmt.Printf("digest of the image: %v\n", hash.String())
   112  
   113  	// Construct the image index.
   114  	var index v1.ImageIndex = empty.Index
   115  	if rmt != nil {
   116  		index, err = rmt.ImageIndex()
   117  		if err != nil {
   118  			return fmt.Errorf("unable to parse an image as image index: %w", err)
   119  		}
   120  	}
   121  
   122  	wasmPlatform := v1.Platform{
   123  		Architecture: "wasm",
   124  		OS:           "js",
   125  	}
   126  	index = mutate.RemoveManifests(index, match.Platforms(wasmPlatform))
   127  	index = mutate.AppendManifests(index, mutate.IndexAddendum{
   128  		Add: img,
   129  		Descriptor: v1.Descriptor{
   130  			MediaType: "application/vnd.docker.distribution.manifest.v2+json",
   131  			Digest:    hash,
   132  			Platform:  &wasmPlatform,
   133  		},
   134  	})
   135  	index = mutate.IndexMediaType(index, types.DockerManifestList)
   136  
   137  	// Push the image index to remote.
   138  	err = remote.WriteIndex(tag, index, options...)
   139  	if err != nil {
   140  		return fmt.Errorf("unable to write image index: %w", err)
   141  	}
   142  	indexHash, err := index.Digest()
   143  	if err != nil {
   144  		return fmt.Errorf("unable to get digest for the image index: %w", err)
   145  	}
   146  	fmt.Printf("digest of the image index: %v\n", indexHash.String())
   147  	return nil
   148  }
   149  
   150  func wasmFileToTar(filename string) (io.Reader, error) {
   151  	buf := bytes.NewBuffer(nil)
   152  	writer := tar.NewWriter(buf)
   153  
   154  	b, err := os.ReadFile(filename)
   155  	if err != nil {
   156  		return nil, fmt.Errorf("failed to read from file %v: %w", filename, err)
   157  	}
   158  	blen := len(b)
   159  
   160  	if err = writer.WriteHeader(&tar.Header{
   161  		Name: filepath.Base(filename),
   162  		Size: int64(blen),
   163  		Mode: 0644,
   164  	}); err != nil {
   165  		return nil, fmt.Errorf("failed to write tar header: %w", err)
   166  	}
   167  
   168  	if _, err = writer.Write(b); err != nil {
   169  		return nil, fmt.Errorf("failed to write tar contents: %w", err)
   170  	}
   171  	return buf, nil
   172  }
   173  
   174  func (r *Client) LoadWasm(ctx context.Context, imageName string) (io.ReadCloser, error) {
   175  	ref, err := name.ParseReference(imageName)
   176  	if err != nil {
   177  		return nil, err
   178  	}
   179  
   180  	fetcher := func() (io.ReadCloser, error) {
   181  		options := []remote.Option{
   182  			remote.WithContext(ctx),
   183  			remote.WithAuthFromKeychain(gcrane.Keychain),
   184  			remote.WithPlatform(v1.Platform{
   185  				Architecture: "wasm",
   186  				OS:           "js",
   187  			}),
   188  		}
   189  		ociImage, err := remote.Image(ref, options...)
   190  		if err != nil {
   191  			return nil, fmt.Errorf("unable to get remote image: %w", err)
   192  		}
   193  
   194  		reader := mutate.Extract(ociImage)
   195  		return reader, nil
   196  	}
   197  
   198  	// We need the per-digest cache here because otherwise we have to make a network request to look up the manifest in remote.Image
   199  	// (this could be cached by the go-containerregistry library, for some reason it is not...)
   200  	// TODO: Is there then any real reason to _also_ have the image-layer cache?
   201  	f, err := oci.WithCacheFile(filepath.Join(r.GetCacheDir(), "wasm", ref.String()), fetcher)
   202  	if err != nil {
   203  		return nil, err
   204  	}
   205  
   206  	wrapper := &tarReadCloser{
   207  		Reader: tar.NewReader(f),
   208  		closer: f,
   209  	}
   210  
   211  	wasmBytesReader, err := loadWasmFromTar(wrapper)
   212  	if err != nil {
   213  		return nil, err
   214  	}
   215  	return wasmBytesReader, nil
   216  }
   217  
   218  type tarReadCloser struct {
   219  	*tar.Reader
   220  	closer io.Closer
   221  }
   222  
   223  func (w *tarReadCloser) Close() error {
   224  	return w.closer.Close()
   225  }
   226  
   227  func loadWasmFromTar(tarReadCloser *tarReadCloser) (io.ReadCloser, error) {
   228  	for {
   229  		hdr, err := tarReadCloser.Next()
   230  		if err == io.EOF {
   231  			break
   232  		}
   233  		if err != nil {
   234  			return nil, err
   235  		}
   236  		path := hdr.Name
   237  		fileType := hdr.FileInfo().Mode().Type()
   238  		switch fileType {
   239  		case fs.ModeDir:
   240  			// Ignored
   241  		case fs.ModeSymlink:
   242  			// We probably don't want to support this; feels high-risk, low-reward
   243  			return nil, fmt.Errorf("package cannot contain symlink (%q)", path)
   244  		case 0:
   245  			// there is only one file in it
   246  			return tarReadCloser, nil
   247  		default:
   248  			return nil, fmt.Errorf("package cannot unsupported entry type for %q (%v)", path, fileType)
   249  		}
   250  	}
   251  
   252  	return nil, nil
   253  }
   254  
   255  func isManifestNotFoundErr(err error) bool {
   256  	if err == nil {
   257  		return false
   258  	}
   259  	var terr *transport.Error
   260  	isTransportError := errors.As(err, &terr)
   261  	if !isTransportError {
   262  		return false
   263  	}
   264  	if terr.StatusCode != http.StatusNotFound {
   265  		return false
   266  	}
   267  	foundManifestUnknown := false
   268  	for _, e := range terr.Errors {
   269  		if e.Code == transport.ManifestUnknownErrorCode {
   270  			foundManifestUnknown = true
   271  		}
   272  	}
   273  	return foundManifestUnknown
   274  }