get_url 8.56 KB
Newer Older
Jan-Piet Mens committed
1
#!/usr/bin/python
2
# -*- coding: utf-8 -*-
Jan-Piet Mens committed
3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20

# (c) 2012, Jan-Piet Mens <jpmens () gmail.com>
#
# This file is part of Ansible
#
# Ansible is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.
#
21
# see examples/playbooks/get_url.yml
Jan-Piet Mens committed
22 23 24 25 26

import shutil
import datetime
import tempfile

27 28 29 30
DOCUMENTATION = '''
---
module: get_url
short_description: Downloads files from HTTP, HTTPS, or FTP to node
31 32
description:
     - Downloads files from HTTP, HTTPS, or FTP to the remote server. The remote
Jan-Piet Mens committed
33
       server I(must) have direct access to the remote resource.
34 35
version_added: "0.6"
options:
36 37
  url:
    description:
38
      - HTTP, HTTPS, or FTP URL in the form (http|https|ftp)://[user[:pass]]@host.domain[:port]/path
39 40 41 42 43 44
    required: true
    default: null
    aliases: []
  dest:
    description:
      - absolute path of where to download the file to.
Jan-Piet Mens committed
45
      - If I(dest) is a directory, the basename of the file on the remote server will be used. If a directory, C(thirsty=yes) must also be set.
46 47 48 49 50
    required: true
    default: null
  thirsty:
    description:
      - if C(yes), will download the file every time and replace the
Jan-Piet Mens committed
51
        file if the contents change. If C(no), the file will only be downloaded if
52 53 54 55 56 57 58 59 60 61
        the destination does not exist. Generally should be C(yes) only for small
        local files. prior to 0.6, acts if C(yes) by default.
    version_added: "0.7"
    required: false
    choices: [ "yes", "no" ]
    default: "no"
  others:
    description:
      - all arguments accepted by the M(file) module also work here
    required: false
62
examples:
63
   - code: "get_url: url=http://example.com/path/file.conf dest=/etc/foo.conf mode=0440"
64
     description: "Example from Ansible Playbooks"
65
notes:
igor committed
66
    - This module doesn't yet support configuration for proxies.
67 68
# informational: requirements for nodes
requirements: [ urllib2, urlparse ]
69
author: Jan-Piet Mens
70 71
'''

igor committed
72
HAS_URLLIB2 = True
Jan-Piet Mens committed
73 74
try:
    import urllib2
75
except ImportError:
igor committed
76 77
    HAS_URLLIB2 = False
HAS_URLPARSE = True
78

Jan-Piet Mens committed
79 80 81
try:
    import urlparse
    import socket
82
except ImportError:
Jan-Piet Mens committed
83 84 85 86 87 88
    HAS_URLPARSE=False

# ==============================================================
# url handling

def url_filename(url):
89 90 91 92
    fn = os.path.basename(urlparse.urlsplit(url)[2])
    if fn == '':
        return 'index.html'
    return fn
Jan-Piet Mens committed
93

94 95 96 97
def url_do_get(module, url, dest):
    """
    Get url and return request and info
    Credits: http://stackoverflow.com/questions/7006574/how-to-download-file-from-ftp
Jan-Piet Mens committed
98
    """
99

Jan-Piet Mens committed
100
    USERAGENT = 'ansible-httpget'
101
    info = dict(url=url, dest=dest)
Jan-Piet Mens committed
102
    r = None
103
    parsed = urlparse.urlparse(url)
104 105
    if '@' in parsed[1]:
        credentials, netloc = parsed[1].split('@', 1)
106
        if ':' in credentials:
107 108 109 110
            username, password = credentials.split(':', 1)
        else:
            username = credentials
            password = ''
111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127
        parsed = list(parsed)
        parsed[1] = netloc

        passman = urllib2.HTTPPasswordMgrWithDefaultRealm()
        # this creates a password manager
        passman.add_password(None, netloc, username, password)
        # because we have put None at the start it will always
        # use this username/password combination for  urls
        # for which `theurl` is a super-url

        authhandler = urllib2.HTTPBasicAuthHandler(passman)
        # create the AuthHandler

        opener = urllib2.build_opener(authhandler)
        urllib2.install_opener(opener)
        #reconstruct url without credentials
        url = urlparse.urlunparse(parsed)
Jan-Piet Mens committed
128 129 130 131

    request = urllib2.Request(url)
    request.add_header('User-agent', USERAGENT)

132 133
    if os.path.exists(dest):
        t = datetime.datetime.utcfromtimestamp(os.path.getmtime(dest))
Jan-Piet Mens committed
134 135 136 137 138
        tstamp = t.strftime('%a, %d %b %Y %H:%M:%S +0000')
        request.add_header('If-Modified-Since', tstamp)

    try:
        r = urllib2.urlopen(request)
139 140
        info.update(r.info())
        info.update(dict(msg="OK (%s bytes)" % r.headers.get('Content-Length', 'unknown'), status=200))
141
    except urllib2.HTTPError, e:
Jan-Piet Mens committed
142
        # Must not fail_json() here so caller can handle HTTP 304 unmodified
143
        info.update(dict(msg=str(e), status=e.code))
Jan-Piet Mens committed
144
        return r, info
145
    except urllib2.URLError, e:
146 147
        code = getattr(e, 'code', -1)
        module.fail_json(msg="Request failed: %s" % str(e), status_code=code)
Jan-Piet Mens committed
148 149 150

    return r, info

151 152
def url_get(module, url, dest):
    """
153
    Download url and store at dest.
154 155
    If dest is a directory, determine filename from url.
    Return (tempfile, info about the request)
Jan-Piet Mens committed
156 157
    """

158
    req, info = url_do_get(module, url, dest)
Jan-Piet Mens committed
159

160
    # TODO: should really handle 304, but how? src file could exist (and be newer) but empty
Jan-Piet Mens committed
161
    if info['status'] == 304:
162
        module.exit_json(url=url, dest=dest, changed=False, msg=info.get('msg', ''))
Jan-Piet Mens committed
163

164 165
    # create a temporary file and copy content to do md5-based replacement
    if info['status'] != 200:
166
        module.fail_json(msg="Request failed", status_code=info['status'], response=info['msg'], url=url, dest=dest)
167 168 169 170 171 172 173 174 175 176 177

    fd, tempname = tempfile.mkstemp()
    f = os.fdopen(fd, 'wb')
    try:
        shutil.copyfileobj(req, f)
    except Exception, err:
        os.remove(tempname)
        module.fail_json(msg="failed to create temporary content file: %s" % str(err))
    f.close()
    req.close()
    return tempname, info
Jan-Piet Mens committed
178 179 180 181 182

# ==============================================================
# main

def main():
183 184

    # does this really happen on non-ancient python?
185 186 187 188 189
    if not HAS_URLLIB2:
        module.fail_json(msg="urllib2 is not installed")
    if not HAS_URLPARSE:
        module.fail_json(msg="urlparse is not installed")

Jan-Piet Mens committed
190
    module = AnsibleModule(
191
        # not checking because of daisy chain to file module
Jan-Piet Mens committed
192 193 194
        argument_spec = dict(
            url = dict(required=True),
            dest = dict(required=True),
195
            thirsty = dict(default='no', choices=BOOLEANS)
196 197
        ),
        add_file_common_args=True
Jan-Piet Mens committed
198
    )
199

200 201
    url  = module.params['url']
    dest = os.path.expanduser(module.params['dest'])
202 203
    thirsty = module.boolean(module.params['thirsty'])

204 205 206
    if os.path.isdir(dest):
        dest = os.path.join(dest, url_filename(url))

207
    if not thirsty:
208
        if os.path.exists(dest):
209
            module.exit_json(msg="file already exists", dest=dest, url=url, changed=False)
210

211 212 213 214
    # download to tmpsrc
    tmpsrc, info = url_get(module, url, dest)
    md5sum_src   = None
    md5sum_dest  = None
215

Jan-Piet Mens committed
216 217 218 219 220 221 222
    # raise an error if there is no tmpsrc file
    if not os.path.exists(tmpsrc):
        os.remove(tmpsrc)
        module.fail_json(msg="Request failed", status_code=info['status'], response=info['msg'])
    if not os.access(tmpsrc, os.R_OK):
        os.remove(tmpsrc)
        module.fail_json( msg="Source %s not readable" % (tmpsrc))
223
    md5sum_src = module.md5(tmpsrc)
224

Jan-Piet Mens committed
225 226 227 228 229 230 231 232 233
    # check if there is no dest file
    if os.path.exists(dest):
        # raise an error if copy has no permission on dest
        if not os.access(dest, os.W_OK):
            os.remove(tmpsrc)
            module.fail_json( msg="Destination %s not writable" % (dest))
        if not os.access(dest, os.R_OK):
            os.remove(tmpsrc)
            module.fail_json( msg="Destination %s not readable" % (dest))
234
        md5sum_dest = module.md5(dest)
Jan-Piet Mens committed
235 236 237 238
    else:
        if not os.access(os.path.dirname(dest), os.W_OK):
            os.remove(tmpsrc)
            module.fail_json( msg="Destination %s not writable" % (os.path.dirname(dest)))
239

Jan-Piet Mens committed
240 241 242 243 244
    if md5sum_src != md5sum_dest:
        try:
            shutil.copyfile(tmpsrc, dest)
        except Exception, err:
            os.remove(tmpsrc)
245
            module.fail_json(msg="failed to copy %s to %s: %s" % (tmpsrc, dest, str(err)))
Jan-Piet Mens committed
246 247 248
        changed = True
    else:
        changed = False
249

Jan-Piet Mens committed
250
    os.remove(tmpsrc)
251

252 253 254 255 256 257
    # allow file attribute changes
    module.params['path'] = dest
    file_args = module.load_file_common_arguments(module.params)
    file_args['path'] = dest
    changed = module.set_file_attributes_if_different(file_args, changed)

258
    # Mission complete
259
    module.exit_json(url=url, dest=dest, src=tmpsrc, md5sum=md5sum_src,
260
        changed=changed, msg=info.get('msg', ''))
Jan-Piet Mens committed
261 262 263 264

# this is magic, see lib/ansible/module_common.py
#<<INCLUDE_ANSIBLE_MODULE_COMMON>>
main()