asteris-llc/converge

View on GitHub
resource/file/fetch/fetch.go

Summary

Maintainability
B
5 hrs
Test Coverage
// Copyright © 2016 Asteris, LLC
//
// 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 fetch

import (
    "crypto/md5"
    "crypto/sha1"
    "crypto/sha256"
    "crypto/sha512"
    "encoding/hex"
    "fmt"
    "hash"
    "io"
    "net/url"
    "os"

    "github.com/asteris-llc/converge/resource"
    "github.com/hashicorp/go-getter"
    "github.com/pkg/errors"
    "golang.org/x/net/context"
)

// Hash type for Fetch
type Hash string

const (
    // HashMD5 indicates hash type md5
    HashMD5 Hash = "md5"

    // HashSHA1 indicates hash type sha1
    HashSHA1 Hash = "sha1"

    // HashSHA256 indicates hash type sha256
    HashSHA256 Hash = "sha256"

    // HashSHA512 indicates hash type sha512
    HashSHA512 Hash = "sha512"
)

// Fetch gets a file and makes it available on disk
type Fetch struct {
    // location of the file to fetch
    Source string `export:"source"`

    // destination for the fetched file
    Destination string `export:"destination"`

    // hash function used to generate the checksum hash; value is available for
    // lookup if set in the hcl
    HashType string `export:"hash_type"`

    // the checksum hash; value is available for lookup if set in the hcl
    Hash string `export:"hash"`

    // whether the file will be fetched if it already exists
    Force bool `export:"force"`

    // whether the fetched file will be unarchived
    Unarchive bool

    hasApplied bool
}

// response struct
// contains response (resource.TaskStatus and error) from Check and Apply
type response struct {
    status resource.TaskStatus
    err    error
}

// Check if changes are needed for Fetch
func (f *Fetch) Check(ctx context.Context, r resource.Renderer) (resource.TaskStatus, error) {
    ch := make(chan response, 1)

    go func(ctx context.Context, r resource.Renderer) {
        status, err := f.checkWithContext(ctx, r)
        ch <- response{status, err}
    }(ctx, r)

    select {
    case <-ctx.Done():
        return nil, ctx.Err()
    case check := <-ch:
        return check.status, check.err
    }
}

// Apply changes for Fetch
func (f *Fetch) Apply(ctx context.Context) (resource.TaskStatus, error) {
    ch := make(chan response, 1)

    go func(ctx context.Context) {
        status, err := f.applyWithContext(ctx)
        ch <- response{status, err}
    }(ctx)

    select {
    case <-ctx.Done():
        return nil, ctx.Err()
    case apply := <-ch:
        return apply.status, apply.err
    }
}

// checkWithContext implements Check for Fetch
func (f *Fetch) checkWithContext(context.Context, resource.Renderer) (resource.TaskStatus, error) {
    var (
        hsh    hash.Hash
        err    error
        status = resource.NewStatus()
    )

    if f.hasApplied {
        return status, nil
    }

    if f.Hash != "" {
        hsh, err = f.getHash()
        if err != nil {
            status.RaiseLevel(resource.StatusCantChange)
            return status, errors.Wrap(err, "will not attempt file fetch")
        }
    }

    status, err = f.DiffFile(status, hsh)

    return status, err
}

// applyWithContext implements Apply for Fetch
func (f *Fetch) applyWithContext(context.Context) (resource.TaskStatus, error) {
    var (
        hsh      hash.Hash
        err      error
        status   = resource.NewStatus()
        checksum = ""
        mode     = getter.ClientModeFile
    )

    if f.Hash != "" {
        hsh, err = f.getHash()
        if err != nil {
            status.RaiseLevel(resource.StatusCantChange)
            return status, errors.Wrap(err, "will not attempt file fetch")
        }
    }

    stat, err := f.DiffFile(status, hsh)
    if err != nil {
        return stat, err
    } else if !resource.AnyChanges(stat.Differences) {
        return status, nil
    }

    u, err := url.Parse(f.Source)
    if err != nil {
        status.RaiseLevel(resource.StatusFatal)
        return status, errors.Wrap(err, "could not parse source")
    }

    values := u.Query()
    if f.Unarchive == false {
        values.Set("archive", "false")
    } else {
        mode = getter.ClientModeAny
    }
    if f.Hash != "" {
        checksum = fmt.Sprintf("checksum=%s:%s", f.HashType, f.Hash)
    }
    source := u.String() + "?" + values.Encode() + checksum

    pwd, err := os.Getwd()
    if err != nil {
        status.RaiseLevel(resource.StatusFatal)
        return status, errors.Wrap(err, "failed to get working directory")
    }

    client := &getter.Client{
        Src:  source,
        Dst:  f.Destination,
        Pwd:  pwd,
        Mode: mode,
    }
    if err := client.Get(); err != nil {
        status.RaiseLevel(resource.StatusFatal)
        return status, errors.Wrap(err, "failed to fetch")
    }
    status.AddMessage("fetched successfully")
    f.hasApplied = true

    return status, nil
}

// DiffFile evaluates the differences of the file to be fetched and the current
// state of the system
func (f *Fetch) DiffFile(status *resource.Status, hsh hash.Hash) (*resource.Status, error) {
    // the destination should be a file if fetching without an unarchive
    // if unarchiving, the destination should be a directory
    stat, err := os.Stat(f.Destination)
    if err == nil {
        if stat.IsDir() {
            if f.Unarchive == false {
                status.RaiseLevel(resource.StatusCantChange)
                return status, fmt.Errorf("invalid destination %q, cannot be directory", f.Destination)
            }
            status.RaiseLevel(resource.StatusWillChange)
            status.AddDifference("destination", "<absent>", f.Destination, "")
            return status, nil
        }

        if f.Unarchive {
            status.RaiseLevel(resource.StatusCantChange)
            return status, fmt.Errorf("invalid destination %q for unarchiving, must be directory", f.Destination)
        }
    } else if os.IsNotExist(err) {
        status.RaiseLevel(resource.StatusWillChange)
        status.AddDifference("destination", "<absent>", f.Destination, "")
        return status, nil
    }

    // file exists, evaluate what needs to change
    if hsh != nil {
        actual, err := f.getChecksum(hsh)
        if err != nil {
            status.RaiseLevel(resource.StatusFatal)
            return status, err
        }

        // evaluate the checksums
        if actual == f.Hash {
            status.AddMessage("file exists")
        } else if f.Force {
            status.AddDifference("checksum", actual, f.Hash, "")
            status.AddMessage("checksum mismatch")
        } else {
            status.AddMessage("checksum mismatch, use the \"force\" option to replace")
            status.RaiseLevel(resource.StatusCantChange)
            return status, errors.New("will not attempt fetch: checksum mismatch")
        }
    } else {
        if f.Force {
            status.AddDifference("destination", "<force fetch>", f.Destination, "")
            status.AddMessage("file exists, will fetch due to \"force\"")
        } else {
            status.AddMessage("file exists")
        }
    }

    status.RaiseLevelForDiffs()

    return status, nil
}

// getHash returns a new hash based on the f.HashType
func (f *Fetch) getHash() (hash.Hash, error) {
    switch f.HashType {
    case string(HashMD5):
        return md5.New(), nil
    case string(HashSHA1):
        return sha1.New(), nil
    case string(HashSHA256):
        return sha256.New(), nil
    case string(HashSHA512):
        return sha512.New(), nil
    default:
        return nil, fmt.Errorf("unsupported hashType %q", f.HashType)
    }
}

// checksum obtains the checksum of the destination
func (f *Fetch) getChecksum(hsh hash.Hash) (string, error) {
    file, err := os.Open(f.Destination)
    if err != nil {
        return "", errors.Wrap(err, "failed to open file for checksum")
    }
    defer file.Close()

    if _, err := io.Copy(hsh, file); err != nil {
        return "", errors.Wrap(err, "failed to hash")
    }

    return hex.EncodeToString(hsh.Sum(nil)), nil
}