cli_tools/common/image/importer/source.go (129 lines of code) (raw):

// Copyright 2020 Google Inc. All Rights Reserved. // // 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 importer import ( "compress/gzip" "net/url" "regexp" "strings" daisy "github.com/GoogleCloudPlatform/compute-daisy" "github.com/GoogleCloudPlatform/compute-image-import/cli_tools/common/domain" "github.com/GoogleCloudPlatform/compute-image-import/cli_tools/common/utils/daisyutils" "github.com/GoogleCloudPlatform/compute-image-import/cli_tools/common/utils/param" "github.com/GoogleCloudPlatform/compute-image-import/cli_tools/common/utils/storage" ) // Source is a resource that can be imported to GCE disk images. If an instance of this // interface exists, it is expected that validation has already occurred, // and that the caller can safely use the resource. // //go:generate go run github.com/golang/mock/mockgen -package imagemocks -source $GOFILE -destination mocks/source_mocks.go type Source interface { Path() string } // SourceFactory takes the sourceFile and sourceImage specified by the user // and determines which, if any, is importable. It is an error if both sourceFile and // sourceImage are specified. type SourceFactory interface { Init(sourceFile, sourceImage string) (Source, error) } // NewSourceFactory returns an instance of SourceFactory. func NewSourceFactory(storageClient domain.StorageClientInterface) SourceFactory { return sourceFactory{storageClient: storageClient} } type sourceFactory struct { storageClient domain.StorageClientInterface } func (factory sourceFactory) Init(sourceFile, sourceImage string) (Source, error) { sourceFile = strings.TrimSpace(sourceFile) sourceImage = strings.TrimSpace(sourceImage) if sourceFile == "" && sourceImage == "" { return nil, daisy.Errf( "either -source_file or -source_image has to be specified") } if sourceFile != "" && sourceImage != "" { return nil, daisy.Errf( "either -source_file or -source_image has to be specified, but not both %v %v", sourceFile, sourceImage) } if sourceFile != "" { return newFileSource(sourceFile, factory.storageClient) } return newImageSource(sourceImage) } // Whether the resource is a file in GCS. func isFile(s Source) bool { _, ok := s.(fileSource) return ok } // Whether the resource is a GCE image. func isImage(s Source) bool { _, ok := s.(imageSource) return ok } // An importable source backed by a GCS object. type fileSource struct { gcsPath string bucket string object string } // Create a fileSource from a gcsPath to a disk image. This method uses storageClient // to read a few bytes from the file. It is an error if the file is empty, or if // the file is compressed with gzip. func newFileSource(gcsPath string, storageClient domain.StorageClientInterface) (Source, error) { sourceBucketName, sourceObjectName, err := storage.GetGCSObjectPathElements(gcsPath) if err != nil { return nil, err } source := fileSource{ gcsPath: gcsPath, bucket: sourceBucketName, object: sourceObjectName, } return source, source.validate(storageClient) } // The resource path for fileSource is its GCS path. func (s fileSource) Path() string { return s.gcsPath } // Performs basic validation, focusing on error cases that we've seen in the past. // This reads a few bytes from the file in GCS. It is an error if the file // is empty, or if the file is compressed with gzip. func (s fileSource) validate(storageClient domain.StorageObjectCreatorInterface) error { rc, err := storageClient.GetObject(s.bucket, s.object).NewReader() if err != nil { return daisy.Errf("failed to read GCS file when validating resource file: unable to open "+ "file from bucket %q, file %q: %v", s.bucket, s.object, err) } defer rc.Close() byteCountingReader := daisyutils.NewByteCountingReader(rc) // Detect whether it's a compressed file by extracting compressed file header if _, err = gzip.NewReader(byteCountingReader); err == nil { return daisy.Errf("the input file is a gzip file, which is not supported by " + "image import. To import a file that was exported from Google Compute " + "Engine, please use image create. To import a file that was exported " + "from a different system, decompress it and run image import on the " + "disk image file directly") } // By calling gzip.NewReader above, a few bytes were read from the Reader in // an attempt to decode the compression header. If the Reader represents // an empty file, then BytesRead will be zero. if byteCountingReader.BytesRead <= 0 { return daisy.Errf("cannot import an image from an empty file") } return nil } // An importable source backed by a GCE disk image. type imageSource struct { uri string } // Creates an imageSource from a reference to a GCE disk image. The syntax of the // reference is validated, but no I/O is performed to determine whether the image // exists or whether the calling user has access to it. func newImageSource(imagePath string) (Source, error) { source := imageSource{ uri: param.GetGlobalResourcePath("images", imagePath), } return source, source.validate() } var imageNamePattern = regexp.MustCompile("^[a-z]([-a-z0-9]*[a-z0-9])?$") // Performs basic validation, focusing on error cases that // we've seen in the past. Specifically: // 1. Using GCS paths, eg `gs://bucket/image.vmdk` // 2. Referencing a file, eg `image.vmdk` // // This method does not validate: // 1. Whether the image exists. // 2. Whether the calling user has access to it. // 3. Whether the user's input is a well-formed // [GCP resource URI](https://cloud.google.com/apis/design/resource_names) func (s imageSource) validate() error { parsed, err := url.Parse(s.uri) if err != nil { return err } if parsed.Scheme != "" { return daisy.Errf( "invalid image reference %q.", s.uri) } var imageName string if slash := strings.LastIndex(s.uri, "/"); slash > -1 { imageName = s.uri[slash+1:] } else { imageName = s.uri } if imageName == "" || len(imageName) > 63 { return daisy.Errf( "invalid image name %q. Image name must be 1-63 characters long, inclusive", imageName) } if !imageNamePattern.MatchString(imageName) { return daisy.Errf( "invalid image name %q. The first character must be a lowercase letter, and all "+ "following characters must be a dash, lowercase letter, or digit, except the last "+ "character, which cannot be a dash.", imageName) } return nil } // The path to an imageSource is a fully-qualified global GCP resource path. func (s imageSource) Path() string { return s.uri }