s3 19.7 KB
Newer Older
1
#!/usr/bin/python
lwade committed
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
# 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/>.

DOCUMENTATION = '''
---
module: s3
20
short_description: idempotent S3 module putting a file into S3. 
lwade committed
21
description:
22
    - This module allows the user to dictate the presence of a given file in an S3 bucket. If or once the key (file) exists in the bucket, it returns a time-expired download URL. This module has a dependency on python-boto.
lwade committed
23 24 25 26
version_added: "1.1"
options:
  bucket:
    description:
27
      - Bucket name. 
lwade committed
28 29 30
    required: true
    default: null 
    aliases: []
31
  object:
lwade committed
32
    description:
33
      - Keyname of the object inside the bucket. Can be used to create "virtual directories", see examples.
lwade committed
34 35 36
    required: false
    default: null
    aliases: []
37 38
    version_added: "1.3"
  src:
39
    description:
40
      - The source file path when performing a PUT operation.
41 42 43
    required: false
    default: null
    aliases: []
44 45
    version_added: "1.3"
  dest:
lwade committed
46
    description:
47
      - The destination file path when downloading an object/key with a GET operation.
lwade committed
48 49
    required: false
    aliases: []
50
    version_added: "1.3"
51 52
  overwrite:
    description:
53
      - Force overwrite either locally on the filesystem or remotely with the object/key. Used with PUT and GET operations.
54
    required: false
55
    default: true
56
    version_added: "1.2"
57 58
  mode:
    description:
59
      - Switches the module behaviour between put (upload), get (download), geturl (return download url (Ansible 1.3+), getstr (download object as string (1.3+)), create (bucket) and delete (bucket). 
60 61 62
    required: true
    default: null
    aliases: []
63
  expiration:
64 65 66
    description:
      - Time limit (in seconds) for the URL generated and returned by S3/Walrus when performing a mode=put or mode=geturl operation. 
    required: false
67
    default: 600
68
    aliases: []
69 70
  s3_url:
    description:
71
        - "S3 URL endpoint. If not specified then the S3_URL environment variable is used, if that variable is defined. Ansible tries to guess if fakes3 (https://github.com/jubos/fake-s3) or Eucalyptus Walrus (https://github.com/eucalyptus/eucalyptus/wiki/Walrus) is used and configure connection accordingly. Current heuristic is: everything with scheme fakes3:// is fakes3, everything else not ending with amazonaws.com is Walrus."
72 73
    default: null
    aliases: [ S3_URL ]
Bruce Pennypacker committed
74
  aws_secret_key:
75
    description:
Bruce Pennypacker committed
76
      - AWS secret key. If not set then the value of the AWS_SECRET_KEY environment variable is used. 
77 78
    required: false
    default: null
Bruce Pennypacker committed
79 80
    aliases: ['ec2_secret_key', 'secret_key']
  aws_access_key:
81
    description:
Bruce Pennypacker committed
82
      - AWS access key. If not set then the value of the AWS_ACCESS_KEY environment variable is used.
83 84
    required: false
    default: null
Bruce Pennypacker committed
85
    aliases: [ 'ec2_access_key', 'access_key' ]
86 87 88 89 90
  metadata:
    description:
      - Metadata for PUT operation, as a dictionary of 'key=value' and 'key=value,key=value'.
    required: false
    default: null
91 92
    version_added: "1.6"

lwade committed
93
requirements: [ "boto" ]
94
author: Lester Wade, Ralph Tice
lwade committed
95 96
'''

97 98
EXAMPLES = '''
# Simple PUT operation
99 100 101 102
- s3: bucket=mybucket object=/my/desired/key.txt src=/usr/local/myfile.txt mode=put
# Simple GET operation
- s3: bucket=mybucket object=/my/desired/key.txt dest=/usr/local/myfile.txt mode=get
# GET/download and overwrite local file (trust remote)
103 104 105
- s3: bucket=mybucket object=/my/desired/key.txt dest=/usr/local/myfile.txt mode=get 
# GET/download and do not overwrite local file (trust remote)
- s3: bucket=mybucket object=/my/desired/key.txt dest=/usr/local/myfile.txt mode=get force=false
106
# PUT/upload and overwrite remote file (trust local)
107 108 109
- s3: bucket=mybucket object=/my/desired/key.txt src=/usr/local/myfile.txt mode=put
# PUT/upload with metadata
- s3: bucket=mybucket object=/my/desired/key.txt src=/usr/local/myfile.txt mode=put metadata='Content-Encoding=gzip'
110 111
# PUT/upload with multiple metadata
- s3: bucket=mybucket object=/my/desired/key.txt src=/usr/local/myfile.txt mode=put metadata='Content-Encoding=gzip,Cache-Control=no-cache'
112 113
# PUT/upload and do not overwrite remote file (trust local)
- s3: bucket=mybucket object=/my/desired/key.txt src=/usr/local/myfile.txt mode=put force=false
114 115
# Download an object as a string to use else where in your playbook
- s3: bucket=mybucket object=/my/desired/key.txt src=/usr/local/myfile.txt mode=getstr
116 117
# Create an empty bucket
- s3: bucket=mybucket mode=create
118 119
# Create a bucket with key as directory
- s3: bucket=mybucket object=/my/directory/path mode=create
120 121
# Delete a bucket and all contents
- s3: bucket=mybucket mode=delete
122 123
'''

lwade committed
124 125 126
import sys
import os
import urlparse
127
import hashlib
lwade committed
128 129 130 131

try:
    import boto
except ImportError:
132
    module.fail_json(msg="boto required for this module")
lwade committed
133

134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190
def key_check(module, s3, bucket, obj):
    try:
        bucket = s3.lookup(bucket)
        key_check = bucket.get_key(obj)
    except s3.provider.storage_response_error, e:
        module.fail_json(msg= str(e))
    if key_check:
        return True
    else:
        return False

def keysum(module, s3, bucket, obj):
    bucket = s3.lookup(bucket)
    key_check = bucket.get_key(obj)
    if key_check:
        md5_remote = key_check.etag[1:-1]
        etag_multipart = md5_remote.find('-')!=-1 #Check for multipart, etag is not md5
        if etag_multipart is True:
            module.fail_json(msg="Files uploaded with multipart of s3 are not supported with checksum, unable to compute checksum.")
    return md5_remote

def bucket_check(module, s3, bucket):
    try:
        result = s3.lookup(bucket)
    except s3.provider.storage_response_error, e:
        module.fail_json(msg= str(e))
    if result:
        return True
    else:
        return False

def create_bucket(module, s3, bucket):
    try:
        bucket = s3.create_bucket(bucket)
    except s3.provider.storage_response_error, e:
        module.fail_json(msg= str(e))
    if bucket:
        return True

def delete_bucket(module, s3, bucket):
    try:
        bucket = s3.lookup(bucket)
        bucket_contents = bucket.list()
        bucket.delete_keys([key.name for key in bucket_contents])
        bucket.delete()
        return True
    except s3.provider.storage_response_error, e:
        module.fail_json(msg= str(e))

def delete_key(module, s3, bucket, obj):
    try:
        bucket = s3.lookup(bucket)
        bucket.delete_key(obj)
        module.exit_json(msg="Object deleted from bucket %s"%bucket, changed=True)
    except s3.provider.storage_response_error, e:
        module.fail_json(msg= str(e))
 
191
def create_dirkey(module, s3, bucket, obj):
192 193
    try:
        bucket = s3.lookup(bucket)
194 195 196
        key = bucket.new_key(obj)
        key.set_contents_from_string('')
        module.exit_json(msg="Virtual directory %s created in bucket %s" % (obj, bucket.name), changed=True)
197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214
    except s3.provider.storage_response_error, e:
        module.fail_json(msg= str(e))

def upload_file_check(src):
    if os.path.exists(src):
        file_exists is True
    else:
        file_exists is False
    if os.path.isdir(src):
        module.fail_json(msg="Specifying a directory is not a valid source for upload.", failed=True)
    return file_exists

def path_check(path):
    if os.path.exists(path):
        return True 
    else:
        return False

215
def upload_s3file(module, s3, bucket, obj, src, expiry, metadata):
216
    try:
217
        bucket = s3.lookup(bucket)
218 219 220 221 222
        key = bucket.new_key(obj)
        if metadata:
            for meta_key in metadata.keys():
                key.set_metadata(meta_key, metadata[meta_key])

223
        key.set_contents_from_filename(src)
224
        url = key.generate_url(expiry)
225 226 227 228 229 230 231 232 233 234
        module.exit_json(msg="PUT operation complete", url=url, changed=True)
    except s3.provider.storage_copy_error, e:
        module.fail_json(msg= str(e))

def download_s3file(module, s3, bucket, obj, dest):
    try:
        bucket = s3.lookup(bucket)
        key = bucket.lookup(obj)
        key.get_contents_to_filename(dest)
        module.exit_json(msg="GET operation complete", changed=True)
235 236 237
    except s3.provider.storage_copy_error, e:
        module.fail_json(msg= str(e))

238 239 240 241 242 243 244 245 246
def download_s3str(module, s3, bucket, obj):
    try:
        bucket = s3.lookup(bucket)
        key = bucket.lookup(obj)
        contents = key.get_contents_as_string()
        module.exit_json(msg="GET operation complete", contents=contents, changed=True)
    except s3.provider.storage_copy_error, e:
        module.fail_json(msg= str(e))

247
def get_download_url(module, s3, bucket, obj, expiry, changed=True):
248 249 250 251
    try:
        bucket = s3.lookup(bucket)
        key = bucket.lookup(obj)
        url = key.generate_url(expiry)
252
        module.exit_json(msg="Download url:", url=url, expiry=expiry, changed=changed)
253 254 255
    except s3.provider.storage_response_error, e:
        module.fail_json(msg= str(e))

256 257 258 259 260 261 262
def is_fakes3(s3_url):
    """ Return True if s3_url has scheme fakes3:// """
    if s3_url is not None:
        return urlparse.urlparse(s3_url).scheme == 'fakes3'
    else:
        return False

263 264 265 266 267 268 269 270 271 272
def is_walrus(s3_url):
    """ Return True if it's Walrus endpoint, not S3

    We assume anything other than *.amazonaws.com is Walrus"""
    if s3_url is not None:
        o = urlparse.urlparse(s3_url)
        return not o.hostname.endswith('amazonaws.com')
    else:
        return False

lwade committed
273
def main():
274
    argument_spec = ec2_argument_spec()
275
    argument_spec.update(dict(
276 277 278
            bucket         = dict(required=True),
            object         = dict(),
            src            = dict(),
bennojoy committed
279
            dest           = dict(default=None),
280
            mode           = dict(choices=['get', 'put', 'delete', 'create', 'geturl', 'getstr'], required=True),
281 282
            expiry         = dict(default=600, aliases=['expiration']),
            s3_url         = dict(aliases=['S3_URL']),
283
            overwrite      = dict(aliases=['force'], default=True, type='bool'),
284 285
            metadata      = dict(type='dict'),
        ),
lwade committed
286
    )
287
    module = AnsibleModule(argument_spec=argument_spec)
lwade committed
288

289 290 291
    bucket = module.params.get('bucket')
    obj = module.params.get('object')
    src = module.params.get('src')
bennojoy committed
292 293
    if module.params.get('dest'):
        dest = os.path.expanduser(module.params.get('dest'))
294
    mode = module.params.get('mode')
lwade committed
295 296
    expiry = int(module.params['expiry'])
    s3_url = module.params.get('s3_url')
297
    overwrite = module.params.get('overwrite')
298
    metadata = module.params.get('metadata')
299 300

    ec2_url, aws_access_key, aws_secret_key, region = get_ec2_creds(module)
301 302 303
    
    if module.params.get('object'):
        obj = os.path.expanduser(module.params['object'])
lwade committed
304 305 306 307

    # allow eucarc environment variables to be used if ansible vars aren't set
    if not s3_url and 'S3_URL' in os.environ:
        s3_url = os.environ['S3_URL']
Bruce Pennypacker committed
308

309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324
    # Look at s3_url and tweak connection settings
    # if connecting to Walrus or fakes3
    if is_fakes3(s3_url):
        try:
            fakes3 = urlparse.urlparse(s3_url)
            from boto.s3.connection import OrdinaryCallingFormat
            s3 = boto.connect_s3(
                aws_access_key,
                aws_secret_key,
                is_secure=False,
                host=fakes3.hostname,
                port=fakes3.port,
                calling_format=OrdinaryCallingFormat())
        except boto.exception.NoAuthHandlerFound, e:
            module.fail_json(msg = str(e))
    elif is_walrus(s3_url):
lwade committed
325 326
        try:
            walrus = urlparse.urlparse(s3_url).hostname
Bruce Pennypacker committed
327
            s3 = boto.connect_walrus(walrus, aws_access_key, aws_secret_key)
lwade committed
328 329 330 331
        except boto.exception.NoAuthHandlerFound, e:
            module.fail_json(msg = str(e))
    else:
        try:
Bruce Pennypacker committed
332
            s3 = boto.connect_s3(aws_access_key, aws_secret_key)
lwade committed
333 334
        except boto.exception.NoAuthHandlerFound, e:
            module.fail_json(msg = str(e))
335 336 337
 
    # If our mode is a GET operation (download), go through the procedure as appropriate ...
    if mode == 'get':
lwade committed
338
    
339 340 341 342
        # First, we check to see if the bucket exists, we get "bucket" returned.
        bucketrtn = bucket_check(module, s3, bucket)
        if bucketrtn is False:
            module.fail_json(msg="Target bucket cannot be found", failed=True)
lwade committed
343

344 345 346 347
        # Next, we check to see if the key in the bucket exists. If it exists, it also returns key_matches md5sum check.
        keyrtn = key_check(module, s3, bucket, obj)    
        if keyrtn is False:
            module.fail_json(msg="Target key cannot be found", failed=True)
lwade committed
348

349 350 351 352
        # If the destination path doesn't exist, no need to md5um etag check, so just download.
        pathrtn = path_check(dest)
        if pathrtn is False:
            download_s3file(module, s3, bucket, obj, dest)
353

354 355 356 357 358 359 360 361 362 363
        # Compare the remote MD5 sum of the object with the local dest md5sum, if it already exists. 
        if pathrtn is True:
            md5_remote = keysum(module, s3, bucket, obj)
            md5_local = hashlib.md5(open(dest, 'rb').read()).hexdigest()
            if md5_local == md5_remote:
                sum_matches = True
                if overwrite is True:
                    download_s3file(module, s3, bucket, obj, dest)
                else:
                    module.exit_json(msg="Local and remote object are identical, ignoring. Use overwrite parameter to force.", changed=False)
lwade committed
364
            else:
365 366 367 368 369
                sum_matches = False
                if overwrite is True:
                    download_s3file(module, s3, bucket, obj, dest)
                else:
                    module.fail_json(msg="WARNING: Checksums do not match. Use overwrite parameter to force download.", failed=True)
370
        
371 372 373 374 375 376 377 378 379 380 381 382
        # Firstly, if key_matches is TRUE and overwrite is not enabled, we EXIT with a helpful message. 
        if sum_matches is True and overwrite is False:
            module.exit_json(msg="Local and remote object are identical, ignoring. Use overwrite parameter to force.", changed=False)

        # At this point explicitly define the overwrite condition.
        if sum_matches is True and pathrtn is True and overwrite is True:
            download_s3file(module, s3, bucket, obj, dest)

        # If sum does not match but the destination exists, we 
               
    # if our mode is a PUT operation (upload), go through the procedure as appropriate ...        
    if mode == 'put':
383

384 385 386
        # Use this snippet to debug through conditionals:
#       module.exit_json(msg="Bucket return %s"%bucketrtn)
#       sys.exit(0)
387

388 389 390 391 392 393 394
        # Lets check the src path.
        pathrtn = path_check(src)
        if pathrtn is False:
            module.fail_json(msg="Local object for PUT does not exist", failed=True)
        
        # Lets check to see if bucket exists to get ground truth.
        bucketrtn = bucket_check(module, s3, bucket)
395 396
        if bucketrtn is True:
            keyrtn = key_check(module, s3, bucket, obj)
397 398 399 400 401 402 403 404

        # Lets check key state. Does it exist and if it does, compute the etag md5sum.
        if bucketrtn is True and keyrtn is True:
                md5_remote = keysum(module, s3, bucket, obj)
                md5_local = hashlib.md5(open(src, 'rb').read()).hexdigest()
                if md5_local == md5_remote:
                    sum_matches = True
                    if overwrite is True:
405
                        upload_s3file(module, s3, bucket, obj, src, expiry, metadata)
406
                    else:
407
                        get_download_url(module, s3, bucket, obj, expiry, changed=False)
408 409 410
                else:
                    sum_matches = False
                    if overwrite is True:
411
                        upload_s3file(module, s3, bucket, obj, src, expiry, metadata)
412 413 414 415 416 417
                    else:
                        module.exit_json(msg="WARNING: Checksums do not match. Use overwrite parameter to force upload.", failed=True)
                                                                                                            
        # If neither exist (based on bucket existence), we can create both.
        if bucketrtn is False and pathrtn is True:      
            create_bucket(module, s3, bucket)
418
            upload_s3file(module, s3, bucket, obj, src, expiry, metadata)
lwade committed
419

420 421
        # If bucket exists but key doesn't, just upload.
        if bucketrtn is True and pathrtn is True and keyrtn is False:
422
            upload_s3file(module, s3, bucket, obj, src, expiry, metadata)
lwade committed
423
    
424 425 426 427 428 429 430 431 432
    # Support for deleting an object if we have both params.  
    if mode == 'delete':
        if bucket:
            bucketrtn = bucket_check(module, s3, bucket)
            if bucketrtn is True:
                deletertn = delete_bucket(module, s3, bucket)
                if deletertn is True:
                    module.exit_json(msg="Bucket %s and all keys have been deleted."%bucket, changed=True)
            else:
Benno Joy committed
433
                module.fail_json(msg="Bucket does not exist.", changed=False)
lwade committed
434
        else:
435 436 437 438 439
            module.fail_json(msg="Bucket parameter is required.", failed=True)
 
    # Need to research how to create directories without "populating" a key, so this should just do bucket creation for now.
    # WE SHOULD ENABLE SOME WAY OF CREATING AN EMPTY KEY TO CREATE "DIRECTORY" STRUCTURE, AWS CONSOLE DOES THIS.
    if mode == 'create':
440
        if bucket and not obj: 
441 442 443 444
            bucketrtn = bucket_check(module, s3, bucket)
            if bucketrtn is True:
                module.exit_json(msg="Bucket already exists.", changed=False)
            else:
bennojoy committed
445
                module.exit_json(msg="Bucket created succesfully", changed=create_bucket(module, s3, bucket))
446
        if bucket and obj:
447 448 449 450 451 452 453 454 455 456 457 458 459 460
            bucketrtn = bucket_check(module, s3, bucket)
            if obj.endswith('/'):
                dirobj = obj
            else:
                dirobj = obj + "/"
            if bucketrtn is True:
                keyrtn = key_check(module, s3, bucket, dirobj)
                if keyrtn is True: 
                    module.exit_json(msg="Bucket %s and key %s already exists."% (bucket, obj), changed=False)
                else:      
                    create_dirkey(module, s3, bucket, dirobj)
            if bucketrtn is False:
                created = create_bucket(module, s3, bucket)
                create_dirkey(module, s3, bucket, dirobj)
461 462 463 464 465 466 467 468 469 470 471 472 473 474 475

    # Support for grabbing the time-expired URL for an object in S3/Walrus.
    if mode == 'geturl':
        if bucket and obj:
            bucketrtn = bucket_check(module, s3, bucket)
            if bucketrtn is False:
                module.fail_json(msg="Bucket %s does not exist."%bucket, failed=True)
            else:
                keyrtn = key_check(module, s3, bucket, obj)
                if keyrtn is True:
                    get_download_url(module, s3, bucket, obj, expiry)
                else:
                    module.fail_json(msg="Key %s does not exist."%obj, failed=True)
        else:
            module.fail_json(msg="Bucket and Object parameters must be set", failed=True)
476

477 478 479 480 481 482 483 484 485 486 487 488
    if mode == 'getstr':
        if bucket and obj:
            bucketrtn = bucket_check(module, s3, bucket)
            if bucketrtn is False:
                module.fail_json(msg="Bucket %s does not exist."%bucket, failed=True)
            else:
                keyrtn = key_check(module, s3, bucket, obj)
                if keyrtn is True:
                    download_s3str(module, s3, bucket, obj)
                else:
                    module.fail_json(msg="Key %s does not exist."%obj, failed=True)

489 490
    module.exit_json(failed=False)

491 492 493
# import module snippets
from ansible.module_utils.basic import *
from ansible.module_utils.ec2 import *
lwade committed
494 495

main()