blob: c2c2df213a72cc654e5637e06caa3e0d7c43a6c1 [file] [log] [blame]
// Copyright Istio Authors
//
// 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 wasm
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/tls"
"fmt"
"io"
"path/filepath"
"reflect"
"strings"
)
import (
"github.com/docker/cli/cli/config/configfile"
dtypes "github.com/docker/cli/cli/config/types"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/google/go-containerregistry/pkg/v1/types"
"github.com/hashicorp/go-multierror"
)
// This file implements the fetcher of "Wasm Image Specification" compatible container images.
// The spec is here https://github.com/solo-io/wasm/blob/master/spec/README.md.
// Basically, this supports fetching and unpackaging three types of container images containing a Wasm binary.
type ImageFetcherOption struct {
// TODO(mathetake) Add signature verification stuff.
PullSecret []byte
Insecure bool
}
func (o *ImageFetcherOption) useDefaultKeyChain() bool {
return o.PullSecret == nil
}
func (o ImageFetcherOption) String() string {
if o.PullSecret == nil {
return fmt.Sprintf("{Insecure: %v}", o.Insecure)
}
return fmt.Sprintf("{Insecure: %v, PullSecret: <redacted>}", o.Insecure)
}
type ImageFetcher struct {
fetchOpts []remote.Option
}
func NewImageFetcher(ctx context.Context, opt ImageFetcherOption) *ImageFetcher {
fetchOpts := make([]remote.Option, 0, 2)
// TODO(mathetake): have "Anonymous" option?
if opt.useDefaultKeyChain() {
// Note that default key chain reads the docker config from DOCKER_CONFIG
// so must set the envvar when reaching this branch is expected.
fetchOpts = append(fetchOpts, remote.WithAuthFromKeychain(authn.DefaultKeychain))
} else {
fetchOpts = append(fetchOpts, remote.WithAuthFromKeychain(&wasmKeyChain{data: opt.PullSecret}))
}
if opt.Insecure {
t := remote.DefaultTransport.Clone()
t.TLSClientConfig = &tls.Config{
InsecureSkipVerify: opt.Insecure, //nolint: gosec
}
fetchOpts = append(fetchOpts, remote.WithTransport(t))
}
return &ImageFetcher{
fetchOpts: append(fetchOpts, remote.WithContext(ctx)),
}
}
// PrepareFetch is the entrypoint for fetching Wasm binary from Wasm Image Specification compatible images.
// Wasm binary is not fetched immediately, but returned by `binaryFetcher` function, which is returned by PrepareFetch.
// By this way, we can have another chance to check cache with `actualDigest` without downloading the OCI image.
func (o *ImageFetcher) PrepareFetch(url string) (binaryFetcher func() ([]byte, error), actualDigest string, err error) {
ref, err := name.ParseReference(url)
if err != nil {
err = fmt.Errorf("could not parse url in image reference: %v", err)
return
}
// fallback to http based request, inspired by [helm](https://github.com/helm/helm/blob/12f1bc0acdeb675a8c50a78462ed3917fb7b2e37/pkg/registry/client.go#L594)
// only deal with https fallback instead of attributing all other type of errors to URL parsing error
desc, err := remote.Get(ref, o.fetchOpts...)
if err != nil && strings.Contains(err.Error(), "server gave HTTP response") {
wasmLog.Infof("fetch with plain text from %s", url)
ref, err = name.ParseReference(url, name.Insecure)
if err == nil {
desc, err = remote.Get(ref, o.fetchOpts...)
}
}
if err != nil {
err = fmt.Errorf("could not fetch manifest: %v", err)
return
}
// Fetch image.
img, err := desc.Image()
if err != nil {
err = fmt.Errorf("could not fetch image: %v", err)
return
}
// Check Manifest's digest if expManifestDigest is not empty.
d, _ := img.Digest()
actualDigest = d.Hex
binaryFetcher = func() ([]byte, error) {
manifest, err := img.Manifest()
if err != nil {
return nil, fmt.Errorf("could not retrieve manifest: %v", err)
}
if manifest.MediaType == types.DockerManifestSchema2 {
// This case, assume we have docker images with "application/vnd.docker.distribution.manifest.v2+json"
// as the manifest media type. Note that the media type of manifest is Docker specific and
// all OCI images would have an empty string in .MediaType field.
ret, err := extractDockerImage(img)
if err != nil {
return nil, fmt.Errorf("could not extract Wasm file from the image as Docker container %v", err)
}
return ret, nil
}
// We try to parse it as the "compat" variant image with a single "application/vnd.oci.image.layer.v1.tar+gzip" layer.
ret, errCompat := extractOCIStandardImage(img)
if errCompat == nil {
return ret, nil
}
// Otherwise, we try to parse it as the *oci* variant image with custom artifact media types.
ret, errOCI := extractOCIArtifactImage(img)
if errOCI == nil {
return ret, nil
}
// We failed to parse the image in any format, so wrap the errors and return.
return nil, fmt.Errorf("the given image is in invalid format as an OCI image: %v",
multierror.Append(err,
fmt.Errorf("could not parse as compat variant: %v", errCompat),
fmt.Errorf("could not parse as oci variant: %v", errOCI),
),
)
}
return
}
// extractDockerImage extracts the Wasm binary from the
// *compat* variant Wasm image with the standard Docker media type: application/vnd.docker.image.rootfs.diff.tar.gzip.
// https://github.com/solo-io/wasm/blob/master/spec/spec-compat.md#specification
func extractDockerImage(img v1.Image) ([]byte, error) {
layers, err := img.Layers()
if err != nil {
return nil, fmt.Errorf("could not fetch layers: %v", err)
}
// The image must be single-layered.
if len(layers) != 1 {
return nil, fmt.Errorf("number of layers must be 1 but got %d", len(layers))
}
layer := layers[0]
mt, err := layer.MediaType()
if err != nil {
return nil, fmt.Errorf("could not get media type: %v", err)
}
// Media type must be application/vnd.docker.image.rootfs.diff.tar.gzip.
if mt != types.DockerLayer {
return nil, fmt.Errorf("invalid media type %s (expect %s)", mt, types.DockerLayer)
}
r, err := layer.Compressed()
if err != nil {
return nil, fmt.Errorf("could not get layer content: %v", err)
}
defer r.Close()
ret, err := extractWasmPluginBinary(r)
if err != nil {
return nil, fmt.Errorf("could not extract wasm binary: %v", err)
}
return ret, nil
}
// extractOCIStandardImage extracts the Wasm binary from the
// *compat* variant Wasm image with the standard OCI media type: application/vnd.oci.image.layer.v1.tar+gzip.
// https://github.com/solo-io/wasm/blob/master/spec/spec-compat.md#specification
func extractOCIStandardImage(img v1.Image) ([]byte, error) {
layers, err := img.Layers()
if err != nil {
return nil, fmt.Errorf("could not fetch layers: %v", err)
}
// The image must be single-layered.
if len(layers) != 1 {
return nil, fmt.Errorf("number of layers must be 1 but got %d", len(layers))
}
layer := layers[0]
mt, err := layer.MediaType()
if err != nil {
return nil, fmt.Errorf("could not get media type: %v", err)
}
// Check if the layer is "application/vnd.oci.image.layer.v1.tar+gzip".
if types.OCILayer != mt {
return nil, fmt.Errorf("invalid media type %s (expect %s)", mt, types.OCILayer)
}
r, err := layer.Compressed()
if err != nil {
return nil, fmt.Errorf("could not get layer content: %v", err)
}
defer r.Close()
ret, err := extractWasmPluginBinary(r)
if err != nil {
return nil, fmt.Errorf("could not extract wasm binary: %v", err)
}
return ret, nil
}
// Extracts the Wasm plugin binary named "plugin.wasm" in a given reader for tar.gz.
// This is only used for *compat* variant.
func extractWasmPluginBinary(r io.Reader) ([]byte, error) {
gr, err := gzip.NewReader(r)
if err != nil {
return nil, fmt.Errorf("failed to parse layer as tar.gz: %v", err)
}
// The target file name for Wasm binary.
// https://github.com/solo-io/wasm/blob/master/spec/spec-compat.md#specification
const wasmPluginFileName = "plugin.wasm"
// Search for the file walking through the archive.
tr := tar.NewReader(gr)
for {
h, err := tr.Next()
if err == io.EOF {
break
} else if err != nil {
return nil, err
}
ret := make([]byte, h.Size)
if filepath.Base(h.Name) == wasmPluginFileName {
_, err := io.ReadFull(tr, ret)
if err != nil {
return nil, fmt.Errorf("failed to read %s: %v", wasmPluginFileName, err)
}
return ret, nil
}
}
return nil, fmt.Errorf("%s not found in the archive", wasmPluginFileName)
}
// extractOCIArtifactImage extracts the Wasm binary from the
// *oci* variant Wasm image: https://github.com/solo-io/wasm/blob/master/spec/spec.md#format
func extractOCIArtifactImage(img v1.Image) ([]byte, error) {
layers, err := img.Layers()
if err != nil {
return nil, fmt.Errorf("could not fetch layers: %v", err)
}
// The image must be two-layered.
if len(layers) != 2 {
return nil, fmt.Errorf("number of layers must be 2 but got %d", len(layers))
}
// The layer type of the Wasm binary itself in *oci* variant.
const wasmLayerMediaType = "application/vnd.module.wasm.content.layer.v1+wasm"
// Find the target layer walking through the layers.
var layer v1.Layer
for _, l := range layers {
mt, err := l.MediaType()
if err != nil {
return nil, fmt.Errorf("could not retrieve the media type: %v", err)
}
if mt == wasmLayerMediaType {
layer = l
break
}
}
if layer == nil {
return nil, fmt.Errorf("could not find the layer of type %s", wasmLayerMediaType)
}
// Somehow go-containerregistry recognizes custom artifact layers as compressed ones,
// while the Solo's Wasm layer is actually uncompressed and therefore
// the content itself is a raw Wasm binary. So using "Uncompressed()" here result in errors
// since internally it tries to umcompress it as gzipped blob.
r, err := layer.Compressed()
if err != nil {
return nil, fmt.Errorf("could not get layer content: %v", err)
}
defer r.Close()
// Just read it since the content is already a raw Wasm binary as mentioned above.
ret, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("could not extract wasm binary: %v", err)
}
return ret, nil
}
type wasmKeyChain struct {
data []byte
}
// Resolve an image reference to a credential.
// The function code is borrowed from https://github.com/google/go-containerregistry/blob/v0.8.0/pkg/authn/keychain.go#L65,
// by making it take dockerconfigjson directly as bytes instead of reading from files.
func (k *wasmKeyChain) Resolve(target authn.Resource) (authn.Authenticator, error) {
if reflect.DeepEqual(k.data, []byte("null")) {
// Filter out key chain with content "null" to prevent crash at underlying docker library.
// Remove this check when https://github.com/docker/cli/pull/3434 is merged.
return nil, fmt.Errorf("")
}
reader := bytes.NewReader(k.data)
cf := configfile.ConfigFile{}
if err := cf.LoadFromReader(reader); err != nil {
return nil, err
}
key := target.RegistryStr()
if key == name.DefaultRegistry {
key = authn.DefaultAuthKey
}
cfg, err := cf.GetAuthConfig(key)
if err != nil {
return nil, err
}
empty := dtypes.AuthConfig{}
if cfg == empty {
return authn.Anonymous, nil
}
authConfig := authn.AuthConfig{
Username: cfg.Username,
Password: cfg.Password,
Auth: cfg.Auth,
IdentityToken: cfg.IdentityToken,
RegistryToken: cfg.RegistryToken,
}
return authn.FromConfig(authConfig), nil
}