diff --git a/core/imageroot/usr/local/agent/actions/list-backup-repositories/50list_backup_repositories b/core/imageroot/usr/local/agent/actions/list-backup-repositories/50list_backup_repositories new file mode 100755 index 000000000..15199f81a --- /dev/null +++ b/core/imageroot/usr/local/agent/actions/list-backup-repositories/50list_backup_repositories @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 + +# +# Copyright (C) 2024 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +import sys +import json +import agent +import asyncio +import os +import time +from datetime import datetime, timezone + +rdb = agent.redis_connect(privileged=False) +module_id = os.environ['MODULE_ID'] +module_uuid = os.environ['MODULE_UUID'] +module_ui_name = rdb.get(f'module/{module_id}/ui_name') or "" +image_name = agent.get_image_name_from_url(os.environ["IMAGE_URL"]) +cluster_uuid = rdb.get("cluster/uuid") or "" +odests = {} +for krepo in rdb.scan_iter('cluster/backup_repository/*'): + dest_uuid = krepo.removeprefix('cluster/backup_repository/') + odests[dest_uuid] = rdb.hgetall(krepo) +rdb.close() + +# +# Fetch data from all backup destinations +# + +async def read_destination_repo(dest_uuid, dest_path): + proc = await asyncio.create_subprocess_exec('rclone-wrapper', dest_uuid, 'lsjson', f'REMOTE_PATH/{dest_path}/config', stdout=asyncio.subprocess.PIPE) + # Return the first and only element of the expected JSON array + out, _ = await proc.communicate() + if out == b'[\n]\n' or not out: + data = {} + else: + data = json.loads(out)[0] + return data + +async def read_destination_meta(dest_uuid, dest_path): + proc = await asyncio.create_subprocess_exec('rclone-wrapper', dest_uuid, 'cat', f'REMOTE_PATH/{dest_path}.json', stdout=asyncio.subprocess.PIPE) + out, _ = await proc.communicate() + if out: + data = json.loads(out) + else: + data = {} + return data + + +async def get_destination_info(dest_uuid, odest): + global cluster_uuid, module_id, module_uuid, module_ui_name, image_name + + dest_path = f"{image_name}/{module_uuid}" + + async with asyncio.TaskGroup() as tg: + task_repo = tg.create_task(read_destination_repo(dest_uuid, dest_path)) + task_meta = tg.create_task(read_destination_meta(dest_uuid, dest_path)) + + info = { + "module_id": module_id, + "module_ui_name": module_ui_name, + "node_fqdn": "", + "path": dest_path, + "name": image_name, + "uuid": module_uuid, + "timestamp": 0, + "repository_id" : dest_uuid, + "repository_name": odest["name"], + "repository_provider": odest["provider"], + "repository_url": odest["url"], + "installed_instance": module_id, + "installed_instance_ui_name": module_ui_name, + "is_generated_locally": False, + } + + result_repo = task_repo.result() + if not result_repo: + return None + + try: + # Obtain from lsjson the repository creation timestamp + info['timestamp'] = int(time.mktime(datetime.fromisoformat(result_repo["ModTime"]).timetuple())) + except: + info['timestamp'] = int(time.time()) + + result_meta = task_meta.result() + if "cluster_uuid" in result_meta and result_meta["cluster_uuid"] == cluster_uuid: + info['is_generated_locally'] = True + info.update(result_meta) # merge two dictionaries + + return info + +async def print_destinations(odests): + tasks = [] + async with asyncio.TaskGroup() as tg: + for dest_uuid, odest in odests.items(): + tasks.append(tg.create_task(get_destination_info(dest_uuid, odest))) + destinations = list(filter(lambda r: r, [task.result() for task in tasks])) + json.dump(destinations, fp=sys.stdout) + +asyncio.run(print_destinations(odests)) diff --git a/core/imageroot/usr/local/agent/actions/list-backup-repositories/validate-output.json b/core/imageroot/usr/local/agent/actions/list-backup-repositories/validate-output.json new file mode 100644 index 000000000..0602e1cad --- /dev/null +++ b/core/imageroot/usr/local/agent/actions/list-backup-repositories/validate-output.json @@ -0,0 +1,107 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "list-backup-repositories output", + "$id": "http://schema.nethserver.org/module/list-backup-repositories-output.json", + "description": "Return a list of the module's Restic backup repositories. The format is the same of cluster/read-backup-repositories.", + "examples": [ + [ + { + "module_id": "loki1", + "module_ui_name": "My Loki", + "node_fqdn": "rl1.dp.nethserver.net", + "path": "loki/35f45b73-f81e-467b-b622-96ec3b7fec19", + "name": "loki", + "uuid": "35f45b73-f81e-467b-b622-96ec3b7fec19", + "timestamp": 1721405723, + "repository_id": "14030a59-a4e6-54cc-b8ea-cd5f97fe44c8", + "repository_name": "BackBlaze repo1", + "repository_provider": "backblaze", + "repository_url": "b2:ns8-davidep", + "installed_instance": "loki1", + "installed_instance_ui_name": "My Loki", + "is_generated_locally": true + } + ] + ], + "type": "array", + "items": { + "type": "object", + "properties": { + "module_id": { + "type": "string", + "description": "Original module ID value." + }, + "module_ui_name": { + "type": "string", + "description": "Original module label, assigned by the user." + }, + "node_fqdn": { + "type": "string", + "description": "The FQDN of the node where the module of the backup is hosted." + }, + "path": { + "type": "string", + "description": "Path of the repository, relative to the backup destination." + }, + "name": { + "type": "string", + "description": "Name of the module. It is equal to the module image name." + }, + "uuid": { + "type": "string", + "description": "Universal, unique identifier of the module instance." + }, + "timestamp": { + "type": "integer", + "description": "Unix timestamp of the last backup run." + }, + "repository_id": { + "type": "string", + "description": "UUID of the backup destination." + }, + "repository_name": { + "type": "string", + "description": "Human readable name of the backup destination." + }, + "repository_provider": { + "type": "string", + "description": "Type of backup destination provider, e.g. SMB, S3..." + }, + "repository_url": { + "type": "string", + "description": "Restic URL of the backup destination." + }, + "installed_instance": { + "type": "string", + "description": "If the backup belongs to an installed module instance this is its module ID." + }, + "installed_instance_ui_name": { + "type": "string", + "description": "If the backup belongs to an installed module instance this is its module friendly name." + }, + "is_generated_locally": { + "type": [ + "boolean", + "null" + ], + "description": "Tells if the backup originates from the local cluster or from another cluster. The null value is returned if this information is missing completely, as it happens in old backups." + } + }, + "required": [ + "module_id", + "module_ui_name", + "node_fqdn", + "path", + "name", + "uuid", + "timestamp", + "repository_id", + "repository_name", + "repository_provider", + "repository_url", + "installed_instance", + "installed_instance_ui_name", + "is_generated_locally" + ] + } +} diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/list-backup-repositories/validate-output.json b/core/imageroot/var/lib/nethserver/cluster/actions/list-backup-repositories/validate-output.json index ed3f734ea..c716c5fcf 100644 --- a/core/imageroot/var/lib/nethserver/cluster/actions/list-backup-repositories/validate-output.json +++ b/core/imageroot/var/lib/nethserver/cluster/actions/list-backup-repositories/validate-output.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "list-backup-repositories output", "$id": "http://schema.nethserver.org/cluster/list-backup-repositories-output.json", - "description": "Get the list of available backup repositories and the status of cluster backup password", + "description": "Get the list of available backup destinations and the status of cluster backup password", "examples": [ { "repositories": [