pulumi/sdk/go/common/resource/asset/asset.go

427 lines
12 KiB
Go

// Copyright 2016-2018, Pulumi Corporation.
//
// 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 asset
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource/sig"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/httputil"
)
// Asset is a serialized asset reference. It is a union: thus, at most one of its fields will be non-nil. Several helper
// routines exist as members in order to easily interact with the assets referenced by an instance of this type.
type Asset struct {
// Sig is the unique asset type signature (see properties.go).
Sig string `json:"4dabf18193072939515e22adb298388d" yaml:"4dabf18193072939515e22adb298388d"`
// Hash is the SHA256 hash of the asset's contents.
Hash string `json:"hash,omitempty" yaml:"hash,omitempty"`
// Text is set to a non-empty value for textual assets.
Text string `json:"text,omitempty" yaml:"text,omitempty"`
// Path will contain a non-empty path to the file on the current filesystem for file assets.
Path string `json:"path,omitempty" yaml:"path,omitempty"`
// URI will contain a non-empty URI (file://, http://, https://, or custom) for URI-backed assets.
URI string `json:"uri,omitempty" yaml:"uri,omitempty"`
}
const (
AssetSig = sig.AssetSig
AssetHashProperty = "hash" // the dynamic property for an asset's hash.
AssetTextProperty = "text" // the dynamic property for an asset's text.
AssetPathProperty = "path" // the dynamic property for an asset's path.
AssetURIProperty = "uri" // the dynamic property for an asset's URI.
)
// FromText produces a new asset and its corresponding SHA256 hash from the given text.
func FromText(text string) (*Asset, error) {
a := &Asset{Sig: AssetSig, Text: text}
// Special case the empty string otherwise EnsureHash will fail.
if text == "" {
a.Hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
err := a.EnsureHash()
return a, err
}
// FromPath produces a new asset and its corresponding SHA256 hash from the given filesystem path.
func FromPath(path string) (*Asset, error) {
a := &Asset{Sig: AssetSig, Path: path}
err := a.EnsureHash()
return a, err
}
// FromPathWithWD produces a new asset and its corresponding SHA256 hash from the given filesystem path.
func FromPathWithWD(path string, wd string) (*Asset, error) {
a := &Asset{Sig: AssetSig, Path: path}
err := a.EnsureHashWithWD(wd)
return a, err
}
// FromURI produces a new asset and its corresponding SHA256 hash from the given network URI.
func FromURI(uri string) (*Asset, error) {
a := &Asset{Sig: AssetSig, URI: uri}
err := a.EnsureHash()
return a, err
}
func (a *Asset) IsText() bool {
if a.IsPath() || a.IsURI() {
return false
}
if a.Text != "" {
return true
}
// We can't easily tell the difference between an Asset that really has the empty string as its text and one that
// has no text at all. If we have a hash we can check if that's the "zero hash" and if so then we know the text is
// just empty. If the hash does not equal the empty hash then we know this is a _placeholder_ asset where the text is
// just currently not known. If we don't have a hash then we can't tell the difference and assume it's just empty.
if a.Hash == "" || a.Hash == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" {
return true
}
return false
}
func (a *Asset) IsPath() bool { return a.Path != "" }
func (a *Asset) IsURI() bool { return a.URI != "" }
func (a *Asset) GetText() (string, bool) {
if a.IsText() {
return a.Text, true
}
return "", false
}
func (a *Asset) GetPath() (string, bool) {
if a.IsPath() {
return a.Path, true
}
return "", false
}
func (a *Asset) GetURI() (string, bool) {
if a.IsURI() {
return a.URI, true
}
return "", false
}
// GetURIURL returns the underlying URI as a parsed URL, provided it is one. If there was an error parsing the URI, it
// will be returned as a non-nil error object.
func (a *Asset) GetURIURL() (*url.URL, bool, error) {
if uri, isuri := a.GetURI(); isuri {
url, err := url.Parse(uri)
if err != nil {
return nil, true, err
}
return url, true, nil
}
return nil, false, nil
}
// Equals returns true if a is value-equal to other. In this case, value equality is determined only by the hash: even
// if the contents of two assets come from different sources, they are treated as equal if their hashes match.
// Similarly, if the contents of two assets come from the same source but the assets have different hashes, the assets
// are not equal.
func (a *Asset) Equals(other *Asset) bool {
if a == nil {
return other == nil
} else if other == nil {
return false
}
// If we can't get a hash for both assets, treat them as differing.
if err := a.EnsureHash(); err != nil {
return false
}
if err := other.EnsureHash(); err != nil {
return false
}
return a.Hash == other.Hash
}
// Serialize returns a weakly typed map that contains the right signature for serialization purposes.
func (a *Asset) Serialize() map[string]interface{} {
result := map[string]interface{}{
sig.Key: AssetSig,
}
if a.Hash != "" {
result[AssetHashProperty] = a.Hash
}
if a.Text != "" {
result[AssetTextProperty] = a.Text
}
if a.Path != "" {
result[AssetPathProperty] = a.Path
}
if a.URI != "" {
result[AssetURIProperty] = a.URI
}
return result
}
// DeserializeAsset checks to see if the map contains an asset, using its signature, and if so deserializes it.
func Deserialize(obj map[string]interface{}) (*Asset, bool, error) {
// If not an asset, return false immediately.
if obj[sig.Key] != AssetSig {
return &Asset{}, false, nil
}
// Else, deserialize the possible fields.
var hash string
if v, has := obj[AssetHashProperty]; has {
h, ok := v.(string)
if !ok {
return &Asset{}, false, fmt.Errorf("unexpected asset hash of type %T", v)
}
hash = h
}
var text string
if v, has := obj[AssetTextProperty]; has {
t, ok := v.(string)
if !ok {
return &Asset{}, false, fmt.Errorf("unexpected asset text of type %T", v)
}
text = t
}
var path string
if v, has := obj[AssetPathProperty]; has {
p, ok := v.(string)
if !ok {
return &Asset{}, false, fmt.Errorf("unexpected asset path of type %T", v)
}
path = p
}
var uri string
if v, has := obj[AssetURIProperty]; has {
u, ok := v.(string)
if !ok {
return &Asset{}, false, fmt.Errorf("unexpected asset URI of type %T", v)
}
uri = u
}
return &Asset{Sig: AssetSig, Hash: hash, Text: text, Path: path, URI: uri}, true, nil
}
// HasContents indicates whether or not an asset's contents can be read.
func (a *Asset) HasContents() bool {
return a.IsText() || a.IsPath() || a.IsURI()
}
// Bytes returns the contents of the asset as a byte slice.
func (a *Asset) Bytes() ([]byte, error) {
// If this is a text asset, just return its bytes directly.
if text, istext := a.GetText(); istext {
return []byte(text), nil
}
blob, err := a.Read()
if err != nil {
return nil, err
}
return io.ReadAll(blob)
}
// Read begins reading an asset.
func (a *Asset) Read() (*Blob, error) {
wd, err := os.Getwd()
if err != nil {
return nil, err
}
return a.ReadWithWD(wd)
}
// ReadWithWD begins reading an asset.
func (a *Asset) ReadWithWD(wd string) (*Blob, error) {
if a.IsText() {
return a.readText()
} else if a.IsPath() {
return a.readPath(wd)
} else if a.IsURI() {
return a.readURI()
}
return nil, errors.New("unrecognized asset type")
}
func (a *Asset) readText() (*Blob, error) {
text, istext := a.GetText()
contract.Assertf(istext, "Expected a text-based asset")
return NewByteBlob([]byte(text)), nil
}
func (a *Asset) readPath(wd string) (*Blob, error) {
path, ispath := a.GetPath()
contract.Assertf(ispath, "Expected a path-based asset")
if !filepath.IsAbs(path) {
path = filepath.Join(wd, path)
}
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to open asset file '%v': %w", path, err)
}
// Do a quick check to make sure it's a file, so we can fail gracefully if someone passes a directory.
info, err := file.Stat()
if err != nil {
contract.IgnoreClose(file)
return nil, fmt.Errorf("failed to stat asset file '%v': %w", path, err)
}
if info.IsDir() {
contract.IgnoreClose(file)
return nil, fmt.Errorf("asset path '%v' is a directory; try using an archive", path)
}
blob := &Blob{
rd: file,
sz: info.Size(),
}
return blob, nil
}
func (a *Asset) readURI() (*Blob, error) {
url, isURL, err := a.GetURIURL()
if err != nil {
return nil, err
}
contract.Assertf(isURL, "Expected a URI-based asset")
switch s := url.Scheme; s {
case "http", "https":
resp, err := httputil.GetWithRetry(url.String(), http.DefaultClient)
if err != nil {
return nil, err
}
return NewReadCloserBlob(resp.Body)
case "file":
contract.Assertf(url.User == nil, "file:// URIs cannot have a user: %v", url)
contract.Assertf(url.RawQuery == "", "file:// URIs cannot have a query string: %v", url)
contract.Assertf(url.Fragment == "", "file:// URIs cannot have a fragment: %v", url)
if url.Host != "" && url.Host != "localhost" {
return nil, fmt.Errorf("file:// host '%v' not supported (only localhost)", url.Host)
}
f, err := os.Open(url.Path)
if err != nil {
return nil, err
}
return NewFileBlob(f)
default:
return nil, fmt.Errorf("Unrecognized or unsupported URI scheme: %v", s)
}
}
// EnsureHash computes the SHA256 hash of the asset's contents and stores it on the object.
func (a *Asset) EnsureHash() error {
if a.Hash == "" {
blob, err := a.Read()
if err != nil {
return err
}
defer contract.IgnoreClose(blob)
hash := sha256.New()
n, err := io.Copy(hash, blob)
if err != nil {
return err
}
if n != blob.Size() {
return fmt.Errorf("incorrect blob size: expected %v, got %v", blob.Size(), n)
}
a.Hash = hex.EncodeToString(hash.Sum(nil))
}
return nil
}
// EnsureHash computes the SHA256 hash of the asset's contents and stores it on the object.
func (a *Asset) EnsureHashWithWD(wd string) error {
if a.Hash == "" {
blob, err := a.ReadWithWD(wd)
if err != nil {
return err
}
defer contract.IgnoreClose(blob)
hash := sha256.New()
n, err := io.Copy(hash, blob)
if err != nil {
return err
}
if n != blob.Size() {
return fmt.Errorf("incorrect blob size: expected %v, got %v", blob.Size(), n)
}
a.Hash = hex.EncodeToString(hash.Sum(nil))
}
return nil
}
// Blob is a blob that implements ReadCloser and offers Len functionality.
type Blob struct {
rd io.ReadCloser // an underlying reader.
sz int64 // the size of the blob.
}
func (blob *Blob) Close() error { return blob.rd.Close() }
func (blob *Blob) Read(p []byte) (int, error) { return blob.rd.Read(p) }
func (blob *Blob) Size() int64 { return blob.sz }
// NewByteBlob creates a new byte blob.
func NewByteBlob(data []byte) *Blob {
return &Blob{
rd: io.NopCloser(bytes.NewReader(data)),
sz: int64(len(data)),
}
}
// NewFileBlob creates a new asset blob whose size is known thanks to stat.
func NewFileBlob(f *os.File) (*Blob, error) {
stat, err := f.Stat()
if err != nil {
return nil, err
}
return &Blob{
rd: f,
sz: stat.Size(),
}, nil
}
// NewReadCloserBlob turn any old ReadCloser into an Blob, usually by making a copy.
func NewReadCloserBlob(r io.ReadCloser) (*Blob, error) {
if f, isf := r.(*os.File); isf {
// If it's a file, we can "fast path" the asset creation without making a copy.
return NewFileBlob(f)
}
// Otherwise, read it all in, and create a blob out of that.
defer contract.IgnoreClose(r)
data, err := io.ReadAll(r)
if err != nil {
return nil, err
}
return NewByteBlob(data), nil
}
func NewRawBlob(r io.ReadCloser, size int64) *Blob {
return &Blob{rd: r, sz: size}
}