diff --git a/collection/plugins/modules/folder.py b/collection/plugins/modules/folder.py index 759febc..8995b08 100644 --- a/collection/plugins/modules/folder.py +++ b/collection/plugins/modules/folder.py @@ -37,7 +37,7 @@ options: required: false devices: description: - - List of devices to share folder with + - List of device ids to share folder with. Always share with self. required: false fs_watcher: description: @@ -84,12 +84,20 @@ author: EXAMPLES = ''' # Add a folder to synchronize with another device -- name: Add syncthing folder - syncthing_folder: - id: box - path: ~/box - devices: - - 1234-1234-1234-1234 + - name: Create folder + become: yes + become_user: syncthing + community.syncthing.folder: + host: http://localhost + unix_socket: /run/syncthing/syncthing.sock + config_file: /var/lib/syncthing/.config/syncthing/config.xml + id: 'my-folder-1' + label: 'my folder 1' + path: '/root/test-folder-1' + devices: + - '1234-1234-1234' + - '5678-5678-5678' + register: folder ''' RETURN = ''' @@ -98,129 +106,18 @@ response: type: dict ''' -import os -import json -import platform -from xml.etree.ElementTree import parse - -from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils.urls import fetch_url, url_argument_spec - -SYNCTHING_API_BASE_URI = "/rest" -if platform.system() == 'Windows': - DEFAULT_ST_CONFIG_LOCATION = '%localappdata%/Syncthing/config.xml' -elif platform.system() == 'Darwin': - DEFAULT_ST_CONFIG_LOCATION = '$HOME/Library/Application Support/Syncthing/config.xml' -else: - DEFAULT_ST_CONFIG_LOCATION = '$HOME/.config/syncthing/config.xml' - - -def make_headers(host, api_key, resource): - url = '{}{}/{}'.format(host, SYNCTHING_API_BASE_URI, resource) - headers = {'X-Api-Key': api_key } - return url, headers - -def get_key_from_filesystem(module): - try: - if module.params['config_file']: - stconfigfile = module.params['config_file'] - else: - stconfigfile = os.path.expandvars(DEFAULT_ST_CONFIG_LOCATION) - stconfig = parse(stconfigfile) - root = stconfig.getroot() - gui = root.find('gui') - api_key = gui.find('apikey').text - return api_key - except Exception: - module.fail_json(msg="Auto-configuration failed. Please specify" - "the API key manually.") - -def get_data_from_rest_api(module, resource): - url, headers = make_headers( - module.params['host'], module.params['api_key'], resource - ) - resp, info = fetch_url( - module, - url, - data=None, - headers=headers, - method='GET', - timeout=module.params['timeout'] - ) - - if not info or info['status'] != 200: - result['response'] = info - module.fail_json(msg='Error occured while calling host', **result) - - try: - content = resp.read() - except AttributeError: - result['content'] = info.pop('body', '') - result['response'] = str(info) - module.fail_json(msg='Error occured while reading response', **result) - - return json.loads(content) - -# Fetch Syncthing configuration -def get_config(module): - return get_data_from_rest_api(module, 'system/config') - -# Fetch Syncthing status -def get_status(module): - return get_data_from_rest_api(module, 'system/status') - -# Get the device name -> device ID mapping. -def get_devices_mapping(config): - return { - device['name']: device['deviceID'] for device in config['devices'] - } - -# Get the folder configuration from the global configuration, if it exists -def get_folder_config(folder_id, config): - for folder in config['folders']: - if folder['id'] == folder_id: - return folder - return None - -# Post the new configuration to Syncthing API -def post_config(module, config, result): - url, headers = make_headers( - module.params['host'], - module.params['api_key'], - 'system/config', - ) - headers['Content-Type'] = 'application/json' - - result['msg'] = config - resp, info = fetch_url( - module, url, data=json.dumps(config), headers=headers, - method='POST', timeout=module.params['timeout']) - - if not info or info['status'] != 200: - result['response'] = str(info) - module.fail_json(**result) +from ansible_collections.community.syncthing.plugins.module_utils.syncthing_api import SyncthingModule # Returns an object of a new folder -def create_folder(params, self_id, current_device_ids, devices_mapping): - # We need the current device ID as per the Syncthing API. - # If missing, Syncthing will add it alright, but we don't want to give - # the false idea that this configuration is different just because of that. +def create_folder(params, self_id, existing_device_ids): wanted_device_ids = {self_id} - for device_name_or_id in params['devices']: - if device_name_or_id in devices_mapping: - wanted_device_ids.add(devices_mapping[device_name_or_id]) - else: - # Purposefully do not validate we already know this device ID or - # name as per previous behavior. This will need to be fixed. - wanted_device_ids.add(device_name_or_id) + for device_id in params['devices']: + if device_id in existing_device_ids: + wanted_device_ids.add(device_id) # Keep the original ordering if collections are equivalent. # Again, for idempotency reasons. - device_ids = ( - current_device_ids - if set(current_device_ids) == wanted_device_ids - else sorted(wanted_device_ids) - ) + device_ids = sorted(wanted_device_ids) # Sort the device IDs to keep idem-potency devices = [ @@ -266,80 +163,102 @@ def create_folder(params, self_id, current_device_ids, devices_mapping): 'weakHashThresholdPct': 25 } +def update_folder(folder, params, self_id, existing_device_ids): + wanted_device_ids = {self_id} + for device_id in params['devices']: + if device_id in existing_device_ids: + wanted_device_ids.add(device_id) + + current_device_ids = {d['deviceID'] for d in folder['devices']} + device_ids = ( + current_device_ids + if current_device_ids == wanted_device_ids + else sorted(wanted_device_ids) + ) + + # Sort the device IDs to keep idem-potency + devices = [ + { + 'deviceID': device_id, + 'introducedBy': '', + } for device_id in device_ids + ] + + folder.update({ + 'devices': devices, + 'fsWatcherEnabled': params['fs_watcher'], + 'ignorePerms': params['ignore_perms'], + 'label': params['label'] if params['label'] else params['id'], + 'path': params['path'], + 'paused': True if params['state'] == 'paused' else False, + 'type': params['type'], + }) + + return folder + def run_module(): # module arguments - module_args = url_argument_spec() + module_args = {} module_args.update(dict( id=dict(type='str', required=True), label=dict(type='str', required=False), path=dict(type='path', required=False), - devices=dict(type='list', required=False, default=False), + devices=dict(type='list', required=False, default=[]), fs_watcher=dict(type='bool', default=True), ignore_perms=dict(type='bool', required=False, default=False), type=dict(type='str', default='sendreceive', choices=['sendreceive', 'sendonly', 'receiveonly']), - host=dict(type='str', default='http://127.0.0.1:8384'), - api_key=dict(type='str', required=False, no_log=True), - config_file=dict(type='path', required=False), - timeout=dict(type='int', default=30), state=dict(type='str', default='present', choices=['absent', 'present', 'pause']), )) - # seed the result dict in the object - result = { - "changed": False, - "response": None, - } + module = SyncthingModule( + api_url='/rest/config/folders', + argument_spec=module_args + ) - # the AnsibleModule object will be our abstraction working with Ansible - module = AnsibleModule( - argument_spec=module_args, - supports_check_mode=True + device_module = SyncthingModule( + api_url='/rest/config/devices', + argument_spec=module_args ) if module.params['state'] != 'absent' and not module.params['path']: - module.fail_json(msg='You must provide a path when creating', **result) + module.fail_json(msg='You must provide a path when creating') - if module.check_mode: - return result + folder_exists = False + folder = module.get_call(target=module.params['id']) + if 'id' in folder and folder['id'] == module.params['id']: + folder_exists = True - # Auto-configuration: Try to fetch API key from filesystem - if not module.params['api_key']: - module.params['api_key'] = get_key_from_filesystem(module) - - config = get_config(module) - self_id = get_status(module)['myID'] - devices_mapping = get_devices_mapping(config) if module.params['state'] == 'absent': - # Remove folder from list, if found - for idx, folder in enumerate(config['folders']): - if folder['id'] == module.params['id']: - config['folders'].pop(idx) - result['changed'] = True - break + if folder_exists: + if module.check_mode: + module.result['changed'] = True + else: + module.delete_call(target=folder['id']) else: - folder_config = get_folder_config(module.params['id'], config) - folder_config_devices = ( - [d['deviceID'] for d in folder_config['devices']] if folder_config else [] - ) - folder_config_wanted = create_folder( - module.params, self_id, folder_config_devices, devices_mapping - ) + devices_list = device_module.get_call() + existing_device_ids = [device['deviceID'] for device in devices_list] + self_id = device_module.result['response']['x-syncthing-id'] - if folder_config is None: - config['folders'].append(folder_config_wanted) - result['changed'] = True - elif folder_config != folder_config_wanted: - # Update the folder configuration in-place - folder_config.clear() - folder_config.update(folder_config_wanted) - result['changed'] = True + if folder_exists: # exists but maybe needs changing + if module.check_mode: + module.result['changed'] = True + else: + folder_with_updated_data = update_folder( + folder, module.params, self_id, existing_device_ids + ) + module.patch_call(data=folder_with_updated_data, target=folder['id']) + else: # Doesn't exist but needs to be added + if module.check_mode: + module.result['changed'] = True + else: + folder_config_wanted = create_folder( + module.params, self_id, existing_device_ids + ) + module.post_call(data=folder_config_wanted) - if result['changed']: - post_config(module, config, result) - - module.exit_json(**result) + module.exit_json() def main(): run_module()