├── .gitignore ├── README.md └── backup.py /.gitignore: -------------------------------------------------------------------------------- 1 | data/ 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Workers KV backup 2 | 3 | Copy the content of a Workers KV namespace locally. 4 | 5 | ## Usage 6 | 7 | ``` 8 | python3 ./backup.py --api_token=... --cf_account_id=... --kv_namespace_id=... 9 | ``` 10 | 11 | Flags: 12 | - `api_token`: Cloudflare's API token (Permission: Workers KV readonly) 13 | - `cf_account_id`: Cloudflare's Account ID 14 | - `kv_namespace_id`: Workers KV's namespace ID 15 | - `dest`: Optional, backup location (default is `./data`) 16 | -------------------------------------------------------------------------------- /backup.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import requests 3 | import os 4 | import urllib.parse 5 | from multiprocessing import Pool, freeze_support 6 | from functools import partial 7 | 8 | def get(item, args): 9 | name = item['name'] 10 | dest = "%s/%s" % (args.dest, name) 11 | if os.path.exists(dest): 12 | print("%s already exists; skipping." % name) 13 | return 14 | print("downloading %s" % name) 15 | headers = {'Authorization': 'Bearer %s' % args.api_token} 16 | url = 'https://api.cloudflare.com/client/v4/accounts/%s/storage/kv/namespaces/%s/values/%s'\ 17 | % (args.cf_account_id, args.kv_namespace_id, urllib.parse.quote(name).replace("/", "%2F")) 18 | r = requests.get(url, headers=headers) 19 | if r.status_code != 200: 20 | print(f"Failed to download {name}. Status code: {r.status_code}, Response: {r.text}") 21 | return 22 | if not os.path.exists(os.path.dirname(dest)): 23 | os.makedirs(os.path.dirname(dest)) 24 | with open(dest, "wb+") as f: 25 | f.write(r.content) 26 | 27 | def main(args): 28 | cursor = "" 29 | 30 | if not args.api_token: 31 | raise Exception("Missing api token") 32 | if not args.cf_account_id: 33 | raise Exception("Missing cf account tag") 34 | if not args.kv_namespace_id: 35 | raise Exception("Missing kv namespace id") 36 | 37 | if not os.path.exists(args.dest): 38 | os.makedirs(args.dest) 39 | 40 | pool = Pool(processes=4) 41 | 42 | while True: 43 | headers = {'Authorization': 'Bearer %s' % args.api_token} 44 | url = 'https://api.cloudflare.com/client/v4/accounts/%s/storage/kv/namespaces/%s/keys?&cursor=%s'\ 45 | % (args.cf_account_id, args.kv_namespace_id, cursor) 46 | r = requests.get(url, headers=headers) 47 | if r.status_code != 200: 48 | print(f"Failed to fetch keys. Status code: {r.status_code}, Response: {r.text}") 49 | break 50 | 51 | d = r.json() 52 | print("fetched %d keys" % len(d['result'])) 53 | 54 | pool.map(partial(get, args=args), d['result']) 55 | 56 | if d["result_info"]["cursor"]: 57 | cursor = d["result_info"]["cursor"] 58 | else: 59 | break 60 | 61 | pool.close() 62 | pool.terminate() 63 | 64 | if __name__ == '__main__': 65 | freeze_support() 66 | 67 | my_parser = argparse.ArgumentParser(description='List the content of a folder') 68 | 69 | my_parser.add_argument('--api_token', type=str, help='Cloudflare\'s API token') 70 | my_parser.add_argument('--cf_account_id', type=str, help='Cloudflare\'s account tag') 71 | my_parser.add_argument('--kv_namespace_id', type=str, help='KV namespace ID') 72 | my_parser.add_argument('--dest', type=str, help='Dest backup directory', default="./data") 73 | 74 | args = my_parser.parse_args() 75 | 76 | main(args) --------------------------------------------------------------------------------