mirror of
https://github.com/kemko/nomad.git
synced 2026-01-08 03:15:42 +03:00
This PR switches the Nomad repository from using govendor to Go modules for managing dependencies. Aspects of the Nomad workflow remain pretty much the same. The usual Makefile targets should continue to work as they always did. The API submodule simply defers to the parent Nomad version on the repository, keeping the semantics of API versioning that currently exists.
776 lines
16 KiB
Go
776 lines
16 KiB
Go
/*
|
|
Copyright (c) 2014-2018 VMware, 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 soap
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha1"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/vmware/govmomi/vim25/progress"
|
|
"github.com/vmware/govmomi/vim25/types"
|
|
"github.com/vmware/govmomi/vim25/xml"
|
|
)
|
|
|
|
type HasFault interface {
|
|
Fault() *Fault
|
|
}
|
|
|
|
type RoundTripper interface {
|
|
RoundTrip(ctx context.Context, req, res HasFault) error
|
|
}
|
|
|
|
const (
|
|
SessionCookieName = "vmware_soap_session"
|
|
)
|
|
|
|
type Client struct {
|
|
http.Client
|
|
|
|
u *url.URL
|
|
k bool // Named after curl's -k flag
|
|
d *debugContainer
|
|
t *http.Transport
|
|
|
|
hostsMu sync.Mutex
|
|
hosts map[string]string
|
|
|
|
Namespace string // Vim namespace
|
|
Version string // Vim version
|
|
UserAgent string
|
|
|
|
cookie string
|
|
}
|
|
|
|
var schemeMatch = regexp.MustCompile(`^\w+://`)
|
|
|
|
// ParseURL is wrapper around url.Parse, where Scheme defaults to "https" and Path defaults to "/sdk"
|
|
func ParseURL(s string) (*url.URL, error) {
|
|
var err error
|
|
var u *url.URL
|
|
|
|
if s != "" {
|
|
// Default the scheme to https
|
|
if !schemeMatch.MatchString(s) {
|
|
s = "https://" + s
|
|
}
|
|
|
|
u, err = url.Parse(s)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Default the path to /sdk
|
|
if u.Path == "" {
|
|
u.Path = "/sdk"
|
|
}
|
|
|
|
if u.User == nil {
|
|
u.User = url.UserPassword("", "")
|
|
}
|
|
}
|
|
|
|
return u, nil
|
|
}
|
|
|
|
func NewClient(u *url.URL, insecure bool) *Client {
|
|
c := Client{
|
|
u: u,
|
|
k: insecure,
|
|
d: newDebug(),
|
|
}
|
|
|
|
// Initialize http.RoundTripper on client, so we can customize it below
|
|
if t, ok := http.DefaultTransport.(*http.Transport); ok {
|
|
c.t = &http.Transport{
|
|
Proxy: t.Proxy,
|
|
DialContext: t.DialContext,
|
|
MaxIdleConns: t.MaxIdleConns,
|
|
IdleConnTimeout: t.IdleConnTimeout,
|
|
TLSHandshakeTimeout: t.TLSHandshakeTimeout,
|
|
ExpectContinueTimeout: t.ExpectContinueTimeout,
|
|
}
|
|
} else {
|
|
c.t = new(http.Transport)
|
|
}
|
|
|
|
c.hosts = make(map[string]string)
|
|
c.t.TLSClientConfig = &tls.Config{InsecureSkipVerify: c.k}
|
|
// Don't bother setting DialTLS if InsecureSkipVerify=true
|
|
if !c.k {
|
|
c.t.DialTLS = c.dialTLS
|
|
}
|
|
|
|
c.Client.Transport = c.t
|
|
c.Client.Jar, _ = cookiejar.New(nil)
|
|
|
|
// Remove user information from a copy of the URL
|
|
c.u = c.URL()
|
|
c.u.User = nil
|
|
|
|
return &c
|
|
}
|
|
|
|
// NewServiceClient creates a NewClient with the given URL.Path and namespace.
|
|
func (c *Client) NewServiceClient(path string, namespace string) *Client {
|
|
vc := c.URL()
|
|
u, err := url.Parse(path)
|
|
if err != nil {
|
|
log.Panicf("url.Parse(%q): %s", path, err)
|
|
}
|
|
if u.Host == "" {
|
|
u.Scheme = vc.Scheme
|
|
u.Host = vc.Host
|
|
}
|
|
|
|
client := NewClient(u, c.k)
|
|
client.Namespace = "urn:" + namespace
|
|
if cert := c.Certificate(); cert != nil {
|
|
client.SetCertificate(*cert)
|
|
}
|
|
|
|
// Copy the trusted thumbprints
|
|
c.hostsMu.Lock()
|
|
for k, v := range c.hosts {
|
|
client.hosts[k] = v
|
|
}
|
|
c.hostsMu.Unlock()
|
|
|
|
// Copy the cookies
|
|
client.Client.Jar.SetCookies(u, c.Client.Jar.Cookies(u))
|
|
|
|
// Set SOAP Header cookie
|
|
for _, cookie := range client.Jar.Cookies(u) {
|
|
if cookie.Name == SessionCookieName {
|
|
client.cookie = cookie.Value
|
|
break
|
|
}
|
|
}
|
|
|
|
// Copy any query params (e.g. GOVMOMI_TUNNEL_PROXY_PORT used in testing)
|
|
client.u.RawQuery = vc.RawQuery
|
|
|
|
return client
|
|
}
|
|
|
|
// SetRootCAs defines the set of root certificate authorities
|
|
// that clients use when verifying server certificates.
|
|
// By default TLS uses the host's root CA set.
|
|
//
|
|
// See: http.Client.Transport.TLSClientConfig.RootCAs
|
|
func (c *Client) SetRootCAs(file string) error {
|
|
pool := x509.NewCertPool()
|
|
|
|
for _, name := range filepath.SplitList(file) {
|
|
pem, err := ioutil.ReadFile(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pool.AppendCertsFromPEM(pem)
|
|
}
|
|
|
|
c.t.TLSClientConfig.RootCAs = pool
|
|
|
|
return nil
|
|
}
|
|
|
|
// Add default https port if missing
|
|
func hostAddr(addr string) string {
|
|
_, port := splitHostPort(addr)
|
|
if port == "" {
|
|
return addr + ":443"
|
|
}
|
|
return addr
|
|
}
|
|
|
|
// SetThumbprint sets the known certificate thumbprint for the given host.
|
|
// A custom DialTLS function is used to support thumbprint based verification.
|
|
// We first try tls.Dial with the default tls.Config, only falling back to thumbprint verification
|
|
// if it fails with an x509.UnknownAuthorityError or x509.HostnameError
|
|
//
|
|
// See: http.Client.Transport.DialTLS
|
|
func (c *Client) SetThumbprint(host string, thumbprint string) {
|
|
host = hostAddr(host)
|
|
|
|
c.hostsMu.Lock()
|
|
if thumbprint == "" {
|
|
delete(c.hosts, host)
|
|
} else {
|
|
c.hosts[host] = thumbprint
|
|
}
|
|
c.hostsMu.Unlock()
|
|
}
|
|
|
|
// Thumbprint returns the certificate thumbprint for the given host if known to this client.
|
|
func (c *Client) Thumbprint(host string) string {
|
|
host = hostAddr(host)
|
|
c.hostsMu.Lock()
|
|
defer c.hostsMu.Unlock()
|
|
return c.hosts[host]
|
|
}
|
|
|
|
// LoadThumbprints from file with the give name.
|
|
// If name is empty or name does not exist this function will return nil.
|
|
func (c *Client) LoadThumbprints(file string) error {
|
|
if file == "" {
|
|
return nil
|
|
}
|
|
|
|
for _, name := range filepath.SplitList(file) {
|
|
err := c.loadThumbprints(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) loadThumbprints(name string) error {
|
|
f, err := os.Open(name)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
scanner := bufio.NewScanner(f)
|
|
|
|
for scanner.Scan() {
|
|
e := strings.SplitN(scanner.Text(), " ", 2)
|
|
if len(e) != 2 {
|
|
continue
|
|
}
|
|
|
|
c.SetThumbprint(e[0], e[1])
|
|
}
|
|
|
|
_ = f.Close()
|
|
|
|
return scanner.Err()
|
|
}
|
|
|
|
// ThumbprintSHA1 returns the thumbprint of the given cert in the same format used by the SDK and Client.SetThumbprint.
|
|
//
|
|
// See: SSLVerifyFault.Thumbprint, SessionManagerGenericServiceTicket.Thumbprint, HostConnectSpec.SslThumbprint
|
|
func ThumbprintSHA1(cert *x509.Certificate) string {
|
|
sum := sha1.Sum(cert.Raw)
|
|
hex := make([]string, len(sum))
|
|
for i, b := range sum {
|
|
hex[i] = fmt.Sprintf("%02X", b)
|
|
}
|
|
return strings.Join(hex, ":")
|
|
}
|
|
|
|
func (c *Client) dialTLS(network string, addr string) (net.Conn, error) {
|
|
// Would be nice if there was a tls.Config.Verify func,
|
|
// see tls.clientHandshakeState.doFullHandshake
|
|
|
|
conn, err := tls.Dial(network, addr, c.t.TLSClientConfig)
|
|
|
|
if err == nil {
|
|
return conn, nil
|
|
}
|
|
|
|
switch err.(type) {
|
|
case x509.UnknownAuthorityError:
|
|
case x509.HostnameError:
|
|
default:
|
|
return nil, err
|
|
}
|
|
|
|
thumbprint := c.Thumbprint(addr)
|
|
if thumbprint == "" {
|
|
return nil, err
|
|
}
|
|
|
|
config := &tls.Config{InsecureSkipVerify: true}
|
|
conn, err = tls.Dial(network, addr, config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cert := conn.ConnectionState().PeerCertificates[0]
|
|
peer := ThumbprintSHA1(cert)
|
|
if thumbprint != peer {
|
|
_ = conn.Close()
|
|
|
|
return nil, fmt.Errorf("Host %q thumbprint does not match %q", addr, thumbprint)
|
|
}
|
|
|
|
return conn, nil
|
|
}
|
|
|
|
// splitHostPort is similar to net.SplitHostPort,
|
|
// but rather than return error if there isn't a ':port',
|
|
// return an empty string for the port.
|
|
func splitHostPort(host string) (string, string) {
|
|
ix := strings.LastIndex(host, ":")
|
|
|
|
if ix <= strings.LastIndex(host, "]") {
|
|
return host, ""
|
|
}
|
|
|
|
name := host[:ix]
|
|
port := host[ix+1:]
|
|
|
|
return name, port
|
|
}
|
|
|
|
const sdkTunnel = "sdkTunnel:8089"
|
|
|
|
func (c *Client) Certificate() *tls.Certificate {
|
|
certs := c.t.TLSClientConfig.Certificates
|
|
if len(certs) == 0 {
|
|
return nil
|
|
}
|
|
return &certs[0]
|
|
}
|
|
|
|
func (c *Client) SetCertificate(cert tls.Certificate) {
|
|
t := c.Client.Transport.(*http.Transport)
|
|
|
|
// Extension or HoK certificate
|
|
t.TLSClientConfig.Certificates = []tls.Certificate{cert}
|
|
}
|
|
|
|
// Tunnel returns a Client configured to proxy requests through vCenter's http port 80,
|
|
// to the SDK tunnel virtual host. Use of the SDK tunnel is required by LoginExtensionByCertificate()
|
|
// and optional for other methods.
|
|
func (c *Client) Tunnel() *Client {
|
|
tunnel := c.NewServiceClient(c.u.Path, c.Namespace)
|
|
t := tunnel.Client.Transport.(*http.Transport)
|
|
// Proxy to vCenter host on port 80
|
|
host := tunnel.u.Hostname()
|
|
// Should be no reason to change the default port other than testing
|
|
key := "GOVMOMI_TUNNEL_PROXY_PORT"
|
|
|
|
port := tunnel.URL().Query().Get(key)
|
|
if port == "" {
|
|
port = os.Getenv(key)
|
|
}
|
|
|
|
if port != "" {
|
|
host += ":" + port
|
|
}
|
|
|
|
t.Proxy = http.ProxyURL(&url.URL{
|
|
Scheme: "http",
|
|
Host: host,
|
|
})
|
|
|
|
// Rewrite url Host to use the sdk tunnel, required for a certificate request.
|
|
tunnel.u.Host = sdkTunnel
|
|
return tunnel
|
|
}
|
|
|
|
func (c *Client) URL() *url.URL {
|
|
urlCopy := *c.u
|
|
return &urlCopy
|
|
}
|
|
|
|
type marshaledClient struct {
|
|
Cookies []*http.Cookie
|
|
URL *url.URL
|
|
Insecure bool
|
|
}
|
|
|
|
func (c *Client) MarshalJSON() ([]byte, error) {
|
|
m := marshaledClient{
|
|
Cookies: c.Jar.Cookies(c.u),
|
|
URL: c.u,
|
|
Insecure: c.k,
|
|
}
|
|
|
|
return json.Marshal(m)
|
|
}
|
|
|
|
func (c *Client) UnmarshalJSON(b []byte) error {
|
|
var m marshaledClient
|
|
|
|
err := json.Unmarshal(b, &m)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
*c = *NewClient(m.URL, m.Insecure)
|
|
c.Jar.SetCookies(m.URL, m.Cookies)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) do(ctx context.Context, req *http.Request) (*http.Response, error) {
|
|
if nil == ctx || nil == ctx.Done() { // ctx.Done() is for ctx
|
|
return c.Client.Do(req)
|
|
}
|
|
|
|
return c.Client.Do(req.WithContext(ctx))
|
|
}
|
|
|
|
// Signer can be implemented by soap.Header.Security to sign requests.
|
|
// If the soap.Header.Security field is set to an implementation of Signer via WithHeader(),
|
|
// then Client.RoundTrip will call Sign() to marshal the SOAP request.
|
|
type Signer interface {
|
|
Sign(Envelope) ([]byte, error)
|
|
}
|
|
|
|
type headerContext struct{}
|
|
|
|
// WithHeader can be used to modify the outgoing request soap.Header fields.
|
|
func (c *Client) WithHeader(ctx context.Context, header Header) context.Context {
|
|
return context.WithValue(ctx, headerContext{}, header)
|
|
}
|
|
|
|
func (c *Client) RoundTrip(ctx context.Context, reqBody, resBody HasFault) error {
|
|
var err error
|
|
var b []byte
|
|
|
|
reqEnv := Envelope{Body: reqBody}
|
|
resEnv := Envelope{Body: resBody}
|
|
|
|
h, ok := ctx.Value(headerContext{}).(Header)
|
|
if !ok {
|
|
h = Header{}
|
|
}
|
|
|
|
// We added support for OperationID before soap.Header was exported.
|
|
if id, ok := ctx.Value(types.ID{}).(string); ok {
|
|
h.ID = id
|
|
}
|
|
|
|
h.Cookie = c.cookie
|
|
if h.Cookie != "" || h.ID != "" || h.Security != nil {
|
|
reqEnv.Header = &h // XML marshal header only if a field is set
|
|
}
|
|
|
|
// Create debugging context for this round trip
|
|
d := c.d.newRoundTrip()
|
|
if d.enabled() {
|
|
defer d.done()
|
|
}
|
|
|
|
if signer, ok := h.Security.(Signer); ok {
|
|
b, err = signer.Sign(reqEnv)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
b, err = xml.Marshal(reqEnv)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
rawReqBody := io.MultiReader(strings.NewReader(xml.Header), bytes.NewReader(b))
|
|
req, err := http.NewRequest("POST", c.u.String(), rawReqBody)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
req = req.WithContext(ctx)
|
|
|
|
req.Header.Set(`Content-Type`, `text/xml; charset="utf-8"`)
|
|
|
|
action := h.Action
|
|
if action == "" {
|
|
action = fmt.Sprintf("%s/%s", c.Namespace, c.Version)
|
|
}
|
|
req.Header.Set(`SOAPAction`, action)
|
|
|
|
if c.UserAgent != "" {
|
|
req.Header.Set(`User-Agent`, c.UserAgent)
|
|
}
|
|
|
|
if d.enabled() {
|
|
d.debugRequest(req)
|
|
}
|
|
|
|
tstart := time.Now()
|
|
res, err := c.do(ctx, req)
|
|
tstop := time.Now()
|
|
|
|
if d.enabled() {
|
|
d.logf("%6dms (%T)", tstop.Sub(tstart)/time.Millisecond, resBody)
|
|
}
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if d.enabled() {
|
|
d.debugResponse(res)
|
|
}
|
|
|
|
// Close response regardless of what happens next
|
|
defer res.Body.Close()
|
|
|
|
switch res.StatusCode {
|
|
case http.StatusOK:
|
|
// OK
|
|
case http.StatusInternalServerError:
|
|
// Error, but typically includes a body explaining the error
|
|
default:
|
|
return errors.New(res.Status)
|
|
}
|
|
|
|
dec := xml.NewDecoder(res.Body)
|
|
dec.TypeFunc = types.TypeFunc()
|
|
err = dec.Decode(&resEnv)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if f := resBody.Fault(); f != nil {
|
|
return WrapSoapFault(f)
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func (c *Client) CloseIdleConnections() {
|
|
c.t.CloseIdleConnections()
|
|
}
|
|
|
|
// ParseURL wraps url.Parse to rewrite the URL.Host field
|
|
// In the case of VM guest uploads or NFC lease URLs, a Host
|
|
// field with a value of "*" is rewritten to the Client's URL.Host.
|
|
func (c *Client) ParseURL(urlStr string) (*url.URL, error) {
|
|
u, err := url.Parse(urlStr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
host, _ := splitHostPort(u.Host)
|
|
if host == "*" {
|
|
// Also use Client's port, to support port forwarding
|
|
u.Host = c.URL().Host
|
|
}
|
|
|
|
return u, nil
|
|
}
|
|
|
|
type Upload struct {
|
|
Type string
|
|
Method string
|
|
ContentLength int64
|
|
Headers map[string]string
|
|
Ticket *http.Cookie
|
|
Progress progress.Sinker
|
|
}
|
|
|
|
var DefaultUpload = Upload{
|
|
Type: "application/octet-stream",
|
|
Method: "PUT",
|
|
}
|
|
|
|
// Upload PUTs the local file to the given URL
|
|
func (c *Client) Upload(ctx context.Context, f io.Reader, u *url.URL, param *Upload) error {
|
|
var err error
|
|
|
|
if param.Progress != nil {
|
|
pr := progress.NewReader(ctx, param.Progress, f, param.ContentLength)
|
|
f = pr
|
|
|
|
// Mark progress reader as done when returning from this function.
|
|
defer func() {
|
|
pr.Done(err)
|
|
}()
|
|
}
|
|
|
|
req, err := http.NewRequest(param.Method, u.String(), f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req = req.WithContext(ctx)
|
|
|
|
req.ContentLength = param.ContentLength
|
|
req.Header.Set("Content-Type", param.Type)
|
|
|
|
for k, v := range param.Headers {
|
|
req.Header.Add(k, v)
|
|
}
|
|
|
|
if param.Ticket != nil {
|
|
req.AddCookie(param.Ticket)
|
|
}
|
|
|
|
res, err := c.Client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch res.StatusCode {
|
|
case http.StatusOK:
|
|
case http.StatusCreated:
|
|
default:
|
|
err = errors.New(res.Status)
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
// UploadFile PUTs the local file to the given URL
|
|
func (c *Client) UploadFile(ctx context.Context, file string, u *url.URL, param *Upload) error {
|
|
if param == nil {
|
|
p := DefaultUpload // Copy since we set ContentLength
|
|
param = &p
|
|
}
|
|
|
|
s, err := os.Stat(file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
f, err := os.Open(file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
param.ContentLength = s.Size()
|
|
|
|
return c.Upload(ctx, f, u, param)
|
|
}
|
|
|
|
type Download struct {
|
|
Method string
|
|
Headers map[string]string
|
|
Ticket *http.Cookie
|
|
Progress progress.Sinker
|
|
Writer io.Writer
|
|
}
|
|
|
|
var DefaultDownload = Download{
|
|
Method: "GET",
|
|
}
|
|
|
|
// DownloadRequest wraps http.Client.Do, returning the http.Response without checking its StatusCode
|
|
func (c *Client) DownloadRequest(ctx context.Context, u *url.URL, param *Download) (*http.Response, error) {
|
|
req, err := http.NewRequest(param.Method, u.String(), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req = req.WithContext(ctx)
|
|
|
|
for k, v := range param.Headers {
|
|
req.Header.Add(k, v)
|
|
}
|
|
|
|
if param.Ticket != nil {
|
|
req.AddCookie(param.Ticket)
|
|
}
|
|
|
|
return c.Client.Do(req)
|
|
}
|
|
|
|
// Download GETs the remote file from the given URL
|
|
func (c *Client) Download(ctx context.Context, u *url.URL, param *Download) (io.ReadCloser, int64, error) {
|
|
res, err := c.DownloadRequest(ctx, u, param)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
switch res.StatusCode {
|
|
case http.StatusOK:
|
|
default:
|
|
err = errors.New(res.Status)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
r := res.Body
|
|
|
|
return r, res.ContentLength, nil
|
|
}
|
|
|
|
func (c *Client) WriteFile(ctx context.Context, file string, src io.Reader, size int64, s progress.Sinker, w io.Writer) error {
|
|
var err error
|
|
|
|
r := src
|
|
|
|
fh, err := os.Create(file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if s != nil {
|
|
pr := progress.NewReader(ctx, s, src, size)
|
|
src = pr
|
|
|
|
// Mark progress reader as done when returning from this function.
|
|
defer func() {
|
|
pr.Done(err)
|
|
}()
|
|
}
|
|
|
|
if w == nil {
|
|
w = fh
|
|
} else {
|
|
w = io.MultiWriter(w, fh)
|
|
}
|
|
|
|
_, err = io.Copy(w, r)
|
|
|
|
cerr := fh.Close()
|
|
|
|
if err == nil {
|
|
err = cerr
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
// DownloadFile GETs the given URL to a local file
|
|
func (c *Client) DownloadFile(ctx context.Context, file string, u *url.URL, param *Download) error {
|
|
var err error
|
|
if param == nil {
|
|
param = &DefaultDownload
|
|
}
|
|
|
|
rc, contentLength, err := c.Download(ctx, u, param)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return c.WriteFile(ctx, file, rc, contentLength, param.Progress, param.Writer)
|
|
}
|